From eabc5d3b17875edf666603f798e461eacd5a3574 Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 10:35:45 +0000 Subject: [PATCH 01/12] feat(sdk): add the Platform API and managed-user mode A tenant client built with api_key can create and get managed users through client.platform.managed_users, with typed ManagedUser values and PlatformError failures that never carry upstream bodies. Adding managed_user acts as that user: REST requests send the key and a Pine-Managed-User header, the Socket.IO handshake sends token and managed_user, and connect() resolves the Pine user ID through auth.me(). client_name adds a Pine-Client header to every REST request. Co-Authored-By: Claude Opus 5.5 --- src/pine_assistant/__init__.py | 10 +- src/pine_assistant/client.py | 67 ++++- src/pine_assistant/errors.py | 7 + src/pine_assistant/models/platform.py | 27 ++ src/pine_assistant/platform_api.py | 114 +++++++++ src/pine_assistant/transport/http.py | 95 ++++--- src/pine_assistant/transport/socketio.py | 13 +- tests/test_platform.py | 305 +++++++++++++++++++++++ 8 files changed, 603 insertions(+), 35 deletions(-) create mode 100644 src/pine_assistant/models/platform.py create mode 100644 src/pine_assistant/platform_api.py create mode 100644 tests/test_platform.py diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index 46ace45..94338bb 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -12,7 +12,7 @@ from pine_assistant.auth import Auth, SyncAuth from pine_assistant.chat import ChatEvent from pine_assistant.client import AsyncPineAI, PineAI -from pine_assistant.errors import AuthError, ConnectionError, PineAIError, SessionError +from pine_assistant.errors import AuthError, ConnectionError, PineAIError, PlatformError, SessionError from pine_assistant.models.auth import AuthIdentity, AuthTicket, RedeemedTicket from pine_assistant.models.events import ( SUPPORTED_EVENTS, @@ -20,6 +20,7 @@ S2CEvent, is_supported_event, ) +from pine_assistant.models.platform import ManagedUser from pine_assistant.models.session import ( SessionInfo, SessionListResponse, @@ -28,6 +29,7 @@ SessionOutcomeRating, SessionOutcomesPage, ) +from pine_assistant.platform_api import ManagedUsersAPI, PlatformAPI, SyncManagedUsersAPI, SyncPlatformAPI from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI __version__ = "0.5.0rc1" @@ -38,6 +40,11 @@ "SyncAuth", "SessionsAPI", "SyncSessionsAPI", + "PlatformAPI", + "SyncPlatformAPI", + "ManagedUsersAPI", + "SyncManagedUsersAPI", + "ManagedUser", "AuthIdentity", "AuthTicket", "RedeemedTicket", @@ -51,6 +58,7 @@ "PineAIError", "AuthError", "SessionError", + "PlatformError", "ConnectionError", "C2SEvent", "S2CEvent", diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index dddfdb9..11d6351 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -5,6 +5,7 @@ import asyncio import logging import os +import re import uuid from collections.abc import AsyncGenerator, Callable from pathlib import Path @@ -17,6 +18,7 @@ from pine_assistant.errors import ConnectionError from pine_assistant.models.events import C2SEvent from pine_assistant.models.form import FormSubmissionResult, FormToUserData, encode_form_answers +from pine_assistant.platform_api import API_KEY_PREFIX, PlatformAPI, SyncPlatformAPI, validate_external_id from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI from pine_assistant.transport.http import DEFAULT_API_BASE_PATH, DEFAULT_BASE_URL, HttpClient, SyncHttpClient from pine_assistant.transport.socketio import SocketIOManager @@ -31,9 +33,35 @@ FORM_RESPONSE_TIMEOUT_S = 10.0 FORM_RESPONSE_MAX_TIMEOUT_S = 30.0 +_CLIENT_NAME = re.compile(r"[A-Za-z0-9._-]{1,64}") + _logger = logging.getLogger(__name__) +def _platform_identity( + access_token: str | None, + user_id: str | None, + api_key: str | None, + managed_user: str | None, + client_name: str | None, +) -> str | None: + """Validate the credential mode and return the bearer the client sends. + + Error messages never include the key or the external ID. + """ + if client_name is not None and not (isinstance(client_name, str) and _CLIENT_NAME.fullmatch(client_name)): + raise ValueError("client_name must be 1-64 letters, digits, '.', '_' or '-'") + if api_key is None and managed_user is None: + return access_token + if access_token is not None or user_id is not None: + raise ValueError("api_key cannot be combined with access_token or user_id") + if not isinstance(api_key, str) or not api_key.startswith(API_KEY_PREFIX): + raise ValueError(f"api_key must be a Pine Platform secret key starting with {API_KEY_PREFIX}") + if managed_user is not None: + validate_external_id(managed_user, "managed_user") + return api_key + + def _get_or_create_device_id(provided: str | None = None) -> str: """Resolve a stable device_id. @@ -69,6 +97,15 @@ class AsyncPineAI: """Async Pine AI client (primary). A client tracks one session. Concurrent sessions need one client each. + + Authenticate either as a Pine user with ``access_token`` (and ``user_id`` + for Socket.IO), or as a Pine Platform tenant with its secret ``api_key``. + A tenant client calls the Platform API through ``platform``; adding + ``managed_user`` (the tenant's ``external_id``) makes it act as that + managed user: every user-scoped REST request carries the key and a + ``Pine-Managed-User`` header, the Socket.IO handshake carries both, and + ``connect()`` resolves the managed user's Pine user ID itself. + ``client_name`` adds a ``Pine-Client`` header to every REST request. """ def __init__( @@ -83,10 +120,15 @@ def __init__( api_base_path: str = DEFAULT_API_BASE_PATH, http_client: httpx.AsyncClient | None = None, http_transport: httpx.AsyncBaseTransport | None = None, + api_key: str | None = None, + managed_user: str | None = None, + client_name: str | None = None, ): + access_token = _platform_identity(access_token, user_id, api_key, managed_user, client_name) self._base_url = base_url self._access_token = access_token self._user_id = user_id + self._managed_user = managed_user self._device_id = _get_or_create_device_id(device_id) self._transports = transports self._ready_timeout = ready_timeout @@ -97,9 +139,12 @@ def __init__( api_base_path=api_base_path, client=http_client, transport=http_transport, + managed_user=managed_user, + client_name=client_name, ) self.auth = Auth(self.http) self.sessions = SessionsAPI(self.http) + self.platform = PlatformAPI(self.http) self._sio: SocketIOManager | None = None self._chat: ChatEngine | None = None @@ -115,6 +160,17 @@ async def __aexit__(self, *_exc_info: object) -> None: await self.aclose() async def connect(self, access_token: str | None = None, user_id: str | None = None) -> None: + """Open Socket.IO and wait until the backend reports it ready. + + With ``api_key``/``managed_user`` the identity is fixed at construction; + the managed user's Pine user ID, which envelopes carry, is read once + from ``auth.me()`` and reused for the client's lifetime. + """ + if self._managed_user is not None: + if access_token is not None or user_id is not None: + raise ValueError("a managed-user client cannot connect with another access_token or user_id") + if self._user_id is None: + self._user_id = (await self.auth.me()).user_id token = access_token or self._access_token uid = user_id or self._user_id if not token or not uid: @@ -128,6 +184,7 @@ async def connect(self, access_token: str | None = None, user_id: str | None = N device_id=self._device_id, transports=self._transports, ready_timeout=self._ready_timeout, + managed_user=self._managed_user, ) self._chat = ChatEngine(self._sio, check_session_state=self._session_state) await self._sio.connect() @@ -433,6 +490,8 @@ class PineAI: REST resources return concrete values through ``httpx.Client``. Real-time Socket.IO requires an event loop and is intentionally available only on :class:`AsyncPineAI`; this client does not create a hidden loop or thread. + ``api_key``, ``managed_user`` and ``client_name`` behave as on + :class:`AsyncPineAI`. """ def __init__( @@ -443,16 +502,22 @@ def __init__( api_base_path: str = DEFAULT_API_BASE_PATH, http_client: httpx.Client | None = None, http_transport: httpx.BaseTransport | None = None, + api_key: str | None = None, + managed_user: str | None = None, + client_name: str | None = None, ) -> None: self.http = SyncHttpClient( base_url=base_url, - token=access_token, + token=_platform_identity(access_token, None, api_key, managed_user, client_name), api_base_path=api_base_path, client=http_client, transport=http_transport, + managed_user=managed_user, + client_name=client_name, ) self.auth = SyncAuth(self.http) self.sessions = SyncSessionsAPI(self.http) + self.platform = SyncPlatformAPI(self.http) def __enter__(self) -> "PineAI": return self diff --git a/src/pine_assistant/errors.py b/src/pine_assistant/errors.py index d8ac9fb..d6adcee 100644 --- a/src/pine_assistant/errors.py +++ b/src/pine_assistant/errors.py @@ -37,6 +37,13 @@ def __init__( super().__init__(code, message, details, status_code=status_code) +class PlatformError(PineAIError): + """A Platform API failure: stable ``code`` and ``status_code``, never the upstream body.""" + + def __init__(self, message: str, code: str = "platform_error", *, status_code: int | None = None): + super().__init__(code, message, status_code=status_code) + + class ConnectionError(PineAIError): def __init__(self, message: str): super().__init__("connection_error", message) diff --git a/src/pine_assistant/models/platform.py b/src/pine_assistant/models/platform.py new file mode 100644 index 0000000..d8ce276 --- /dev/null +++ b/src/pine_assistant/models/platform.py @@ -0,0 +1,27 @@ +"""Typed responses for the Pine Platform API.""" + +from pydantic import BaseModel, ConfigDict, field_validator + +from pine_assistant.models.session import SessionInfo + + +class ManagedUser(BaseModel): + """One end user of a Platform integration, addressed by its ``external_id``. + + ``id`` is the Pine user ID that ``/api/v2`` and Socket.IO payloads carry; + the Platform API never accepts it as input. + """ + + model_config = ConfigDict(extra="allow") + + id: str + external_id: str + email: str + name: str + phone: str | None + created_at: str + + @field_validator("id", mode="before") + @classmethod + def normalize_id(cls, value: object) -> str: + return SessionInfo.normalize_id(value) diff --git a/src/pine_assistant/platform_api.py b/src/pine_assistant/platform_api.py new file mode 100644 index 0000000..66d3cd0 --- /dev/null +++ b/src/pine_assistant/platform_api.py @@ -0,0 +1,114 @@ +""" +Pine Platform API — enterprise (tenant) resources authenticated by a secret key. + +A tenant's secret key (``pine_sk_live_...`` / ``pine_sk_test_...``) belongs on +its servers only. These requests act as the tenant itself and never carry a +``Pine-Managed-User`` header. +""" + +from __future__ import annotations + +import re +from urllib.parse import quote + +from pydantic import ValidationError + +from pine_assistant.errors import PineAIError, PlatformError +from pine_assistant.models.platform import ManagedUser +from pine_assistant.transport.http import HttpClient, SyncHttpClient + +API_KEY_PREFIX = "pine_sk_" +_EXTERNAL_ID = re.compile(r"[A-Za-z0-9_:|-][A-Za-z0-9._:|-]{0,127}") +_MANAGED_USERS = "/platform/v1/managed-users" + + +def validate_external_id(value: str, field: str = "external_id") -> str: + """Check the backend's external_id rule; the value is never echoed on failure.""" + if not isinstance(value, str) or not _EXTERNAL_ID.fullmatch(value): + raise ValueError( + f"{field} must be 1-128 letters, digits, '.', '_', ':', '|' or '-' and must not start with '.'" + ) + return value + + +def _create_body(external_id: str, email: str, name: str, phone: str | None) -> dict[str, str]: + body = {"external_id": validate_external_id(external_id), "email": email, "name": name} + if phone is not None: + body["phone"] = phone + return body + + +def _path(external_id: str) -> str: + return f"{_MANAGED_USERS}/{quote(validate_external_id(external_id), safe='')}" + + +def _platform_failure(error: PineAIError) -> PlatformError: + return PlatformError("Pine Platform API request failed", error.code, status_code=error.status_code) + + +def _parse_managed_user(payload: object) -> ManagedUser: + try: + return ManagedUser.model_validate(payload) + except ValidationError: + raise PlatformError("Pine Platform API returned an invalid managed user", "invalid_response") from None + + +class ManagedUsersAPI: + def __init__(self, http: HttpClient): + self._http = http + + async def create(self, external_id: str, *, email: str, name: str, phone: str | None = None) -> ManagedUser: + """Create the managed user for ``external_id``, or return the existing one unchanged. + + Idempotent per integration and ``external_id``: a repeated call returns + the stored user even when ``email``, ``name`` or ``phone`` differ. + ``phone`` is an E.164 number you have verified. + """ + body = _create_body(external_id, email, name, phone) + try: + payload = await self._http.post(_MANAGED_USERS, body, as_tenant=True) + except PineAIError as exc: + raise _platform_failure(exc) from None + return _parse_managed_user(payload) + + async def get(self, external_id: str) -> ManagedUser: + """Return the managed user for ``external_id``; another integration's user is a 404.""" + path = _path(external_id) + try: + payload = await self._http.get(path, as_tenant=True) + except PineAIError as exc: + raise _platform_failure(exc) from None + return _parse_managed_user(payload) + + +class SyncManagedUsersAPI: + def __init__(self, http: SyncHttpClient): + self._http = http + + def create(self, external_id: str, *, email: str, name: str, phone: str | None = None) -> ManagedUser: + body = _create_body(external_id, email, name, phone) + try: + payload = self._http.post(_MANAGED_USERS, body, as_tenant=True) + except PineAIError as exc: + raise _platform_failure(exc) from None + return _parse_managed_user(payload) + + def get(self, external_id: str) -> ManagedUser: + path = _path(external_id) + try: + payload = self._http.get(path, as_tenant=True) + except PineAIError as exc: + raise _platform_failure(exc) from None + return _parse_managed_user(payload) + + +class PlatformAPI: + """Platform API resources for a client built with ``api_key``.""" + + def __init__(self, http: HttpClient): + self.managed_users = ManagedUsersAPI(http) + + +class SyncPlatformAPI: + def __init__(self, http: SyncHttpClient): + self.managed_users = SyncManagedUsersAPI(http) diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index f09b67f..6ebc8db 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -11,6 +11,8 @@ DEFAULT_BASE_URL = "https://www.19pine.ai" DEFAULT_API_BASE_PATH = "/api" _USER_AGENT = "pine-assistant-sdk" +MANAGED_USER_HEADER = "Pine-Managed-User" +CLIENT_HEADER = "Pine-Client" # Public HTTPX timeout inputs accepted by both sync and async clients. HTTPX's # internal ``TimeoutTypes`` alias is not exported in every supported release. @@ -87,6 +89,35 @@ def _json_data(response: httpx.Response) -> Any: return payload +def _identity_headers( + http: "HttpClient | SyncHttpClient", authenticated: bool, token: str | None, as_tenant: bool = False +) -> dict[str, str]: + """Headers naming who a request acts as. + + The managed user belongs to the client's own API key, so it is sent only + with that key, never with a per-request ``token`` override, and never on + Platform API requests the tenant makes as itself (``as_tenant``). + """ + headers: dict[str, str] = {"Content-Type": "application/json"} + actual_token = http._token if token is None else token + if authenticated and actual_token: + headers["Authorization"] = f"Bearer {actual_token}" + if token is None and http._managed_user and not as_tenant: + headers[MANAGED_USER_HEADER] = http._managed_user + if http._client_name: + headers[CLIENT_HEADER] = http._client_name + return headers + + +def _enforce_identity(request: httpx.Request, headers: dict[str, str]) -> None: + """Make the identity headers exactly the computed ones, whatever an injected client defaults.""" + for name in ("Authorization", MANAGED_USER_HEADER): + if name in headers: + request.headers[name] = headers[name] + else: + request.headers.pop(name, None) + + class HttpClient: """Async HTTP client; injected clients remain caller-owned.""" @@ -99,11 +130,15 @@ def __init__( client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, timeout: TimeoutConfig = 30.0, + managed_user: str | None = None, + client_name: str | None = None, ): if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") self._api_base_url = _api_url(base_url, api_base_path) self._token = token + self._managed_user = managed_user + self._client_name = client_name self._owns_client = client is None self._client = client or httpx.AsyncClient( headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}, @@ -115,12 +150,8 @@ def __init__( def set_token(self, token: str | None) -> None: self._token = token - def _auth_headers(self, authenticated: bool, token: str | None = None) -> dict[str, str]: - headers: dict[str, str] = {"Content-Type": "application/json"} - actual_token = self._token if token is None else token - if authenticated and actual_token: - headers["Authorization"] = f"Bearer {actual_token}" - return headers + def _auth_headers(self, authenticated: bool, token: str | None = None, as_tenant: bool = False) -> dict[str, str]: + return _identity_headers(self, authenticated, token, as_tenant) def _url(self, path: str) -> str: # Absolute paths keep an injected client's base_url from changing the @@ -138,9 +169,9 @@ async def _request( token: str | None = None, files: Any = None, extra_headers: dict[str, str] | None = None, + as_tenant: bool = False, ) -> Any: - actual_token = self._token if token is None else token - headers = self._auth_headers(authenticated, token) + headers = self._auth_headers(authenticated, token, as_tenant) if extra_headers: headers.update(extra_headers) if files is not None: @@ -151,11 +182,8 @@ async def _request( method, self._url(path), json=body, params=params, headers=headers, files=files, ) # An injected client may carry default authentication. Never let it - # authenticate an anonymous request or a request for another user. - if authenticated and actual_token: - request.headers["Authorization"] = f"Bearer {actual_token}" - else: - request.headers.pop("Authorization", None) + # authenticate an anonymous request or act for another user. + _enforce_identity(request, headers) response = await self._client.send(request, follow_redirects=False, auth=None) except httpx.TimeoutException as exc: raise PineAIError("timeout", "Pine API request timed out") from exc @@ -164,13 +192,17 @@ async def _request( return _json_data(response) async def get(self, path: str, authenticated: bool = True, *, token: str | None = None, - params: dict[str, str | int] | None = None) -> Any: - return await self._request("GET", path, authenticated=authenticated, token=token, params=params) + params: dict[str, str | int] | None = None, as_tenant: bool = False) -> Any: + return await self._request( + "GET", path, authenticated=authenticated, token=token, params=params, as_tenant=as_tenant, + ) async def post(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, - *, token: str | None = None, headers: dict[str, str] | None = None) -> Any: + *, token: str | None = None, headers: dict[str, str] | None = None, + as_tenant: bool = False) -> Any: return await self._request( "POST", path, body=body, authenticated=authenticated, token=token, extra_headers=headers, + as_tenant=as_tenant, ) async def put(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, @@ -207,11 +239,15 @@ def __init__( client: httpx.Client | None = None, transport: httpx.BaseTransport | None = None, timeout: TimeoutConfig = 30.0, + managed_user: str | None = None, + client_name: str | None = None, ) -> None: if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") self._api_base_url = _api_url(base_url, api_base_path) self._token = token + self._managed_user = managed_user + self._client_name = client_name self._owns_client = client is None self._client = client or httpx.Client( headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}, @@ -223,12 +259,8 @@ def __init__( def set_token(self, token: str | None) -> None: self._token = token - def _auth_headers(self, authenticated: bool, token: str | None = None) -> dict[str, str]: - headers = {"Content-Type": "application/json"} - actual_token = self._token if token is None else token - if authenticated and actual_token: - headers["Authorization"] = f"Bearer {actual_token}" - return headers + def _auth_headers(self, authenticated: bool, token: str | None = None, as_tenant: bool = False) -> dict[str, str]: + return _identity_headers(self, authenticated, token, as_tenant) def _url(self, path: str) -> str: return f"{self._api_base_url}/{path.lstrip('/')}" @@ -244,9 +276,9 @@ def _request( token: str | None = None, files: Any = None, extra_headers: dict[str, str] | None = None, + as_tenant: bool = False, ) -> Any: - actual_token = self._token if token is None else token - headers = self._auth_headers(authenticated, token) + headers = self._auth_headers(authenticated, token, as_tenant) if extra_headers: headers.update(extra_headers) if files is not None: @@ -255,10 +287,7 @@ def _request( request = self._client.build_request( method, self._url(path), json=body, params=params, headers=headers, files=files, ) - if authenticated and actual_token: - request.headers["Authorization"] = f"Bearer {actual_token}" - else: - request.headers.pop("Authorization", None) + _enforce_identity(request, headers) response = self._client.send(request, follow_redirects=False, auth=None) except httpx.TimeoutException as exc: raise PineAIError("timeout", "Pine API request timed out") from exc @@ -267,13 +296,17 @@ def _request( return _json_data(response) def get(self, path: str, authenticated: bool = True, *, token: str | None = None, - params: dict[str, str | int] | None = None) -> Any: - return self._request("GET", path, authenticated=authenticated, token=token, params=params) + params: dict[str, str | int] | None = None, as_tenant: bool = False) -> Any: + return self._request( + "GET", path, authenticated=authenticated, token=token, params=params, as_tenant=as_tenant, + ) def post(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, - *, token: str | None = None, headers: dict[str, str] | None = None) -> Any: + *, token: str | None = None, headers: dict[str, str] | None = None, + as_tenant: bool = False) -> Any: return self._request( "POST", path, body=body, authenticated=authenticated, token=token, extra_headers=headers, + as_tenant=as_tenant, ) def put(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, diff --git a/src/pine_assistant/transport/socketio.py b/src/pine_assistant/transport/socketio.py index 33ccf57..073698d 100644 --- a/src/pine_assistant/transport/socketio.py +++ b/src/pine_assistant/transport/socketio.py @@ -1,7 +1,8 @@ """ Socket.IO connection manager — spec sections 3.1, 5.1.2. -Connection: wss://{baseUrl}/api/v2/socket.io/ with auth={token}. +Connection: wss://{baseUrl}/api/v2/socket.io/ with auth={token}, plus +managed_user when a Platform API key acts for a managed user. Waits for `ready` event before resolving connect(). """ @@ -41,9 +42,11 @@ def __init__( device_id: str | None = None, transports: list[str] | None = None, ready_timeout: float = 15.0, + managed_user: str | None = None, ): self._base_url = base_url self._token = token + self._managed_user = managed_user self._user_id = user_id self._device_id = device_id or str(uuid.uuid4()) self._transports = transports or ["websocket"] @@ -144,7 +147,7 @@ async def disconnect(_reason: str = "") -> None: try: await self._sio.connect( self._base_url, - auth={"token": self._token}, + auth=self._handshake_auth(), transports=self._transports, socketio_path=SOCKETIO_PATH, wait_timeout=self._ready_timeout, @@ -190,6 +193,12 @@ async def disconnect(_reason: str = "") -> None: if key in self._form_submission_pending_keys } + def _handshake_auth(self) -> dict[str, str]: + auth = {"token": self._token} + if self._managed_user: + auth["managed_user"] = self._managed_user + return auth + def _track_membership(self, event_type: str, session_id: str | None) -> None: """Remember which sessions to re-join after a reconnect. diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..d31f18a --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,305 @@ +"""Platform API and managed-user mode against a fake backend on 127.0.0.1.""" + +import json +from dataclasses import dataclass, field +from typing import Any + +import httpx +import pytest +import socketio +from aiohttp import web + +from pine_assistant import AsyncPineAI, ManagedUser, PineAI, PineAIError, PlatformError + +API_KEY = "pine_sk_test_c2VjcmV0LWtleS1mb3ItdGVzdHM" +EXTERNAL_ID = "acme:user|42-x" +ENCODED_EXTERNAL_ID = "acme%3Auser%7C42-x" +PINE_USER_ID = "4242" + + +def success(data): + return {"status": "success", "data": data} + + +def managed_user(**overrides): + return { + "id": PINE_USER_ID, + "external_id": EXTERNAL_ID, + "email": "ada@example.com", + "name": "Ada Lovelace", + "phone": None, + "created_at": "2026-10-07T00:00:00Z", + **overrides, + } + + +@dataclass +class FakeBackend: + base_url: str = "" + requests: list[dict[str, Any]] = field(default_factory=list) + handshakes: list[Any] = field(default_factory=list) + envelopes: list[dict[str, Any]] = field(default_factory=list) + error_status: int | None = None + existing: bool = False + + def app(self) -> web.Application: + sio = socketio.AsyncServer(async_mode="aiohttp") + app = web.Application() + sio.attach(app, socketio_path="/api/v2/socket.io/") + + @sio.event + async def connect(sid, _environ, auth): + self.handshakes.append(auth) + await sio.emit("ready", {}, to=sid) + + @sio.on("session:history") + async def history(sid, envelope): + self.envelopes.append(envelope) + await sio.emit( + "session:history", + { + "metadata": {"request_id": envelope["metadata"]["request_id"]}, + "payload": {"session_id": envelope["payload"]["session_id"], "data": {"messages": []}}, + }, + to=sid, + ) + + async def record(request: web.Request) -> web.Response | None: + body = await request.text() + self.requests.append({ + "method": request.method, + "path": request.raw_path, + "headers": dict(request.headers), + "body": json.loads(body) if body else None, + }) + if self.error_status is not None: + # A hostile body: none of it may surface through the SDK. + return web.json_response( + {"status": "error", "error": {"code": "platform_failure", "message": f"{API_KEY} {EXTERNAL_ID}"}}, + status=self.error_status, + ) + return None + + async def create_user(request): + return await record(request) or web.json_response( + success(managed_user(**self.requests[-1]["body"] | {"id": PINE_USER_ID})), + status=200 if self.existing else 201, + ) + + async def get_user(request): + return await record(request) or web.json_response(success(managed_user())) + + async def auth_me(request): + return await record(request) or web.json_response(success({"user_id": PINE_USER_ID})) + + async def sessions(request): + return await record(request) or web.json_response( + success({"sessions": [{"id": "7"}], "total": 1, "limit": 30, "offset": 0}) + ) + + app.router.add_post("/api/platform/v1/managed-users", create_user) + app.router.add_get("/api/platform/v1/managed-users/{external_id}", get_user) + app.router.add_get("/api/v2/auth/me", auth_me) + app.router.add_get("/api/v2/sessions", sessions) + return app + + +@pytest.fixture +async def backend(): + fake = FakeBackend() + runner = web.AppRunner(fake.app()) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + fake.base_url = f"http://127.0.0.1:{port}" + try: + yield fake + finally: + await runner.cleanup() + + +def _assert_no_secrets(text: str) -> None: + assert API_KEY not in text + assert EXTERNAL_ID not in text + + +@pytest.mark.asyncio +async def test_managed_user_rest_requests_carry_key_header_and_client_name(backend): + async with AsyncPineAI( + api_key=API_KEY, managed_user=EXTERNAL_ID, client_name="mcp", base_url=backend.base_url, + ) as client: + listed = await client.sessions.list() + + assert listed.sessions[0].id == "7" + [request] = backend.requests + assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" + assert request["headers"]["Pine-Managed-User"] == EXTERNAL_ID + assert request["headers"]["Pine-Client"] == "mcp" + + +@pytest.mark.asyncio +async def test_managed_user_connect_resolves_user_once_and_sends_handshake(backend): + async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + await client.connect() + await client.get_history("7") + await client.disconnect() + await client.connect() + + assert [request["path"] for request in backend.requests] == ["/api/v2/auth/me"] + assert backend.requests[0]["headers"]["Pine-Managed-User"] == EXTERNAL_ID + assert "Pine-Client" not in backend.requests[0]["headers"] + assert backend.handshakes == [{"token": API_KEY, "managed_user": EXTERNAL_ID}] * 2 + assert backend.envelopes[0]["metadata"]["source"]["user_id"] == PINE_USER_ID + + +@pytest.mark.asyncio +async def test_user_token_handshake_is_unchanged(backend): + async with AsyncPineAI(access_token="user-token", user_id="17", base_url=backend.base_url) as client: + await client.connect() + assert backend.handshakes == [{"token": "user-token"}] + assert backend.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("existing", "phone"), [(False, "+14155550100"), (True, None)]) +async def test_create_managed_user_is_typed_and_acts_as_the_tenant(backend, existing, phone): + backend.existing = existing + async with AsyncPineAI(api_key=API_KEY, managed_user="someone-else", base_url=backend.base_url) as client: + user = await client.platform.managed_users.create( + EXTERNAL_ID, email="ada@example.com", name="Ada Lovelace", phone=phone, + ) + + assert isinstance(user, ManagedUser) + assert user.id == PINE_USER_ID + assert user.external_id == EXTERNAL_ID + assert user.phone == phone + [request] = backend.requests + assert request["method"] == "POST" + assert request["path"] == "/api/platform/v1/managed-users" + expected = {"external_id": EXTERNAL_ID, "email": "ada@example.com", "name": "Ada Lovelace"} + assert request["body"] == (expected | {"phone": phone} if phone else expected) + assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" + assert "Pine-Managed-User" not in request["headers"] + + +@pytest.mark.asyncio +async def test_get_managed_user_percent_encodes_the_external_id(backend): + async with AsyncPineAI(api_key=API_KEY, base_url=backend.base_url) as client: + user = await client.platform.managed_users.get(EXTERNAL_ID) + + assert user.id == PINE_USER_ID + assert backend.requests[0]["path"] == f"/api/platform/v1/managed-users/{ENCODED_EXTERNAL_ID}" + assert "Pine-Managed-User" not in backend.requests[0]["headers"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 429, 500]) +async def test_platform_errors_keep_code_and_status_without_upstream_body(backend, status): + backend.error_status = status + async with AsyncPineAI(api_key=API_KEY, base_url=backend.base_url) as client: + with pytest.raises(PlatformError) as excinfo: + await client.platform.managed_users.get(EXTERNAL_ID) + + assert excinfo.value.status_code == status + assert excinfo.value.code == "platform_failure" + assert excinfo.value.__cause__ is None + _assert_no_secrets(str(excinfo.value)) + _assert_no_secrets(repr(excinfo.value)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [401, 403, 404]) +async def test_managed_user_resolution_failure_is_typed_and_does_not_connect(backend, status): + backend.error_status = status + async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + with pytest.raises(PineAIError) as excinfo: + await client.connect() + assert excinfo.value.status_code == status + assert backend.handshakes == [] + _assert_no_secrets(str(excinfo.value)) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"api_key": API_KEY, "managed_user": EXTERNAL_ID, "access_token": "user-token"}, + {"api_key": API_KEY, "managed_user": EXTERNAL_ID, "user_id": "17"}, + {"managed_user": EXTERNAL_ID}, + {"api_key": "not-a-pine-key", "managed_user": EXTERNAL_ID}, + {"api_key": API_KEY, "managed_user": ".leading-dot"}, + {"api_key": API_KEY, "managed_user": "x" * 129}, + {"api_key": API_KEY, "managed_user": "has space"}, + {"api_key": API_KEY, "client_name": "bad name"}, + ], +) +def test_invalid_platform_configuration_is_rejected_without_echoing_values(kwargs): + client_types = (AsyncPineAI,) if "user_id" in kwargs else (AsyncPineAI, PineAI) + for client_type in client_types: + with pytest.raises(ValueError) as excinfo: + client_type(base_url="https://pine.test", **kwargs) + _assert_no_secrets(str(excinfo.value)) + for value in kwargs.values(): + assert value not in str(excinfo.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("external_id", ["", "a/b", "../x", ".hidden", "x" * 129]) +async def test_invalid_external_id_is_rejected_before_any_request(backend, external_id): + async with AsyncPineAI(api_key=API_KEY, base_url=backend.base_url) as client: + with pytest.raises(ValueError): + await client.platform.managed_users.get(external_id) + with pytest.raises(ValueError): + await client.platform.managed_users.create(external_id, email="a@example.com", name="A") + assert backend.requests == [] + + +@pytest.mark.asyncio +async def test_injected_client_cannot_add_or_override_the_managed_user(): + seen = [] + + async def handler(request): + seen.append(request) + if request.url.path.startswith("/api/platform/"): + return httpx.Response(200, json=success(managed_user())) + return httpx.Response(200, json=success({"user_id": "1"})) + + injected = httpx.AsyncClient( + headers={"Pine-Managed-User": "injected"}, transport=httpx.MockTransport(handler), + ) + async with AsyncPineAI(access_token="user-token", base_url="https://pine.test", http_client=injected) as client: + await client.auth.me() + async with AsyncPineAI( + api_key=API_KEY, managed_user=EXTERNAL_ID, base_url="https://pine.test", http_client=injected, + ) as client: + await client.auth.me() + await client.platform.managed_users.get(EXTERNAL_ID) + await injected.aclose() + + assert "Pine-Managed-User" not in seen[0].headers + assert seen[1].headers["Pine-Managed-User"] == EXTERNAL_ID + assert "Pine-Managed-User" not in seen[2].headers + + +def test_sync_client_supports_platform_and_managed_user_requests(): + seen = [] + + def handler(request): + seen.append(request) + if request.url.path.startswith("/api/platform/"): + return httpx.Response(201, json=success(managed_user())) + return httpx.Response(200, json=success({"sessions": [], "total": 0, "limit": 30, "offset": 0})) + + with PineAI( + api_key=API_KEY, managed_user=EXTERNAL_ID, client_name="mcp", base_url="https://pine.test", + http_transport=httpx.MockTransport(handler), + ) as client: + created = client.platform.managed_users.create(EXTERNAL_ID, email="ada@example.com", name="Ada Lovelace") + client.sessions.list() + + assert created.id == PINE_USER_ID + assert seen[0].url.raw_path == b"/api/platform/v1/managed-users" + assert "Pine-Managed-User" not in seen[0].headers + assert seen[1].headers["Pine-Managed-User"] == EXTERNAL_ID + assert all(request.headers["Authorization"] == f"Bearer {API_KEY}" for request in seen) + assert all(request.headers["Pine-Client"] == "mcp" for request in seen) From f041ca211debeb6916f8e0966ce9e084afdcf157 Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 10:35:45 +0000 Subject: [PATCH 02/12] docs: describe integrating as an enterprise through the Platform API Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++++++++++++++ README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d12dac..6bb3277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- Pine Platform API support for enterprise integrations. `AsyncPineAI` and + `PineAI` accept a tenant secret key as `api_key`; + `client.platform.managed_users.create()` and `.get()` return typed + `ManagedUser` values and raise `PlatformError` without upstream bodies. +- Managed-user mode: `api_key` plus `managed_user` (the tenant's + `external_id`) acts as that user. REST requests send the key and a + `Pine-Managed-User` header, the Socket.IO handshake sends + `{"token", "managed_user"}`, and `connect()` resolves the Pine user ID through + `auth.me()`. Injected HTTP clients cannot add or override that header. +- `client_name` adds a `Pine-Client` header to every REST request. + ## [0.5.0rc1] - 2026-09-18 Internal release candidate for 0.5.0. See [MIGRATION.md](MIGRATION.md) before diff --git a/README.md b/README.md index b4ca6ea..997365b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,56 @@ never retry or replay form submissions. A transport ACK is not a delivery receipt. The legacy synchronous `send_form_response()` is deprecated because it cannot verify the original form request. +## Integrating as an enterprise (Platform API) + +A Pine Platform integration (a *tenant*) holds a secret key, `pine_sk_live_...` +in production or `pine_sk_test_...` elsewhere. Keep it on your servers: read it +from an environment variable or secret store, and never ship it in browser, +mobile or other client-side code, prompts, or logs. Each end user of your +product becomes a *managed user*, identified by your own `external_id` +(1-128 of letters, digits, `.`, `_`, `:`, `|`, `-`, not starting with `.`; no +personal data). + +Create the managed user once. The call is idempotent per `external_id`: a +repeat returns the stored user unchanged, even if the profile you send differs. +`phone` is optional, in E.164 form, and must be a number you have verified. + +```python +import os + +from pine_assistant import AsyncPineAI + +api_key = os.environ["PINE_API_KEY"] + +async with AsyncPineAI(api_key=api_key) as tenant: + user = await tenant.platform.managed_users.create( + "usr_01J9Z3K8QF", email="ada@example.com", name="Ada Lovelace", phone="+14155550100", + ) + same_user = await tenant.platform.managed_users.get("usr_01J9Z3K8QF") +``` + +Then act as that user with `managed_user`. Every user-scoped REST request sends +the key plus a `Pine-Managed-User` header, and the Socket.IO handshake sends +both; `connect()` looks up the user's Pine user ID once through `auth.me()`. +The rest of the SDK works unchanged. + +```python +async with AsyncPineAI(api_key=api_key, managed_user="usr_01J9Z3K8QF") as client: + session = await client.sessions.create() + await client.connect() + await client.join_session(session["id"]) + async for event in client.chat(session["id"], "Call my dentist to move Tuesday's appointment"): + print(event.type, event.data) +``` + +`PineAI` accepts the same `api_key` and `managed_user` for synchronous REST. +`client_name="..."` adds a `Pine-Client` header that the backend records as +the request source. Platform API failures raise `PlatformError` with a stable +`code` and `status_code` (for example 401 for a bad key, 403 for a suspended +integration, 404 `platform_managed_user_not_found`, 409 +`platform_managed_user_limit_reached`); like every SDK error, it never +reproduces the upstream body, the key or the `external_id`. + ## Quick Start (Sync REST) `PineAI` is a synchronous REST client. It returns values directly for auth and From 935bf54d5e955a034a32055052e5d5a7d411830c Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 11:05:30 +0000 Subject: [PATCH 03/12] fix(sdk): send Pine-Client on the handshake and share request identity client_name now also goes to the Socket.IO handshake as a Pine-Client header, where the backend reads it for attribution. The identity headers become a method of a base shared by both HTTP transports instead of a module function reading their private fields. The README states that the backend records Pine-Client only for tenant-key requests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- README.md | 5 +- src/pine_assistant/client.py | 5 +- src/pine_assistant/transport/http.py | 67 +++++++++++------------- src/pine_assistant/transport/socketio.py | 4 ++ tests/test_platform.py | 15 +++++- 6 files changed, 58 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb3277..06c17d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ project adheres to [Semantic Versioning](https://semver.org/). `Pine-Managed-User` header, the Socket.IO handshake sends `{"token", "managed_user"}`, and `connect()` resolves the Pine user ID through `auth.me()`. Injected HTTP clients cannot add or override that header. -- `client_name` adds a `Pine-Client` header to every REST request. +- `client_name` adds a `Pine-Client` header to every REST request and to the + Socket.IO handshake. ## [0.5.0rc1] - 2026-09-18 diff --git a/README.md b/README.md index 997365b..d833eda 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,9 @@ async with AsyncPineAI(api_key=api_key, managed_user="usr_01J9Z3K8QF") as client ``` `PineAI` accepts the same `api_key` and `managed_user` for synchronous REST. -`client_name="..."` adds a `Pine-Client` header that the backend records as -the request source. Platform API failures raise `PlatformError` with a stable +`client_name="..."` sends a `Pine-Client` header on REST requests and on the +Socket.IO handshake. The backend records it as the source only for requests +made with a tenant key, and only the value `mcp`; it changes no behavior. Platform API failures raise `PlatformError` with a stable `code` and `status_code` (for example 401 for a bad key, 403 for a suspended integration, 404 `platform_managed_user_not_found`, 409 `platform_managed_user_limit_reached`); like every SDK error, it never diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 11d6351..e7d145c 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -105,7 +105,8 @@ class AsyncPineAI: managed user: every user-scoped REST request carries the key and a ``Pine-Managed-User`` header, the Socket.IO handshake carries both, and ``connect()`` resolves the managed user's Pine user ID itself. - ``client_name`` adds a ``Pine-Client`` header to every REST request. + ``client_name`` adds a ``Pine-Client`` header to every REST request and + to the Socket.IO handshake. """ def __init__( @@ -129,6 +130,7 @@ def __init__( self._access_token = access_token self._user_id = user_id self._managed_user = managed_user + self._client_name = client_name self._device_id = _get_or_create_device_id(device_id) self._transports = transports self._ready_timeout = ready_timeout @@ -185,6 +187,7 @@ async def connect(self, access_token: str | None = None, user_id: str | None = N transports=self._transports, ready_timeout=self._ready_timeout, managed_user=self._managed_user, + client_name=self._client_name, ) self._chat = ChatEngine(self._sio, check_session_state=self._session_state) await self._sio.connect() diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index 6ebc8db..3340d57 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -89,24 +89,33 @@ def _json_data(response: httpx.Response) -> Any: return payload -def _identity_headers( - http: "HttpClient | SyncHttpClient", authenticated: bool, token: str | None, as_tenant: bool = False -) -> dict[str, str]: - """Headers naming who a request acts as. - - The managed user belongs to the client's own API key, so it is sent only - with that key, never with a per-request ``token`` override, and never on - Platform API requests the tenant makes as itself (``as_tenant``). - """ - headers: dict[str, str] = {"Content-Type": "application/json"} - actual_token = http._token if token is None else token - if authenticated and actual_token: - headers["Authorization"] = f"Bearer {actual_token}" - if token is None and http._managed_user and not as_tenant: - headers[MANAGED_USER_HEADER] = http._managed_user - if http._client_name: - headers[CLIENT_HEADER] = http._client_name - return headers +class _RequestIdentity: + """The credential, managed user and client name shared by the sync and async transports.""" + + def __init__(self, token: str | None, managed_user: str | None, client_name: str | None) -> None: + self._token = token + self._managed_user = managed_user + self._client_name = client_name + + def set_token(self, token: str | None) -> None: + self._token = token + + def _auth_headers(self, authenticated: bool, token: str | None = None, as_tenant: bool = False) -> dict[str, str]: + """Headers naming who a request acts as. + + The managed user belongs to the client's own API key, so it is sent only + with that key, never with a per-request ``token`` override, and never on + Platform API requests the tenant makes as itself (``as_tenant``). + """ + headers: dict[str, str] = {"Content-Type": "application/json"} + actual_token = self._token if token is None else token + if authenticated and actual_token: + headers["Authorization"] = f"Bearer {actual_token}" + if token is None and self._managed_user and not as_tenant: + headers[MANAGED_USER_HEADER] = self._managed_user + if self._client_name: + headers[CLIENT_HEADER] = self._client_name + return headers def _enforce_identity(request: httpx.Request, headers: dict[str, str]) -> None: @@ -118,7 +127,7 @@ def _enforce_identity(request: httpx.Request, headers: dict[str, str]) -> None: request.headers.pop(name, None) -class HttpClient: +class HttpClient(_RequestIdentity): """Async HTTP client; injected clients remain caller-owned.""" def __init__( @@ -135,10 +144,8 @@ def __init__( ): if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") + super().__init__(token, managed_user, client_name) self._api_base_url = _api_url(base_url, api_base_path) - self._token = token - self._managed_user = managed_user - self._client_name = client_name self._owns_client = client is None self._client = client or httpx.AsyncClient( headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}, @@ -147,11 +154,6 @@ def __init__( follow_redirects=False, ) - def set_token(self, token: str | None) -> None: - self._token = token - - def _auth_headers(self, authenticated: bool, token: str | None = None, as_tenant: bool = False) -> dict[str, str]: - return _identity_headers(self, authenticated, token, as_tenant) def _url(self, path: str) -> str: # Absolute paths keep an injected client's base_url from changing the @@ -227,7 +229,7 @@ async def close(self) -> None: await self._client.aclose() -class SyncHttpClient: +class SyncHttpClient(_RequestIdentity): """Synchronous REST transport with the same serialization and error rules.""" def __init__( @@ -244,10 +246,8 @@ def __init__( ) -> None: if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") + super().__init__(token, managed_user, client_name) self._api_base_url = _api_url(base_url, api_base_path) - self._token = token - self._managed_user = managed_user - self._client_name = client_name self._owns_client = client is None self._client = client or httpx.Client( headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}, @@ -256,11 +256,6 @@ def __init__( follow_redirects=False, ) - def set_token(self, token: str | None) -> None: - self._token = token - - def _auth_headers(self, authenticated: bool, token: str | None = None, as_tenant: bool = False) -> dict[str, str]: - return _identity_headers(self, authenticated, token, as_tenant) def _url(self, path: str) -> str: return f"{self._api_base_url}/{path.lstrip('/')}" diff --git a/src/pine_assistant/transport/socketio.py b/src/pine_assistant/transport/socketio.py index 073698d..9aaa1b0 100644 --- a/src/pine_assistant/transport/socketio.py +++ b/src/pine_assistant/transport/socketio.py @@ -16,6 +16,7 @@ from pine_assistant.errors import ConnectionError as PineConnectionError from pine_assistant.models.form import FormSubmissionResult +from pine_assistant.transport.http import CLIENT_HEADER SOCKETIO_PATH = "/api/v2/socket.io/" @@ -43,10 +44,12 @@ def __init__( transports: list[str] | None = None, ready_timeout: float = 15.0, managed_user: str | None = None, + client_name: str | None = None, ): self._base_url = base_url self._token = token self._managed_user = managed_user + self._client_name = client_name self._user_id = user_id self._device_id = device_id or str(uuid.uuid4()) self._transports = transports or ["websocket"] @@ -148,6 +151,7 @@ async def disconnect(_reason: str = "") -> None: await self._sio.connect( self._base_url, auth=self._handshake_auth(), + headers={CLIENT_HEADER: self._client_name} if self._client_name else {}, transports=self._transports, socketio_path=SOCKETIO_PATH, wait_timeout=self._ready_timeout, diff --git a/tests/test_platform.py b/tests/test_platform.py index d31f18a..5019bc1 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -38,6 +38,7 @@ class FakeBackend: base_url: str = "" requests: list[dict[str, Any]] = field(default_factory=list) handshakes: list[Any] = field(default_factory=list) + handshake_clients: list[str | None] = field(default_factory=list) envelopes: list[dict[str, Any]] = field(default_factory=list) error_status: int | None = None existing: bool = False @@ -48,8 +49,9 @@ def app(self) -> web.Application: sio.attach(app, socketio_path="/api/v2/socket.io/") @sio.event - async def connect(sid, _environ, auth): + async def connect(sid, environ, auth): self.handshakes.append(auth) + self.handshake_clients.append(environ.get("HTTP_PINE_CLIENT")) await sio.emit("ready", {}, to=sid) @sio.on("session:history") @@ -150,9 +152,20 @@ async def test_managed_user_connect_resolves_user_once_and_sends_handshake(backe assert backend.requests[0]["headers"]["Pine-Managed-User"] == EXTERNAL_ID assert "Pine-Client" not in backend.requests[0]["headers"] assert backend.handshakes == [{"token": API_KEY, "managed_user": EXTERNAL_ID}] * 2 + assert backend.handshake_clients == [None, None] assert backend.envelopes[0]["metadata"]["source"]["user_id"] == PINE_USER_ID +@pytest.mark.asyncio +async def test_client_name_is_sent_on_the_socketio_handshake(backend): + async with AsyncPineAI( + api_key=API_KEY, managed_user=EXTERNAL_ID, client_name="mcp", base_url=backend.base_url, + ) as client: + await client.connect() + assert backend.requests[0]["headers"]["Pine-Client"] == "mcp" + assert backend.handshake_clients == ["mcp"] + + @pytest.mark.asyncio async def test_user_token_handshake_is_unchanged(backend): async with AsyncPineAI(access_token="user-token", user_id="17", base_url=backend.base_url) as client: From a23fa0ab5fd1c20cacdfd257bba7b046432bccd8 Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 14:04:37 +0000 Subject: [PATCH 04/12] fix(sdk): bind the managed user to its key and apply identity last The managed-user header is sent only with the API key it was configured with, never after set_token() or with a per-request token. Identity headers (Authorization, Pine-Managed-User, Pine-Client) are applied after caller headers, as on main, so http.* callers cannot override them. API keys must start with pine_sk_live_ or pine_sk_test_. API_KEY_PREFIXES, validate_external_id, MANAGED_USER_HEADER and CLIENT_HEADER are exported. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ README.md | 25 +++++--- src/pine_assistant/__init__.py | 14 ++++- src/pine_assistant/client.py | 6 +- src/pine_assistant/platform_api.py | 2 +- src/pine_assistant/transport/http.py | 59 +++++++++---------- tests/test_platform.py | 88 +++++++++++++++++++++++++--- 7 files changed, 145 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c17d9..3cfe4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ project adheres to [Semantic Versioning](https://semver.org/). `auth.me()`. Injected HTTP clients cannot add or override that header. - `client_name` adds a `Pine-Client` header to every REST request and to the Socket.IO handshake. +- `API_KEY_PREFIXES` (`pine_sk_live_`, `pine_sk_test_`), `validate_external_id`, + `MANAGED_USER_HEADER` and `CLIENT_HEADER` are public. The managed user is + sent only with the API key it was configured with, and identity headers are + applied after caller headers, so neither can be overridden per request. ## [0.5.0rc1] - 2026-09-18 diff --git a/README.md b/README.md index d833eda..68b7abe 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,10 @@ api_key = os.environ["PINE_API_KEY"] async with AsyncPineAI(api_key=api_key) as tenant: user = await tenant.platform.managed_users.create( - "usr_01J9Z3K8QF", email="ada@example.com", name="Ada Lovelace", phone="+14155550100", + "usr_01J9Z3K8QF", + email="ada@example.com", + name="Ada Lovelace", + phone="+14155550100", ) same_user = await tenant.platform.managed_users.get("usr_01J9Z3K8QF") ``` @@ -140,22 +143,26 @@ both; `connect()` looks up the user's Pine user ID once through `auth.me()`. The rest of the SDK works unchanged. ```python -async with AsyncPineAI(api_key=api_key, managed_user="usr_01J9Z3K8QF") as client: +async with AsyncPineAI( + api_key=api_key, managed_user="usr_01J9Z3K8QF", +) as client: session = await client.sessions.create() await client.connect() await client.join_session(session["id"]) - async for event in client.chat(session["id"], "Call my dentist to move Tuesday's appointment"): + message = "Call my dentist to move Tuesday's appointment" + async for event in client.chat(session["id"], message): print(event.type, event.data) ``` `PineAI` accepts the same `api_key` and `managed_user` for synchronous REST. `client_name="..."` sends a `Pine-Client` header on REST requests and on the -Socket.IO handshake. The backend records it as the source only for requests -made with a tenant key, and only the value `mcp`; it changes no behavior. Platform API failures raise `PlatformError` with a stable -`code` and `status_code` (for example 401 for a bad key, 403 for a suspended -integration, 404 `platform_managed_user_not_found`, 409 -`platform_managed_user_limit_reached`); like every SDK error, it never -reproduces the upstream body, the key or the `external_id`. +Socket.IO handshake. The backend records it as the source only for requests made +with a tenant key, and only the value `mcp`; it changes no behavior. Platform +API failures raise `PlatformError` with a stable `code` and `status_code` (for +example 401 for a bad key, 403 for a suspended integration, 404 +`platform_managed_user_not_found`, 409 `platform_managed_user_limit_reached`); +like every SDK error, it never reproduces the upstream body, the key or the +`external_id`. ## Quick Start (Sync REST) diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index 94338bb..9e47a68 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -29,8 +29,16 @@ SessionOutcomeRating, SessionOutcomesPage, ) -from pine_assistant.platform_api import ManagedUsersAPI, PlatformAPI, SyncManagedUsersAPI, SyncPlatformAPI +from pine_assistant.platform_api import ( + API_KEY_PREFIXES, + ManagedUsersAPI, + PlatformAPI, + SyncManagedUsersAPI, + SyncPlatformAPI, + validate_external_id, +) from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI +from pine_assistant.transport.http import CLIENT_HEADER, MANAGED_USER_HEADER __version__ = "0.5.0rc1" __all__ = [ @@ -45,6 +53,10 @@ "ManagedUsersAPI", "SyncManagedUsersAPI", "ManagedUser", + "API_KEY_PREFIXES", + "MANAGED_USER_HEADER", + "CLIENT_HEADER", + "validate_external_id", "AuthIdentity", "AuthTicket", "RedeemedTicket", diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index e7d145c..1a81a66 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -18,7 +18,7 @@ from pine_assistant.errors import ConnectionError from pine_assistant.models.events import C2SEvent from pine_assistant.models.form import FormSubmissionResult, FormToUserData, encode_form_answers -from pine_assistant.platform_api import API_KEY_PREFIX, PlatformAPI, SyncPlatformAPI, validate_external_id +from pine_assistant.platform_api import API_KEY_PREFIXES, PlatformAPI, SyncPlatformAPI, validate_external_id from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI from pine_assistant.transport.http import DEFAULT_API_BASE_PATH, DEFAULT_BASE_URL, HttpClient, SyncHttpClient from pine_assistant.transport.socketio import SocketIOManager @@ -55,8 +55,8 @@ def _platform_identity( return access_token if access_token is not None or user_id is not None: raise ValueError("api_key cannot be combined with access_token or user_id") - if not isinstance(api_key, str) or not api_key.startswith(API_KEY_PREFIX): - raise ValueError(f"api_key must be a Pine Platform secret key starting with {API_KEY_PREFIX}") + if not isinstance(api_key, str) or not api_key.startswith(API_KEY_PREFIXES): + raise ValueError("api_key must be a Pine Platform secret key starting with pine_sk_live_ or pine_sk_test_") if managed_user is not None: validate_external_id(managed_user, "managed_user") return api_key diff --git a/src/pine_assistant/platform_api.py b/src/pine_assistant/platform_api.py index 66d3cd0..7b8fe31 100644 --- a/src/pine_assistant/platform_api.py +++ b/src/pine_assistant/platform_api.py @@ -17,7 +17,7 @@ from pine_assistant.models.platform import ManagedUser from pine_assistant.transport.http import HttpClient, SyncHttpClient -API_KEY_PREFIX = "pine_sk_" +API_KEY_PREFIXES = ("pine_sk_live_", "pine_sk_test_") _EXTERNAL_ID = re.compile(r"[A-Za-z0-9_:|-][A-Za-z0-9._:|-]{0,127}") _MANAGED_USERS = "/platform/v1/managed-users" diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index 3340d57..2341ba3 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -13,6 +13,7 @@ _USER_AGENT = "pine-assistant-sdk" MANAGED_USER_HEADER = "Pine-Managed-User" CLIENT_HEADER = "Pine-Client" +_IDENTITY_HEADERS = ("Authorization", MANAGED_USER_HEADER, CLIENT_HEADER) # Public HTTPX timeout inputs accepted by both sync and async clients. HTTPX's # internal ``TimeoutTypes`` alias is not exported in every supported release. @@ -95,36 +96,38 @@ class _RequestIdentity: def __init__(self, token: str | None, managed_user: str | None, client_name: str | None) -> None: self._token = token self._managed_user = managed_user + # A managed user is bound to the API key it was configured with and is + # never asserted with any other token, e.g. one installed by set_token(). + self._managed_user_key = token if managed_user else None self._client_name = client_name def set_token(self, token: str | None) -> None: self._token = token - def _auth_headers(self, authenticated: bool, token: str | None = None, as_tenant: bool = False) -> dict[str, str]: - """Headers naming who a request acts as. - - The managed user belongs to the client's own API key, so it is sent only - with that key, never with a per-request ``token`` override, and never on - Platform API requests the tenant makes as itself (``as_tenant``). - """ - headers: dict[str, str] = {"Content-Type": "application/json"} + def _identity(self, authenticated: bool, token: str | None, as_tenant: bool) -> dict[str, str]: + """The identity headers a request carries; Platform requests the tenant makes as itself omit the user.""" + identity: dict[str, str] = {} actual_token = self._token if token is None else token if authenticated and actual_token: - headers["Authorization"] = f"Bearer {actual_token}" - if token is None and self._managed_user and not as_tenant: - headers[MANAGED_USER_HEADER] = self._managed_user + identity["Authorization"] = f"Bearer {actual_token}" + if self._managed_user and actual_token == self._managed_user_key and not as_tenant: + identity[MANAGED_USER_HEADER] = self._managed_user if self._client_name: - headers[CLIENT_HEADER] = self._client_name - return headers + identity[CLIENT_HEADER] = self._client_name + return identity + @staticmethod + def _apply_identity(request: httpx.Request, identity: dict[str, str]) -> None: + """Make the identity headers exactly the computed ones. -def _enforce_identity(request: httpx.Request, headers: dict[str, str]) -> None: - """Make the identity headers exactly the computed ones, whatever an injected client defaults.""" - for name in ("Authorization", MANAGED_USER_HEADER): - if name in headers: - request.headers[name] = headers[name] - else: - request.headers.pop(name, None) + Applied last, so neither an injected client's defaults nor caller + headers can authenticate a request or choose who it acts as. + """ + for name in _IDENTITY_HEADERS: + if name in identity: + request.headers[name] = identity[name] + else: + request.headers.pop(name, None) class HttpClient(_RequestIdentity): @@ -173,9 +176,8 @@ async def _request( extra_headers: dict[str, str] | None = None, as_tenant: bool = False, ) -> Any: - headers = self._auth_headers(authenticated, token, as_tenant) - if extra_headers: - headers.update(extra_headers) + identity = self._identity(authenticated, token, as_tenant) + headers = {"Content-Type": "application/json", **(extra_headers or {})} if files is not None: # httpx must generate the multipart boundary itself. headers.pop("Content-Type", None) @@ -183,9 +185,7 @@ async def _request( request = self._client.build_request( method, self._url(path), json=body, params=params, headers=headers, files=files, ) - # An injected client may carry default authentication. Never let it - # authenticate an anonymous request or act for another user. - _enforce_identity(request, headers) + self._apply_identity(request, identity) response = await self._client.send(request, follow_redirects=False, auth=None) except httpx.TimeoutException as exc: raise PineAIError("timeout", "Pine API request timed out") from exc @@ -273,16 +273,15 @@ def _request( extra_headers: dict[str, str] | None = None, as_tenant: bool = False, ) -> Any: - headers = self._auth_headers(authenticated, token, as_tenant) - if extra_headers: - headers.update(extra_headers) + identity = self._identity(authenticated, token, as_tenant) + headers = {"Content-Type": "application/json", **(extra_headers or {})} if files is not None: headers.pop("Content-Type", None) try: request = self._client.build_request( method, self._url(path), json=body, params=params, headers=headers, files=files, ) - _enforce_identity(request, headers) + self._apply_identity(request, identity) response = self._client.send(request, follow_redirects=False, auth=None) except httpx.TimeoutException as exc: raise PineAIError("timeout", "Pine API request timed out") from exc diff --git a/tests/test_platform.py b/tests/test_platform.py index 5019bc1..0fb0ab1 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -9,7 +9,17 @@ import socketio from aiohttp import web -from pine_assistant import AsyncPineAI, ManagedUser, PineAI, PineAIError, PlatformError +from pine_assistant import ( + API_KEY_PREFIXES, + MANAGED_USER_HEADER, + AsyncPineAI, + ManagedUser, + PineAI, + PineAIError, + PlatformError, + SessionError, + validate_external_id, +) API_KEY = "pine_sk_test_c2VjcmV0LWtleS1mb3ItdGVzdHM" EXTERNAL_ID = "acme:user|42-x" @@ -33,6 +43,9 @@ def managed_user(**overrides): } +STORED_USER = managed_user(email="stored@example.com", name="Stored Name", phone="+14155550199") + + @dataclass class FakeBackend: base_url: str = "" @@ -41,6 +54,7 @@ class FakeBackend: handshake_clients: list[str | None] = field(default_factory=list) envelopes: list[dict[str, Any]] = field(default_factory=list) error_status: int | None = None + error_code: str = "platform_failure" existing: bool = False def app(self) -> web.Application: @@ -77,16 +91,19 @@ async def record(request: web.Request) -> web.Response | None: if self.error_status is not None: # A hostile body: none of it may surface through the SDK. return web.json_response( - {"status": "error", "error": {"code": "platform_failure", "message": f"{API_KEY} {EXTERNAL_ID}"}}, + {"status": "error", "error": {"code": self.error_code, "message": f"{API_KEY} {EXTERNAL_ID}"}}, status=self.error_status, ) return None async def create_user(request): - return await record(request) or web.json_response( - success(managed_user(**self.requests[-1]["body"] | {"id": PINE_USER_ID})), - status=200 if self.existing else 201, - ) + failure = await record(request) + if failure: + return failure + if self.existing: + # The stored user is returned unchanged, whatever the request carried. + return web.json_response(success(STORED_USER), status=200) + return web.json_response(success(managed_user(**self.requests[-1]["body"])), status=201) async def get_user(request): return await record(request) or web.json_response(success(managed_user())) @@ -103,6 +120,7 @@ async def sessions(request): app.router.add_get("/api/platform/v1/managed-users/{external_id}", get_user) app.router.add_get("/api/v2/auth/me", auth_me) app.router.add_get("/api/v2/sessions", sessions) + app.router.add_post("/api/v2/sessions", sessions) return app @@ -113,8 +131,8 @@ async def backend(): await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 0) await site.start() - port = site._server.sockets[0].getsockname()[1] - fake.base_url = f"http://127.0.0.1:{port}" + host, port = runner.addresses[0][:2] + fake.base_url = f"http://{host}:{port}" try: yield fake finally: @@ -186,7 +204,10 @@ async def test_create_managed_user_is_typed_and_acts_as_the_tenant(backend, exis assert isinstance(user, ManagedUser) assert user.id == PINE_USER_ID assert user.external_id == EXTERNAL_ID - assert user.phone == phone + if existing: + assert user == ManagedUser.model_validate(STORED_USER) + else: + assert (user.email, user.name, user.phone) == ("ada@example.com", "Ada Lovelace", phone) [request] = backend.requests assert request["method"] == "POST" assert request["path"] == "/api/platform/v1/managed-users" @@ -240,6 +261,8 @@ async def test_managed_user_resolution_failure_is_typed_and_does_not_connect(bac {"api_key": API_KEY, "managed_user": EXTERNAL_ID, "user_id": "17"}, {"managed_user": EXTERNAL_ID}, {"api_key": "not-a-pine-key", "managed_user": EXTERNAL_ID}, + {"api_key": "pine_sk_prod_c2VjcmV0", "managed_user": EXTERNAL_ID}, + {"api_key": "pine_sk_c2VjcmV0"}, {"api_key": API_KEY, "managed_user": ".leading-dot"}, {"api_key": API_KEY, "managed_user": "x" * 129}, {"api_key": API_KEY, "managed_user": "has space"}, @@ -316,3 +339,50 @@ def handler(request): assert seen[1].headers["Pine-Managed-User"] == EXTERNAL_ID assert all(request.headers["Authorization"] == f"Bearer {API_KEY}" for request in seen) assert all(request.headers["Pine-Client"] == "mcp" for request in seen) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("status", "code"), [(403, "platform_route_not_allowed"), (429, "rate_limited")]) +async def test_user_scoped_failures_keep_code_and_status_without_upstream_body(backend, status, code): + backend.error_status, backend.error_code = status, code + async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + with pytest.raises(SessionError) as excinfo: + await client.sessions.list() + assert (excinfo.value.code, excinfo.value.status_code) == (code, status) + _assert_no_secrets(str(excinfo.value)) + + +@pytest.mark.asyncio +async def test_managed_user_is_never_sent_with_a_token_other_than_the_key(backend): + async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + client.http.set_token("user-jwt") + await client.sessions.list() + await client.http.get("/v2/sessions", token=API_KEY) + await client.http.get("/v2/sessions", token="other-jwt") + + first, second, third = (request["headers"] for request in backend.requests) + assert first["Authorization"] == "Bearer user-jwt" and MANAGED_USER_HEADER not in first + assert second["Authorization"] == f"Bearer {API_KEY}" and second[MANAGED_USER_HEADER] == EXTERNAL_ID + assert third["Authorization"] == "Bearer other-jwt" and MANAGED_USER_HEADER not in third + + +@pytest.mark.asyncio +async def test_caller_headers_cannot_override_identity(backend): + forged = {"Authorization": "Bearer forged", MANAGED_USER_HEADER: "someone-else", "Pine-Client": "forged"} + async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + await client.http.post("/v2/sessions", {}, headers=forged) + async with AsyncPineAI(access_token="user-token", base_url=backend.base_url) as client: + await client.http.post("/v2/sessions", {}, headers=forged) + + managed, user = (request["headers"] for request in backend.requests) + assert managed["Authorization"] == f"Bearer {API_KEY}" + assert managed[MANAGED_USER_HEADER] == EXTERNAL_ID + assert "Pine-Client" not in managed + assert user["Authorization"] == "Bearer user-token" + assert MANAGED_USER_HEADER not in user and "Pine-Client" not in user + + +def test_public_platform_names(): + assert API_KEY_PREFIXES == ("pine_sk_live_", "pine_sk_test_") + assert MANAGED_USER_HEADER == "Pine-Managed-User" + assert validate_external_id(EXTERNAL_ID) == EXTERNAL_ID From 8d24306be498ece4fccafc48c3425bb816c3282c Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 17:49:16 +0000 Subject: [PATCH 05/12] docs: name the managed-user cases that differ from a user client mint_ticket() always fails with 403 platform_route_not_allowed as AuthError, and while the backend's access switch is off user-scoped calls fail with 403 platform_managed_user_access_disabled as the resource's usual error type. A test pins both. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++++++++- tests/test_platform.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 68b7abe..e4b320e 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,16 @@ async with AsyncPineAI(api_key=api_key) as tenant: Then act as that user with `managed_user`. Every user-scoped REST request sends the key plus a `Pine-Managed-User` header, and the Socket.IO handshake sends both; `connect()` looks up the user's Pine user ID once through `auth.me()`. -The rest of the SDK works unchanged. +Two cases differ from a user-token client. Every error below subclasses +`PineAIError` and carries `code` and `status_code`: + +- `client.auth.mint_ticket()` always fails with 403 + `platform_route_not_allowed`, raised as `AuthError`. Managed users cannot mint + login tickets. +- While the backend's managed-user access switch is off, every user-scoped call + fails with 403 `platform_managed_user_access_disabled`. The exception type is + the resource's usual one: `SessionError` from `sessions.*`, `AuthError` from + `auth.*` and from `connect()`, which reads `auth.me()` first. ```python async with AsyncPineAI( diff --git a/tests/test_platform.py b/tests/test_platform.py index 0fb0ab1..dcc823f 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -13,6 +13,7 @@ API_KEY_PREFIXES, MANAGED_USER_HEADER, AsyncPineAI, + AuthError, ManagedUser, PineAI, PineAIError, @@ -121,6 +122,7 @@ async def sessions(request): app.router.add_get("/api/v2/auth/me", auth_me) app.router.add_get("/api/v2/sessions", sessions) app.router.add_post("/api/v2/sessions", sessions) + app.router.add_post("/api/v2/auth/tickets", sessions) return app @@ -386,3 +388,28 @@ def test_public_platform_names(): assert API_KEY_PREFIXES == ("pine_sk_live_", "pine_sk_test_") assert MANAGED_USER_HEADER == "Pine-Managed-User" assert validate_external_id(EXTERNAL_ID) == EXTERNAL_ID + + +AUTH_ATTEMPT_ID = "a" * 22 +CODE_CHALLENGE = "c" * 43 + + +@pytest.mark.asyncio +async def test_documented_managed_user_restrictions_raise_the_resource_error_type(backend): + async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + backend.error_status, backend.error_code = 403, "platform_route_not_allowed" + with pytest.raises(AuthError) as minted: + await client.auth.mint_ticket( + purpose="mcp", client_id="client", auth_attempt_id=AUTH_ATTEMPT_ID, code_challenge=CODE_CHALLENGE, + ) + backend.error_code = "platform_managed_user_access_disabled" + with pytest.raises(SessionError) as listed: + await client.sessions.list() + with pytest.raises(AuthError) as connected: + await client.connect() + + assert (minted.value.code, minted.value.status_code) == ("platform_route_not_allowed", 403) + for excinfo in (listed, connected): + assert (excinfo.value.code, excinfo.value.status_code) == ("platform_managed_user_access_disabled", 403) + _assert_no_secrets(str(excinfo.value)) + assert backend.handshakes == [] From 03daeba7042e08301f62d2a3b1730b3e9cbe4aea Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 19:03:52 +0000 Subject: [PATCH 06/12] docs: list the billing and admission errors a managed user can see Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e4b320e..8cdc439 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ async with AsyncPineAI(api_key=api_key) as tenant: Then act as that user with `managed_user`. Every user-scoped REST request sends the key plus a `Pine-Managed-User` header, and the Socket.IO handshake sends both; `connect()` looks up the user's Pine user ID once through `auth.me()`. -Two cases differ from a user-token client. Every error below subclasses -`PineAIError` and carries `code` and `status_code`: +Two restrictions apply. Every error below subclasses `PineAIError` and carries +`code` and `status_code`: - `client.auth.mint_ticket()` always fails with 403 `platform_route_not_allowed`, raised as `AuthError`. Managed users cannot mint @@ -151,6 +151,11 @@ Two cases differ from a user-token client. Every error below subclasses the resource's usual one: `SessionError` from `sessions.*`, `AuthError` from `auth.*` and from `connect()`, which reads `auth.me()` first. +Billing and admission can also reject sessions and chat for a managed user: +402 `platform_spend_limit_reached`, 403 `platform_access_suspended` or +`platform_managed_user_unavailable`, 409 `platform_session_state_conflict`, and +429 when the tenant quota is exhausted. + ```python async with AsyncPineAI( api_key=api_key, managed_user="usr_01J9Z3K8QF", From 94636da753f3b1098947db4a651698d11306782f Mon Sep 17 00:00:00 2001 From: Arvin Hsu Date: Wed, 7 Oct 2026 20:23:33 +0000 Subject: [PATCH 07/12] docs: list the running-task limit among managed-user errors Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8cdc439..ed81bee 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,9 @@ Two restrictions apply. Every error below subclasses `PineAIError` and carries Billing and admission can also reject sessions and chat for a managed user: 402 `platform_spend_limit_reached`, 403 `platform_access_suspended` or -`platform_managed_user_unavailable`, 409 `platform_session_state_conflict`, and -429 when the tenant quota is exhausted. +`platform_managed_user_unavailable`, 409 `platform_session_state_conflict`, 409 +`platform_session_limit_reached` from `sessions.start` when the running-task +limit is reached, and 429 when the tenant quota is exhausted. ```python async with AsyncPineAI( From f128be34ee8eb00ac7cbb8f42b004ee3b570ac16 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Mon, 19 Oct 2026 21:40:22 +0800 Subject: [PATCH 08/12] feat(sdk): act for a managed user by its Pine user ID The backend now names the managed user by its Pine user ID, not by its external_id. A partner client acts for a managed user with AsyncPineAI(api_key=key, user_id=user.id). The same ID goes in the Pine-Managed-User-Id header, the managed_user_id handshake field and the envelope user_id. - Remove the managed_user parameter and the Pine-Managed-User header. - Check user_id as a canonical decimal string; export validate_user_id. - connect() no longer calls auth.me(). - A key client without user_id can call only the Platform API. - A key client keeps its key: set_token() and auth.verify_code() raise ValueError, checked before the code is sent. - platform.* on a client without api_key raises ValueError. - Add sessions.history() to read stored messages over REST. - Set the version to 0.5.0rc2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 51 +++-- pyproject.toml | 2 +- src/pine_assistant/__init__.py | 11 +- src/pine_assistant/auth.py | 2 + src/pine_assistant/client.py | 74 ++++--- src/pine_assistant/models/platform.py | 5 +- src/pine_assistant/models/session.py | 31 +++ src/pine_assistant/platform_api.py | 33 ++- src/pine_assistant/sessions.py | 64 ++++-- src/pine_assistant/transport/http.py | 70 +++--- src/pine_assistant/transport/socketio.py | 21 +- tests/test_m1_resources.py | 77 +++++++ tests/test_platform.py | 263 +++++++++++++++++------ 13 files changed, 528 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfe4f4..cc6d179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,25 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [0.5.0rc2] - 2026-10-19 + +Internal release candidate for 0.5.0. ### Added -- Pine Platform API support for enterprise integrations. `AsyncPineAI` and - `PineAI` accept a tenant secret key as `api_key`; - `client.platform.managed_users.create()` and `.get()` return typed - `ManagedUser` values and raise `PlatformError` without upstream bodies. -- Managed-user mode: `api_key` plus `managed_user` (the tenant's - `external_id`) acts as that user. REST requests send the key and a - `Pine-Managed-User` header, the Socket.IO handshake sends - `{"token", "managed_user"}`, and `connect()` resolves the Pine user ID through - `auth.me()`. Injected HTTP clients cannot add or override that header. +- Pine Platform API support for partners (businesses that build on Pine). + `AsyncPineAI` and `PineAI` accept a partner's secret key as `api_key`. + `client.platform.managed_users.create()` and `.get()` take your `external_id`, + return typed `ManagedUser` values, and raise `PlatformError` without upstream + bodies. +- Managed-user mode: `api_key` plus `user_id`, where `user_id` is the + `ManagedUser.id` (the Pine user ID). REST requests send the key and a + `Pine-Managed-User-Id` header. The Socket.IO handshake sends + `{"token", "managed_user_id"}`, and envelopes carry the same ID as + `user_id`. `connect()` sends no REST request first. Injected HTTP clients + cannot add or override the header. +- In key mode, `user_id` must be a string in canonical decimal form: 1 to + 9223372036854775807, with no sign, spaces or leading zeros. An `int` is + refused. `validate_user_id` exposes this check. +- A client with `api_key` and no `user_id` can call only the Platform API. A + user-scoped call from it raises `ValueError` before any request is sent. + These also raise `ValueError`: `api_key` with `access_token`, identity + overrides passed to `connect()` in key mode, `platform.*` on a client without + `api_key`, and `auth.verify_code()` or `set_token()` on a client with + `api_key` (checked before the code is sent). - `client_name` adds a `Pine-Client` header to every REST request and to the Socket.IO handshake. - `API_KEY_PREFIXES` (`pine_sk_live_`, `pine_sk_test_`), `validate_external_id`, - `MANAGED_USER_HEADER` and `CLIENT_HEADER` are public. The managed user is - sent only with the API key it was configured with, and identity headers are - applied after caller headers, so neither can be overridden per request. + `validate_user_id`, `MANAGED_USER_HEADER` and `CLIENT_HEADER` are public. The + managed-user header is sent only with the API key it was configured with, and + identity headers are applied after caller headers, so neither can be + overridden per request. Error messages never contain the key, the + `external_id` or the user ID. +- `sessions.history()` on the async and sync clients reads one page of a + session's stored messages over REST (`GET /v2/sessions/{id}/messages`), so + a client without Socket.IO can read the agent's replies. It returns + `SessionHistoryPage` and raises `SessionError`. +- Docs for managed users: `docs/sdk-api.md` (SDK methods), `docs/http-api.md` + (raw HTTP API) and `docs/socketio.md` (Socket.IO, reference only). +- In key mode, a `connect()` that gets no `ready` event within `ready_timeout` + raises `ConnectionError` with the hint to check `api_key` and `user_id`. The + Pine backend refuses a bad handshake by closing the connection, with no + `connect_error`. ## [0.5.0rc1] - 2026-09-18 diff --git a/pyproject.toml b/pyproject.toml index 8356ad6..1173d3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pine-assistant" -version = "0.5.0rc1" +version = "0.5.0rc2" description = "Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client." readme = "README.md" license = "MIT" diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index 9e47a68..f6c1166 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -22,6 +22,8 @@ ) from pine_assistant.models.platform import ManagedUser from pine_assistant.models.session import ( + SessionHistoryMessage, + SessionHistoryPage, SessionInfo, SessionListResponse, SessionMessageStatus, @@ -30,17 +32,17 @@ SessionOutcomesPage, ) from pine_assistant.platform_api import ( - API_KEY_PREFIXES, ManagedUsersAPI, PlatformAPI, SyncManagedUsersAPI, SyncPlatformAPI, validate_external_id, + validate_user_id, ) from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI -from pine_assistant.transport.http import CLIENT_HEADER, MANAGED_USER_HEADER +from pine_assistant.transport.http import API_KEY_PREFIXES, CLIENT_HEADER, MANAGED_USER_HEADER -__version__ = "0.5.0rc1" +__version__ = "0.5.0rc2" __all__ = [ "PineAI", "AsyncPineAI", @@ -57,10 +59,13 @@ "MANAGED_USER_HEADER", "CLIENT_HEADER", "validate_external_id", + "validate_user_id", "AuthIdentity", "AuthTicket", "RedeemedTicket", "SessionInfo", + "SessionHistoryMessage", + "SessionHistoryPage", "SessionListResponse", "SessionMessageStatus", "SessionOutcome", diff --git a/src/pine_assistant/auth.py b/src/pine_assistant/auth.py index 39708e0..2239bac 100644 --- a/src/pine_assistant/auth.py +++ b/src/pine_assistant/auth.py @@ -36,6 +36,7 @@ async def request_code(self, email: str) -> dict[str, Any]: async def verify_code(self, email: str, code: str, request_token: str) -> dict[str, Any]: """Step 2: Verify code and get access token — spec 4.1.2""" + self._http.ensure_token_can_change() try: result = await self._http.post( "/v2/auth/email/verify", @@ -119,6 +120,7 @@ def request_code(self, email: str) -> dict[str, Any]: raise _auth_failure(exc) from exc def verify_code(self, email: str, code: str, request_token: str) -> dict[str, Any]: + self._http.ensure_token_can_change() try: result = self._http.post( "/v2/auth/email/verify", diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 1a81a66..0d771ff 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -18,9 +18,16 @@ from pine_assistant.errors import ConnectionError from pine_assistant.models.events import C2SEvent from pine_assistant.models.form import FormSubmissionResult, FormToUserData, encode_form_answers -from pine_assistant.platform_api import API_KEY_PREFIXES, PlatformAPI, SyncPlatformAPI, validate_external_id +from pine_assistant.platform_api import PlatformAPI, SyncPlatformAPI, validate_user_id from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI -from pine_assistant.transport.http import DEFAULT_API_BASE_PATH, DEFAULT_BASE_URL, HttpClient, SyncHttpClient +from pine_assistant.transport.http import ( + API_KEY_PREFIXES, + DEFAULT_API_BASE_PATH, + DEFAULT_BASE_URL, + KEY_ONLY_MESSAGE, + HttpClient, + SyncHttpClient, +) from pine_assistant.transport.socketio import SocketIOManager DEVICE_ID_FILE = Path.home() / ".pine" / "device_id" @@ -42,23 +49,22 @@ def _platform_identity( access_token: str | None, user_id: str | None, api_key: str | None, - managed_user: str | None, client_name: str | None, ) -> str | None: """Validate the credential mode and return the bearer the client sends. - Error messages never include the key or the external ID. + Error messages never include the key or the user ID. """ if client_name is not None and not (isinstance(client_name, str) and _CLIENT_NAME.fullmatch(client_name)): raise ValueError("client_name must be 1-64 letters, digits, '.', '_' or '-'") - if api_key is None and managed_user is None: + if api_key is None: return access_token - if access_token is not None or user_id is not None: - raise ValueError("api_key cannot be combined with access_token or user_id") + if access_token is not None: + raise ValueError("api_key cannot be combined with access_token") if not isinstance(api_key, str) or not api_key.startswith(API_KEY_PREFIXES): raise ValueError("api_key must be a Pine Platform secret key starting with pine_sk_live_ or pine_sk_test_") - if managed_user is not None: - validate_external_id(managed_user, "managed_user") + if user_id is not None: + validate_user_id(user_id) return api_key @@ -99,12 +105,13 @@ class AsyncPineAI: A client tracks one session. Concurrent sessions need one client each. Authenticate either as a Pine user with ``access_token`` (and ``user_id`` - for Socket.IO), or as a Pine Platform tenant with its secret ``api_key``. - A tenant client calls the Platform API through ``platform``; adding - ``managed_user`` (the tenant's ``external_id``) makes it act as that - managed user: every user-scoped REST request carries the key and a - ``Pine-Managed-User`` header, the Socket.IO handshake carries both, and - ``connect()`` resolves the managed user's Pine user ID itself. + for Socket.IO), or as a partner with its secret ``api_key``. + A partner client calls the Platform API through ``platform``. Add + ``user_id``, the ``id`` of a ``ManagedUser``, to act as that managed user: + every user-scoped REST request carries the key and a + ``Pine-Managed-User-Id`` header, and the Socket.IO handshake carries both. + Without ``user_id``, a user-scoped call raises ``ValueError`` before any + request is sent. ``client_name`` adds a ``Pine-Client`` header to every REST request and to the Socket.IO handshake. """ @@ -122,14 +129,14 @@ def __init__( http_client: httpx.AsyncClient | None = None, http_transport: httpx.AsyncBaseTransport | None = None, api_key: str | None = None, - managed_user: str | None = None, client_name: str | None = None, ): - access_token = _platform_identity(access_token, user_id, api_key, managed_user, client_name) + access_token = _platform_identity(access_token, user_id, api_key, client_name) self._base_url = base_url self._access_token = access_token self._user_id = user_id - self._managed_user = managed_user + self._managed_user_id = user_id if api_key is not None else None + self._key_mode = api_key is not None self._client_name = client_name self._device_id = _get_or_create_device_id(device_id) self._transports = transports @@ -141,7 +148,7 @@ def __init__( api_base_path=api_base_path, client=http_client, transport=http_transport, - managed_user=managed_user, + managed_user_id=self._managed_user_id, client_name=client_name, ) self.auth = Auth(self.http) @@ -164,20 +171,19 @@ async def __aexit__(self, *_exc_info: object) -> None: async def connect(self, access_token: str | None = None, user_id: str | None = None) -> None: """Open Socket.IO and wait until the backend reports it ready. - With ``api_key``/``managed_user`` the identity is fixed at construction; - the managed user's Pine user ID, which envelopes carry, is read once - from ``auth.me()`` and reused for the client's lifetime. + With ``api_key`` the identity is fixed at construction: ``user_id`` goes + in the handshake and in every envelope. """ - if self._managed_user is not None: - if access_token is not None or user_id is not None: - raise ValueError("a managed-user client cannot connect with another access_token or user_id") - if self._user_id is None: - self._user_id = (await self.auth.me()).user_id + if self._key_mode and (access_token is not None or user_id is not None): + raise ValueError("a client built with api_key cannot connect with another access_token or user_id") + if self._key_mode and self._managed_user_id is None: + raise ValueError(KEY_ONLY_MESSAGE) token = access_token or self._access_token uid = user_id or self._user_id if not token or not uid: raise ConnectionError("access_token and user_id required. Run auth flow first.") - self.http.set_token(token) + if not self._key_mode: + self.http.set_token(token) self._sio = SocketIOManager( base_url=self._base_url, @@ -186,7 +192,7 @@ async def connect(self, access_token: str | None = None, user_id: str | None = N device_id=self._device_id, transports=self._transports, ready_timeout=self._ready_timeout, - managed_user=self._managed_user, + managed_user_id=self._managed_user_id, client_name=self._client_name, ) self._chat = ChatEngine(self._sio, check_session_state=self._session_state) @@ -493,8 +499,8 @@ class PineAI: REST resources return concrete values through ``httpx.Client``. Real-time Socket.IO requires an event loop and is intentionally available only on :class:`AsyncPineAI`; this client does not create a hidden loop or thread. - ``api_key``, ``managed_user`` and ``client_name`` behave as on - :class:`AsyncPineAI`. + ``api_key``, ``user_id`` and ``client_name`` behave as on + :class:`AsyncPineAI`; ``user_id`` has no effect without ``api_key``. """ def __init__( @@ -506,16 +512,16 @@ def __init__( http_client: httpx.Client | None = None, http_transport: httpx.BaseTransport | None = None, api_key: str | None = None, - managed_user: str | None = None, + user_id: str | None = None, client_name: str | None = None, ) -> None: self.http = SyncHttpClient( base_url=base_url, - token=_platform_identity(access_token, None, api_key, managed_user, client_name), + token=_platform_identity(access_token, user_id, api_key, client_name), api_base_path=api_base_path, client=http_client, transport=http_transport, - managed_user=managed_user, + managed_user_id=user_id if api_key is not None else None, client_name=client_name, ) self.auth = SyncAuth(self.http) diff --git a/src/pine_assistant/models/platform.py b/src/pine_assistant/models/platform.py index d8ce276..b125b67 100644 --- a/src/pine_assistant/models/platform.py +++ b/src/pine_assistant/models/platform.py @@ -8,8 +8,9 @@ class ManagedUser(BaseModel): """One end user of a Platform integration, addressed by its ``external_id``. - ``id`` is the Pine user ID that ``/api/v2`` and Socket.IO payloads carry; - the Platform API never accepts it as input. + ``id`` is the Pine user ID. Store it, and pass it as ``user_id`` with the + ``api_key`` to act as this user on ``/api/v2`` and Socket.IO. The Platform + API never accepts it as input. """ model_config = ConfigDict(extra="allow") diff --git a/src/pine_assistant/models/session.py b/src/pine_assistant/models/session.py index d48d9d3..5b3741b 100644 --- a/src/pine_assistant/models/session.py +++ b/src/pine_assistant/models/session.py @@ -109,3 +109,34 @@ def normalize_cursor(cls, value: Any) -> str | None: if value is None: return None return SessionInfo.normalize_id(value) + + +class SessionHistoryMessage(BaseModel): + """One stored message; ``data`` is the stored protocol JSON as a string.""" + + model_config = ConfigDict(extra="allow") + + message_id: str + type: str + version: int = 0 + data: str = "" + created_at: str | None = None + + @field_validator("message_id", mode="before") + @classmethod + def normalize_id(cls, value: Any) -> str: + return SessionInfo.normalize_id(value) + + +class SessionHistoryPage(BaseModel): + model_config = ConfigDict(extra="allow") + + messages: list[SessionHistoryMessage] + next_message_id: str | None = None + + @field_validator("next_message_id", mode="before") + @classmethod + def normalize_cursor(cls, value: Any) -> str | None: + if value is None or value == "": + return None + return SessionInfo.normalize_id(value) diff --git a/src/pine_assistant/platform_api.py b/src/pine_assistant/platform_api.py index 7b8fe31..bb754eb 100644 --- a/src/pine_assistant/platform_api.py +++ b/src/pine_assistant/platform_api.py @@ -1,9 +1,9 @@ """ -Pine Platform API — enterprise (tenant) resources authenticated by a secret key. +Pine Platform API — resources of a partner, authenticated by its secret key. -A tenant's secret key (``pine_sk_live_...`` / ``pine_sk_test_...``) belongs on -its servers only. These requests act as the tenant itself and never carry a -``Pine-Managed-User`` header. +A partner's secret key (``pine_sk_live_...`` / ``pine_sk_test_...``) belongs on +its servers only. These requests act as the partner itself and never carry a +``Pine-Managed-User-Id`` header. """ from __future__ import annotations @@ -17,9 +17,10 @@ from pine_assistant.models.platform import ManagedUser from pine_assistant.transport.http import HttpClient, SyncHttpClient -API_KEY_PREFIXES = ("pine_sk_live_", "pine_sk_test_") _EXTERNAL_ID = re.compile(r"[A-Za-z0-9_:|-][A-Za-z0-9._:|-]{0,127}") _MANAGED_USERS = "/platform/v1/managed-users" +_USER_ID = re.compile(r"[1-9][0-9]{0,18}") +_MAX_USER_ID = 9223372036854775807 def validate_external_id(value: str, field: str = "external_id") -> str: @@ -31,6 +32,16 @@ def validate_external_id(value: str, field: str = "external_id") -> str: return value +def validate_user_id(value: str, field: str = "user_id") -> str: + """Check the backend's Pine user ID rule; the value is never echoed on failure.""" + if not isinstance(value, str) or not _USER_ID.fullmatch(value) or int(value) > _MAX_USER_ID: + raise ValueError( + f'{field} must be a string such as "123456789": the decimal Pine user ID, ' + "with no sign, spaces or leading zeros" + ) + return value + + def _create_body(external_id: str, email: str, name: str, phone: str | None) -> dict[str, str]: body = {"external_id": validate_external_id(external_id), "email": email, "name": name} if phone is not None: @@ -60,22 +71,22 @@ def __init__(self, http: HttpClient): async def create(self, external_id: str, *, email: str, name: str, phone: str | None = None) -> ManagedUser: """Create the managed user for ``external_id``, or return the existing one unchanged. - Idempotent per integration and ``external_id``: a repeated call returns + Idempotent per partner and ``external_id``: a repeated call returns the stored user even when ``email``, ``name`` or ``phone`` differ. ``phone`` is an E.164 number you have verified. """ body = _create_body(external_id, email, name, phone) try: - payload = await self._http.post(_MANAGED_USERS, body, as_tenant=True) + payload = await self._http.post(_MANAGED_USERS, body, as_partner=True) except PineAIError as exc: raise _platform_failure(exc) from None return _parse_managed_user(payload) async def get(self, external_id: str) -> ManagedUser: - """Return the managed user for ``external_id``; another integration's user is a 404.""" + """Return the managed user for ``external_id``; another partner's user is a 404.""" path = _path(external_id) try: - payload = await self._http.get(path, as_tenant=True) + payload = await self._http.get(path, as_partner=True) except PineAIError as exc: raise _platform_failure(exc) from None return _parse_managed_user(payload) @@ -88,7 +99,7 @@ def __init__(self, http: SyncHttpClient): def create(self, external_id: str, *, email: str, name: str, phone: str | None = None) -> ManagedUser: body = _create_body(external_id, email, name, phone) try: - payload = self._http.post(_MANAGED_USERS, body, as_tenant=True) + payload = self._http.post(_MANAGED_USERS, body, as_partner=True) except PineAIError as exc: raise _platform_failure(exc) from None return _parse_managed_user(payload) @@ -96,7 +107,7 @@ def create(self, external_id: str, *, email: str, name: str, phone: str | None = def get(self, external_id: str) -> ManagedUser: path = _path(external_id) try: - payload = self._http.get(path, as_tenant=True) + payload = self._http.get(path, as_partner=True) except PineAIError as exc: raise _platform_failure(exc) from None return _parse_managed_user(payload) diff --git a/src/pine_assistant/sessions.py b/src/pine_assistant/sessions.py index b3629ed..5ecfe98 100644 --- a/src/pine_assistant/sessions.py +++ b/src/pine_assistant/sessions.py @@ -10,6 +10,7 @@ from pine_assistant.errors import PineAIError, SessionError from pine_assistant.models.session import ( + SessionHistoryPage, SessionInfo, SessionListResponse, SessionMessageStatus, @@ -55,6 +56,13 @@ def _parse_outcomes_page(payload: Any) -> SessionOutcomesPage: raise SessionError("Pine API returned an invalid session outcomes response", "invalid_response") from None +def _parse_history_page(payload: Any) -> SessionHistoryPage: + try: + return SessionHistoryPage.model_validate(payload) + except ValidationError: + raise SessionError("Pine API returned an invalid session history response", "invalid_response") from None + + def _request_id(request_id: str | None) -> str | None: if request_id is None: return None @@ -65,15 +73,21 @@ def _request_id(request_id: str | None) -> str | None: return request_id or None -def _outcomes_cursor(before: str | int) -> str: - if isinstance(before, bool) or not isinstance(before, (str, int)): - raise ValueError("before must be a decimal message identifier") - value = str(before).strip() +def _message_cursor(cursor: str | int, field: str) -> str: + if isinstance(cursor, bool) or not isinstance(cursor, (str, int)): + raise ValueError(f"{field} must be a decimal message identifier") + value = str(cursor).strip() if not value.isdecimal(): - raise ValueError("before must be a decimal message identifier") + raise ValueError(f"{field} must be a decimal message identifier") return value +def _page_limit(limit: int) -> int: + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: + raise ValueError("limit must be between 1 and 100") + return limit + + def _session_error(exc: PineAIError) -> SessionError: """Translate transport failures without exposing an upstream response body.""" code = exc.code @@ -174,11 +188,9 @@ async def outcomes( self, session_id: str | int, *, limit: int = 20, before: str | int | None = None, ) -> SessionOutcomesPage: """List persisted Outcomes newest first; follow ``next_cursor`` for older pages.""" - if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: - raise ValueError("limit must be between 1 and 100") - params: dict[str, str | int] = {"limit": limit} + params: dict[str, str | int] = {"limit": _page_limit(limit)} if before is not None: - params["before"] = _outcomes_cursor(before) + params["before"] = _message_cursor(before, "before") try: return _parse_outcomes_page( await self._http.get(f"/v2/sessions/{_session_id(session_id)}/outcomes", params=params), @@ -186,6 +198,20 @@ async def outcomes( except PineAIError as exc: raise _session_error(exc) from exc + async def history( + self, session_id: str | int, *, limit: int = 30, before_message_id: str | int | None = None, + ) -> SessionHistoryPage: + """Read stored visible messages newest first; follow ``next_message_id`` for older pages.""" + params: dict[str, str | int] = {"limit": _page_limit(limit)} + if before_message_id is not None: + params["before_message_id"] = _message_cursor(before_message_id, "before_message_id") + try: + return _parse_history_page( + await self._http.get(f"/v2/sessions/{_session_id(session_id)}/messages", params=params), + ) + except PineAIError as exc: + raise _session_error(exc) from exc + async def create(self) -> dict[str, Any]: """Create session — spec 4.3.3""" return await self._http.post("/v2/sessions") @@ -316,11 +342,9 @@ def outcomes( self, session_id: str | int, *, limit: int = 20, before: str | int | None = None, ) -> SessionOutcomesPage: """List persisted Outcomes newest first; follow ``next_cursor`` for older pages.""" - if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: - raise ValueError("limit must be between 1 and 100") - params: dict[str, str | int] = {"limit": limit} + params: dict[str, str | int] = {"limit": _page_limit(limit)} if before is not None: - params["before"] = _outcomes_cursor(before) + params["before"] = _message_cursor(before, "before") try: return _parse_outcomes_page( self._http.get(f"/v2/sessions/{_session_id(session_id)}/outcomes", params=params), @@ -328,6 +352,20 @@ def outcomes( except PineAIError as exc: raise _session_error(exc) from exc + def history( + self, session_id: str | int, *, limit: int = 30, before_message_id: str | int | None = None, + ) -> SessionHistoryPage: + """Read stored visible messages newest first; follow ``next_message_id`` for older pages.""" + params: dict[str, str | int] = {"limit": _page_limit(limit)} + if before_message_id is not None: + params["before_message_id"] = _message_cursor(before_message_id, "before_message_id") + try: + return _parse_history_page( + self._http.get(f"/v2/sessions/{_session_id(session_id)}/messages", params=params), + ) + except PineAIError as exc: + raise _session_error(exc) from exc + def create(self) -> dict[str, Any]: return self._http.post("/v2/sessions") diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index 2341ba3..3f764da 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -11,9 +11,16 @@ DEFAULT_BASE_URL = "https://www.19pine.ai" DEFAULT_API_BASE_PATH = "/api" _USER_AGENT = "pine-assistant-sdk" -MANAGED_USER_HEADER = "Pine-Managed-User" +API_KEY_PREFIXES = ("pine_sk_live_", "pine_sk_test_") +MANAGED_USER_HEADER = "Pine-Managed-User-Id" CLIENT_HEADER = "Pine-Client" _IDENTITY_HEADERS = ("Authorization", MANAGED_USER_HEADER, CLIENT_HEADER) +KEY_ONLY_MESSAGE = ( + "a client built with api_key alone can call only the Platform API; " + "pass user_id to act as a managed user" +) +PLATFORM_KEY_MESSAGE = "the Platform API needs a client built with api_key" +FIXED_KEY_MESSAGE = "a client built with api_key cannot change its token; use a separate client for a user token" # Public HTTPX timeout inputs accepted by both sync and async clients. HTTPX's # internal ``TimeoutTypes`` alias is not exported in every supported release. @@ -91,27 +98,36 @@ def _json_data(response: httpx.Response) -> Any: class _RequestIdentity: - """The credential, managed user and client name shared by the sync and async transports.""" + """The credential, managed user ID and client name shared by the sync and async transports.""" - def __init__(self, token: str | None, managed_user: str | None, client_name: str | None) -> None: + def __init__(self, token: str | None, managed_user_id: str | None, client_name: str | None) -> None: self._token = token - self._managed_user = managed_user + self._managed_user_id = managed_user_id # A managed user is bound to the API key it was configured with and is - # never asserted with any other token, e.g. one installed by set_token(). - self._managed_user_key = token if managed_user else None + # never asserted with any other token. + self._api_key = token if token is not None and token.startswith(API_KEY_PREFIXES) else None self._client_name = client_name + def ensure_token_can_change(self) -> None: + if self._api_key is not None: + raise ValueError(FIXED_KEY_MESSAGE) + def set_token(self, token: str | None) -> None: + self.ensure_token_can_change() self._token = token - def _identity(self, authenticated: bool, token: str | None, as_tenant: bool) -> dict[str, str]: - """The identity headers a request carries; Platform requests the tenant makes as itself omit the user.""" + def _identity(self, authenticated: bool, token: str | None, as_partner: bool) -> dict[str, str]: + """The identity headers a request carries; Platform API requests omit the user.""" + if as_partner and self._api_key is None: + raise ValueError(PLATFORM_KEY_MESSAGE) identity: dict[str, str] = {} actual_token = self._token if token is None else token if authenticated and actual_token: identity["Authorization"] = f"Bearer {actual_token}" - if self._managed_user and actual_token == self._managed_user_key and not as_tenant: - identity[MANAGED_USER_HEADER] = self._managed_user + if actual_token == self._api_key and not as_partner: + if self._managed_user_id is None: + raise ValueError(KEY_ONLY_MESSAGE) + identity[MANAGED_USER_HEADER] = self._managed_user_id if self._client_name: identity[CLIENT_HEADER] = self._client_name return identity @@ -142,12 +158,12 @@ def __init__( client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, timeout: TimeoutConfig = 30.0, - managed_user: str | None = None, + managed_user_id: str | None = None, client_name: str | None = None, ): if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") - super().__init__(token, managed_user, client_name) + super().__init__(token, managed_user_id, client_name) self._api_base_url = _api_url(base_url, api_base_path) self._owns_client = client is None self._client = client or httpx.AsyncClient( @@ -157,7 +173,6 @@ def __init__( follow_redirects=False, ) - def _url(self, path: str) -> str: # Absolute paths keep an injected client's base_url from changing the # destination, and redirects are refused to protect bearer credentials. @@ -174,9 +189,9 @@ async def _request( token: str | None = None, files: Any = None, extra_headers: dict[str, str] | None = None, - as_tenant: bool = False, + as_partner: bool = False, ) -> Any: - identity = self._identity(authenticated, token, as_tenant) + identity = self._identity(authenticated, token, as_partner) headers = {"Content-Type": "application/json", **(extra_headers or {})} if files is not None: # httpx must generate the multipart boundary itself. @@ -194,17 +209,17 @@ async def _request( return _json_data(response) async def get(self, path: str, authenticated: bool = True, *, token: str | None = None, - params: dict[str, str | int] | None = None, as_tenant: bool = False) -> Any: + params: dict[str, str | int] | None = None, as_partner: bool = False) -> Any: return await self._request( - "GET", path, authenticated=authenticated, token=token, params=params, as_tenant=as_tenant, + "GET", path, authenticated=authenticated, token=token, params=params, as_partner=as_partner, ) async def post(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, *, token: str | None = None, headers: dict[str, str] | None = None, - as_tenant: bool = False) -> Any: + as_partner: bool = False) -> Any: return await self._request( "POST", path, body=body, authenticated=authenticated, token=token, extra_headers=headers, - as_tenant=as_tenant, + as_partner=as_partner, ) async def put(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, @@ -241,12 +256,12 @@ def __init__( client: httpx.Client | None = None, transport: httpx.BaseTransport | None = None, timeout: TimeoutConfig = 30.0, - managed_user: str | None = None, + managed_user_id: str | None = None, client_name: str | None = None, ) -> None: if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") - super().__init__(token, managed_user, client_name) + super().__init__(token, managed_user_id, client_name) self._api_base_url = _api_url(base_url, api_base_path) self._owns_client = client is None self._client = client or httpx.Client( @@ -256,7 +271,6 @@ def __init__( follow_redirects=False, ) - def _url(self, path: str) -> str: return f"{self._api_base_url}/{path.lstrip('/')}" @@ -271,9 +285,9 @@ def _request( token: str | None = None, files: Any = None, extra_headers: dict[str, str] | None = None, - as_tenant: bool = False, + as_partner: bool = False, ) -> Any: - identity = self._identity(authenticated, token, as_tenant) + identity = self._identity(authenticated, token, as_partner) headers = {"Content-Type": "application/json", **(extra_headers or {})} if files is not None: headers.pop("Content-Type", None) @@ -290,17 +304,17 @@ def _request( return _json_data(response) def get(self, path: str, authenticated: bool = True, *, token: str | None = None, - params: dict[str, str | int] | None = None, as_tenant: bool = False) -> Any: + params: dict[str, str | int] | None = None, as_partner: bool = False) -> Any: return self._request( - "GET", path, authenticated=authenticated, token=token, params=params, as_tenant=as_tenant, + "GET", path, authenticated=authenticated, token=token, params=params, as_partner=as_partner, ) def post(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, *, token: str | None = None, headers: dict[str, str] | None = None, - as_tenant: bool = False) -> Any: + as_partner: bool = False) -> Any: return self._request( "POST", path, body=body, authenticated=authenticated, token=token, extra_headers=headers, - as_tenant=as_tenant, + as_partner=as_partner, ) def put(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True, diff --git a/src/pine_assistant/transport/socketio.py b/src/pine_assistant/transport/socketio.py index 9aaa1b0..28b8fec 100644 --- a/src/pine_assistant/transport/socketio.py +++ b/src/pine_assistant/transport/socketio.py @@ -2,7 +2,7 @@ Socket.IO connection manager — spec sections 3.1, 5.1.2. Connection: wss://{baseUrl}/api/v2/socket.io/ with auth={token}, plus -managed_user when a Platform API key acts for a managed user. +managed_user_id when a Platform API key acts for a managed user. Waits for `ready` event before resolving connect(). """ @@ -43,12 +43,12 @@ def __init__( device_id: str | None = None, transports: list[str] | None = None, ready_timeout: float = 15.0, - managed_user: str | None = None, + managed_user_id: str | None = None, client_name: str | None = None, ): self._base_url = base_url self._token = token - self._managed_user = managed_user + self._managed_user_id = managed_user_id self._client_name = client_name self._user_id = user_id self._device_id = device_id or str(uuid.uuid4()) @@ -181,11 +181,14 @@ async def disconnect(_reason: str = "") -> None: self._connected = False self._disconnect_event.set() # The Pine backend accepts the WebSocket but only emits 'ready' after - # its own auth check. A timeout here almost always means the token or - # user_id is rejected — surface that hint instead of a generic timeout. + # its own auth check. A timeout here almost always means the + # credentials are rejected — surface that hint instead of a generic timeout. + if self._managed_user_id: + hint = "Check api_key and user_id." + else: + hint = "This usually means access_token or user_id is invalid/expired — re-run the auth flow." raise PineConnectionError( - f"Socket.IO connected but no 'ready' event after {self._ready_timeout}s. " - "This usually means access_token or user_id is invalid/expired — re-run the auth flow." + f"Socket.IO connected but no 'ready' event after {self._ready_timeout}s. {hint}" ) from None self._connection_generation += 1 # An earlier connection may still be unwinding after its disconnect. @@ -199,8 +202,8 @@ async def disconnect(_reason: str = "") -> None: def _handshake_auth(self) -> dict[str, str]: auth = {"token": self._token} - if self._managed_user: - auth["managed_user"] = self._managed_user + if self._managed_user_id: + auth["managed_user_id"] = self._managed_user_id return auth def _track_membership(self, event_type: str, session_id: str | None) -> None: diff --git a/tests/test_m1_resources.py b/tests/test_m1_resources.py index eaed31e..a719cc8 100644 --- a/tests/test_m1_resources.py +++ b/tests/test_m1_resources.py @@ -450,6 +450,83 @@ def test_session_message_and_outcomes_validate_session_ids(session_id): client.close() +def history_page(next_message_id=""): + return success({ + "messages": [ + {"message_id": "19", "type": "session:text", "version": 2, "data": '{"content":"Booked"}', + "created_at": "2026-10-19T01:02:03Z", "extra": "kept"}, + {"message_id": "18", "type": "session:message", "version": 2, "data": '{"content":"Book it"}', + "created_at": None}, + ], + "next_message_id": next_message_id, + }) + + +@pytest.mark.asyncio +async def test_async_session_history_reads_one_page_and_its_cursor(): + async def handler(request): + assert request.method == "GET" + assert request.url.path == "/api/v2/sessions/7/messages" + assert dict(request.url.params) == {"limit": "2", "before_message_id": "20"} + return httpx.Response(200, json=history_page("18")) + + async with AsyncPineAI(base_url="https://pine.test", http_transport=httpx.MockTransport(handler)) as client: + page = await client.sessions.history("7", limit=2, before_message_id=20) + + newest, older = page.messages + assert page.next_message_id == "18" + assert (newest.message_id, newest.type, newest.version) == ("19", "session:text", 2) + assert json.loads(newest.data) == {"content": "Booked"} + assert newest.model_extra == {"extra": "kept"} + assert older.created_at is None + + +def test_sync_session_history_defaults_and_empty_cursor_ends_paging(): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json=history_page("")) + + with PineAI(access_token="user", base_url="https://pine.test", http_transport=httpx.MockTransport(handler)) as client: + page = client.sessions.history("7") + + assert page.next_message_id is None + assert [message.message_id for message in page.messages] == ["19", "18"] + assert dict(requests[0].url.params) == {"limit": "30"} + + +@pytest.mark.parametrize( + "kwargs", + [ + {"session_id": 0}, {"session_id": "bad"}, {"session_id": True}, + {"limit": 0}, {"limit": 101}, {"limit": True}, + {"before_message_id": "x"}, {"before_message_id": True}, + ], +) +def test_session_history_validates_input_before_any_request(kwargs): + requests = [] + arguments = {"session_id": "7", **kwargs} + with PineAI( + base_url="https://pine.test", http_transport=httpx.MockTransport(lambda request: requests.append(request)), + ) as client, pytest.raises(ValueError): + client.sessions.history(arguments.pop("session_id"), **arguments) + assert requests == [] + + +@pytest.mark.asyncio +async def test_session_history_failures_raise_session_error_without_upstream_body(): + async def handler(_request): + return httpx.Response(404, json={"status": "error", "error": {"code": "session_not_found", "message": "secret"}}) + + async with AsyncPineAI(base_url="https://pine.test", http_transport=httpx.MockTransport(handler)) as client: + with pytest.raises(SessionError) as excinfo: + await client.sessions.history("7") + + assert (excinfo.value.code, excinfo.value.status_code) == ("session_not_found", 404) + assert "secret" not in str(excinfo.value) + + @pytest.mark.asyncio async def test_upload_uses_multipart_content_type(tmp_path): attachment = tmp_path / "note.txt" diff --git a/tests/test_platform.py b/tests/test_platform.py index dcc823f..9452091 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -1,5 +1,6 @@ """Platform API and managed-user mode against a fake backend on 127.0.0.1.""" +import asyncio import json from dataclasses import dataclass, field from typing import Any @@ -14,18 +15,19 @@ MANAGED_USER_HEADER, AsyncPineAI, AuthError, + ConnectionError, ManagedUser, PineAI, - PineAIError, PlatformError, SessionError, validate_external_id, + validate_user_id, ) API_KEY = "pine_sk_test_c2VjcmV0LWtleS1mb3ItdGVzdHM" EXTERNAL_ID = "acme:user|42-x" ENCODED_EXTERNAL_ID = "acme%3Auser%7C42-x" -PINE_USER_ID = "4242" +PINE_USER_ID = "730194412688371" def success(data): @@ -57,6 +59,7 @@ class FakeBackend: error_status: int | None = None error_code: str = "platform_failure" existing: bool = False + refuse_handshake: bool = False def app(self) -> web.Application: sio = socketio.AsyncServer(async_mode="aiohttp") @@ -67,7 +70,16 @@ def app(self) -> web.Application: async def connect(sid, environ, auth): self.handshakes.append(auth) self.handshake_clients.append(environ.get("HTTP_PINE_CLIENT")) - await sio.emit("ready", {}, to=sid) + if self.refuse_handshake: + # Like the Pine backend: accept the connection, then close it + # with no connect_error and no code. + async def close(): + await asyncio.sleep(0.05) + await sio.disconnect(sid) + + sio.start_background_task(close) + else: + await sio.emit("ready", {}, to=sid) @sio.on("session:history") async def history(sid, envelope): @@ -92,7 +104,7 @@ async def record(request: web.Request) -> web.Response | None: if self.error_status is not None: # A hostile body: none of it may surface through the SDK. return web.json_response( - {"status": "error", "error": {"code": self.error_code, "message": f"{API_KEY} {EXTERNAL_ID}"}}, + {"status": "error", "error": {"code": self.error_code, "message": f"{API_KEY} {EXTERNAL_ID} {PINE_USER_ID}"}}, status=self.error_status, ) return None @@ -117,10 +129,16 @@ async def sessions(request): success({"sessions": [{"id": "7"}], "total": 1, "limit": 30, "offset": 0}) ) + async def read_history(request): + return await record(request) or web.json_response( + success({"messages": [{"message_id": "9", "type": "session:text", "version": 2, "data": "{}"}]}) + ) + app.router.add_post("/api/platform/v1/managed-users", create_user) app.router.add_get("/api/platform/v1/managed-users/{external_id}", get_user) app.router.add_get("/api/v2/auth/me", auth_me) app.router.add_get("/api/v2/sessions", sessions) + app.router.add_get("/api/v2/sessions/{id}/messages", read_history) app.router.add_post("/api/v2/sessions", sessions) app.router.add_post("/api/v2/auth/tickets", sessions) return app @@ -144,34 +162,47 @@ async def backend(): def _assert_no_secrets(text: str) -> None: assert API_KEY not in text assert EXTERNAL_ID not in text + assert PINE_USER_ID not in text @pytest.mark.asyncio async def test_managed_user_rest_requests_carry_key_header_and_client_name(backend): async with AsyncPineAI( - api_key=API_KEY, managed_user=EXTERNAL_ID, client_name="mcp", base_url=backend.base_url, + api_key=API_KEY, user_id=PINE_USER_ID, client_name="mcp", base_url=backend.base_url, ) as client: listed = await client.sessions.list() assert listed.sessions[0].id == "7" [request] = backend.requests assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" - assert request["headers"]["Pine-Managed-User"] == EXTERNAL_ID + assert request["headers"]["Pine-Managed-User-Id"] == PINE_USER_ID + assert "Pine-Managed-User" not in request["headers"] assert request["headers"]["Pine-Client"] == "mcp" @pytest.mark.asyncio -async def test_managed_user_connect_resolves_user_once_and_sends_handshake(backend): - async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: +async def test_managed_user_reads_session_history_over_rest(backend): + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: + page = await client.sessions.history("7") + + assert [message.message_id for message in page.messages] == ["9"] + [request] = backend.requests + assert request["path"] == "/api/v2/sessions/7/messages?limit=30" + assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" + assert request["headers"][MANAGED_USER_HEADER] == PINE_USER_ID + + +@pytest.mark.asyncio +async def test_managed_user_connect_sends_the_id_in_handshake_and_envelope_without_auth_me(backend): + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: await client.connect() await client.get_history("7") await client.disconnect() await client.connect() - assert [request["path"] for request in backend.requests] == ["/api/v2/auth/me"] - assert backend.requests[0]["headers"]["Pine-Managed-User"] == EXTERNAL_ID - assert "Pine-Client" not in backend.requests[0]["headers"] - assert backend.handshakes == [{"token": API_KEY, "managed_user": EXTERNAL_ID}] * 2 + assert backend.requests == [] + assert backend.handshakes == [{"token": API_KEY, "managed_user_id": PINE_USER_ID}] * 2 + assert all(isinstance(auth["managed_user_id"], str) for auth in backend.handshakes) assert backend.handshake_clients == [None, None] assert backend.envelopes[0]["metadata"]["source"]["user_id"] == PINE_USER_ID @@ -179,10 +210,9 @@ async def test_managed_user_connect_resolves_user_once_and_sends_handshake(backe @pytest.mark.asyncio async def test_client_name_is_sent_on_the_socketio_handshake(backend): async with AsyncPineAI( - api_key=API_KEY, managed_user=EXTERNAL_ID, client_name="mcp", base_url=backend.base_url, + api_key=API_KEY, user_id=PINE_USER_ID, client_name="mcp", base_url=backend.base_url, ) as client: await client.connect() - assert backend.requests[0]["headers"]["Pine-Client"] == "mcp" assert backend.handshake_clients == ["mcp"] @@ -196,9 +226,9 @@ async def test_user_token_handshake_is_unchanged(backend): @pytest.mark.asyncio @pytest.mark.parametrize(("existing", "phone"), [(False, "+14155550100"), (True, None)]) -async def test_create_managed_user_is_typed_and_acts_as_the_tenant(backend, existing, phone): +async def test_create_managed_user_is_typed_and_acts_as_the_partner(backend, existing, phone): backend.existing = existing - async with AsyncPineAI(api_key=API_KEY, managed_user="someone-else", base_url=backend.base_url) as client: + async with AsyncPineAI(api_key=API_KEY, user_id="999", base_url=backend.base_url) as client: user = await client.platform.managed_users.create( EXTERNAL_ID, email="ada@example.com", name="Ada Lovelace", phone=phone, ) @@ -216,7 +246,7 @@ async def test_create_managed_user_is_typed_and_acts_as_the_tenant(backend, exis expected = {"external_id": EXTERNAL_ID, "email": "ada@example.com", "name": "Ada Lovelace"} assert request["body"] == (expected | {"phone": phone} if phone else expected) assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" - assert "Pine-Managed-User" not in request["headers"] + assert MANAGED_USER_HEADER not in request["headers"] @pytest.mark.asyncio @@ -226,7 +256,7 @@ async def test_get_managed_user_percent_encodes_the_external_id(backend): assert user.id == PINE_USER_ID assert backend.requests[0]["path"] == f"/api/platform/v1/managed-users/{ENCODED_EXTERNAL_ID}" - assert "Pine-Managed-User" not in backend.requests[0]["headers"] + assert MANAGED_USER_HEADER not in backend.requests[0]["headers"] @pytest.mark.asyncio @@ -245,40 +275,105 @@ async def test_platform_errors_keep_code_and_status_without_upstream_body(backen @pytest.mark.asyncio -@pytest.mark.parametrize("status", [401, 403, 404]) -async def test_managed_user_resolution_failure_is_typed_and_does_not_connect(backend, status): - backend.error_status = status - async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: - with pytest.raises(PineAIError) as excinfo: +@pytest.mark.parametrize( + ("kwargs", "hint"), + [ + ({"api_key": API_KEY, "user_id": PINE_USER_ID}, "Check api_key and user_id."), + ({"access_token": "user-token", "user_id": "17"}, "re-run the auth flow"), + ], +) +async def test_refused_handshake_raises_connection_error_with_a_credentials_hint(backend, kwargs, hint): + backend.refuse_handshake = True + async with AsyncPineAI(base_url=backend.base_url, ready_timeout=0.5, **kwargs) as client: + with pytest.raises(ConnectionError) as excinfo: await client.connect() - assert excinfo.value.status_code == status + assert not client.connected + + assert len(backend.handshakes) == 1 + message = str(excinfo.value) + assert "no 'ready' event after 0.5s" in message + assert hint in message + _assert_no_secrets(message) + + +@pytest.mark.asyncio +async def test_key_only_client_refuses_user_scoped_calls_before_any_request(backend): + async with AsyncPineAI(api_key=API_KEY, base_url=backend.base_url) as client: + for call in (client.sessions.list, client.auth.me, client.connect): + with pytest.raises(ValueError) as excinfo: + await call() + assert "pass user_id" in str(excinfo.value) + _assert_no_secrets(str(excinfo.value)) + await client.platform.managed_users.get(EXTERNAL_ID) + + with PineAI(api_key=API_KEY, base_url=backend.base_url) as client, pytest.raises(ValueError) as excinfo: + client.sessions.list() + assert "pass user_id" in str(excinfo.value) + + assert [request["path"] for request in backend.requests] == [ + f"/api/platform/v1/managed-users/{ENCODED_EXTERNAL_ID}", + ] assert backend.handshakes == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("override", [{"access_token": "user-token"}, {"user_id": "17"}, {"user_id": PINE_USER_ID}]) +async def test_key_mode_connect_refuses_identity_overrides(backend, override): + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: + with pytest.raises(ValueError) as excinfo: + await client.connect(**override) _assert_no_secrets(str(excinfo.value)) + assert backend.handshakes == [] + + +INVALID_USER_IDS = ["0", "01", "+1", " 1", "1 ", "1\n", "", "9223372036854775808", "\u0661", "usr_1", 42, True, None] + + +@pytest.mark.parametrize("value", INVALID_USER_IDS) +def test_validate_user_id_rejects_non_canonical_ids_without_echoing(value): + with pytest.raises(ValueError) as excinfo: + validate_user_id(value) + message = str(excinfo.value) + assert 'user_id must be a string such as "123456789"' in message + if value not in ("", None, True): + assert str(value) not in message + + +@pytest.mark.parametrize("value", ["1", "123456789", PINE_USER_ID, "9223372036854775807"]) +def test_validate_user_id_accepts_canonical_ids(value): + assert validate_user_id(value) == value @pytest.mark.parametrize( "kwargs", [ - {"api_key": API_KEY, "managed_user": EXTERNAL_ID, "access_token": "user-token"}, - {"api_key": API_KEY, "managed_user": EXTERNAL_ID, "user_id": "17"}, - {"managed_user": EXTERNAL_ID}, - {"api_key": "not-a-pine-key", "managed_user": EXTERNAL_ID}, - {"api_key": "pine_sk_prod_c2VjcmV0", "managed_user": EXTERNAL_ID}, + {"api_key": API_KEY, "user_id": PINE_USER_ID, "access_token": "user-token"}, + {"api_key": API_KEY, "access_token": "user-token"}, + {"api_key": "not-a-pine-key", "user_id": PINE_USER_ID}, + {"api_key": "pine_sk_prod_c2VjcmV0", "user_id": PINE_USER_ID}, {"api_key": "pine_sk_c2VjcmV0"}, - {"api_key": API_KEY, "managed_user": ".leading-dot"}, - {"api_key": API_KEY, "managed_user": "x" * 129}, - {"api_key": API_KEY, "managed_user": "has space"}, + {"api_key": API_KEY, "user_id": EXTERNAL_ID}, + {"api_key": API_KEY, "user_id": "0730194412688371"}, + {"api_key": API_KEY, "user_id": "9223372036854775808"}, + {"api_key": API_KEY, "user_id": 730194412688371}, + {"api_key": API_KEY, "user_id": True}, {"api_key": API_KEY, "client_name": "bad name"}, ], ) def test_invalid_platform_configuration_is_rejected_without_echoing_values(kwargs): - client_types = (AsyncPineAI,) if "user_id" in kwargs else (AsyncPineAI, PineAI) - for client_type in client_types: + for client_type in (AsyncPineAI, PineAI): with pytest.raises(ValueError) as excinfo: client_type(base_url="https://pine.test", **kwargs) _assert_no_secrets(str(excinfo.value)) for value in kwargs.values(): - assert value not in str(excinfo.value) + if value is not True: + assert str(value) not in str(excinfo.value) + + +def test_managed_user_constructor_is_gone(): + for client_type in (AsyncPineAI, PineAI): + with pytest.raises(TypeError): + client_type(api_key=API_KEY, managed_user=EXTERNAL_ID) @pytest.mark.asyncio @@ -303,20 +398,20 @@ async def handler(request): return httpx.Response(200, json=success({"user_id": "1"})) injected = httpx.AsyncClient( - headers={"Pine-Managed-User": "injected"}, transport=httpx.MockTransport(handler), + headers={MANAGED_USER_HEADER: "1"}, transport=httpx.MockTransport(handler), ) async with AsyncPineAI(access_token="user-token", base_url="https://pine.test", http_client=injected) as client: await client.auth.me() async with AsyncPineAI( - api_key=API_KEY, managed_user=EXTERNAL_ID, base_url="https://pine.test", http_client=injected, + api_key=API_KEY, user_id=PINE_USER_ID, base_url="https://pine.test", http_client=injected, ) as client: await client.auth.me() await client.platform.managed_users.get(EXTERNAL_ID) await injected.aclose() - assert "Pine-Managed-User" not in seen[0].headers - assert seen[1].headers["Pine-Managed-User"] == EXTERNAL_ID - assert "Pine-Managed-User" not in seen[2].headers + assert MANAGED_USER_HEADER not in seen[0].headers + assert seen[1].headers[MANAGED_USER_HEADER] == PINE_USER_ID + assert MANAGED_USER_HEADER not in seen[2].headers def test_sync_client_supports_platform_and_managed_user_requests(): @@ -329,7 +424,7 @@ def handler(request): return httpx.Response(200, json=success({"sessions": [], "total": 0, "limit": 30, "offset": 0})) with PineAI( - api_key=API_KEY, managed_user=EXTERNAL_ID, client_name="mcp", base_url="https://pine.test", + api_key=API_KEY, user_id=PINE_USER_ID, client_name="mcp", base_url="https://pine.test", http_transport=httpx.MockTransport(handler), ) as client: created = client.platform.managed_users.create(EXTERNAL_ID, email="ada@example.com", name="Ada Lovelace") @@ -337,17 +432,31 @@ def handler(request): assert created.id == PINE_USER_ID assert seen[0].url.raw_path == b"/api/platform/v1/managed-users" - assert "Pine-Managed-User" not in seen[0].headers - assert seen[1].headers["Pine-Managed-User"] == EXTERNAL_ID + assert MANAGED_USER_HEADER not in seen[0].headers + assert seen[1].headers[MANAGED_USER_HEADER] == PINE_USER_ID assert all(request.headers["Authorization"] == f"Bearer {API_KEY}" for request in seen) assert all(request.headers["Pine-Client"] == "mcp" for request in seen) @pytest.mark.asyncio -@pytest.mark.parametrize(("status", "code"), [(403, "platform_route_not_allowed"), (429, "rate_limited")]) +@pytest.mark.parametrize( + ("status", "code"), + [ + (400, "invalid_request"), + (401, "unauthorized"), + (402, "platform_spend_limit_reached"), + (403, "platform_access_suspended"), + (403, "platform_managed_user_unavailable"), + (403, "platform_route_not_allowed"), + (404, "platform_managed_user_not_found"), + (409, "platform_session_limit_reached"), + (409, "platform_session_state_conflict"), + (429, "too_many_requests"), + ], +) async def test_user_scoped_failures_keep_code_and_status_without_upstream_body(backend, status, code): backend.error_status, backend.error_code = status, code - async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: with pytest.raises(SessionError) as excinfo: await client.sessions.list() assert (excinfo.value.code, excinfo.value.status_code) == (code, status) @@ -356,29 +465,59 @@ async def test_user_scoped_failures_keep_code_and_status_without_upstream_body(b @pytest.mark.asyncio async def test_managed_user_is_never_sent_with_a_token_other_than_the_key(backend): - async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: - client.http.set_token("user-jwt") - await client.sessions.list() + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: await client.http.get("/v2/sessions", token=API_KEY) await client.http.get("/v2/sessions", token="other-jwt") + await client.http.get("/v2/sessions", token="pine_sk_test_another-key") first, second, third = (request["headers"] for request in backend.requests) - assert first["Authorization"] == "Bearer user-jwt" and MANAGED_USER_HEADER not in first - assert second["Authorization"] == f"Bearer {API_KEY}" and second[MANAGED_USER_HEADER] == EXTERNAL_ID - assert third["Authorization"] == "Bearer other-jwt" and MANAGED_USER_HEADER not in third + assert first["Authorization"] == f"Bearer {API_KEY}" and first[MANAGED_USER_HEADER] == PINE_USER_ID + assert second["Authorization"] == "Bearer other-jwt" and MANAGED_USER_HEADER not in second + assert third["Authorization"] == "Bearer pine_sk_test_another-key" and MANAGED_USER_HEADER not in third + + +@pytest.mark.asyncio +@pytest.mark.parametrize("user_id", [PINE_USER_ID, None]) +async def test_key_client_refuses_a_user_token_before_any_request(backend, user_id): + async with AsyncPineAI(api_key=API_KEY, user_id=user_id, base_url=backend.base_url) as client: + with pytest.raises(ValueError) as set_token: + client.http.set_token("user-jwt") + with pytest.raises(ValueError) as verified: + await client.auth.verify_code("ada@example.com", "1234", "request-token") + with PineAI(api_key=API_KEY, user_id=user_id, base_url=backend.base_url) as client, pytest.raises(ValueError): + client.auth.verify_code("ada@example.com", "1234", "request-token") + + for excinfo in (set_token, verified): + assert "cannot change its token" in str(excinfo.value) + _assert_no_secrets(str(excinfo.value)) + assert backend.requests == [] + + +@pytest.mark.asyncio +async def test_platform_calls_need_a_key_client(backend): + async with AsyncPineAI(access_token="user-token", user_id="17", base_url=backend.base_url) as client: + with pytest.raises(ValueError) as fetched: + await client.platform.managed_users.get(EXTERNAL_ID) + with pytest.raises(ValueError) as created: + await client.platform.managed_users.create(EXTERNAL_ID, email="a@example.com", name="A") + for excinfo in (fetched, created): + assert "needs a client built with api_key" in str(excinfo.value) + with PineAI(access_token="user-token", base_url=backend.base_url) as client, pytest.raises(ValueError): + client.platform.managed_users.get(EXTERNAL_ID) + assert backend.requests == [] @pytest.mark.asyncio async def test_caller_headers_cannot_override_identity(backend): - forged = {"Authorization": "Bearer forged", MANAGED_USER_HEADER: "someone-else", "Pine-Client": "forged"} - async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + forged = {"Authorization": "Bearer forged", MANAGED_USER_HEADER: "1", "Pine-Client": "forged"} + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: await client.http.post("/v2/sessions", {}, headers=forged) async with AsyncPineAI(access_token="user-token", base_url=backend.base_url) as client: await client.http.post("/v2/sessions", {}, headers=forged) managed, user = (request["headers"] for request in backend.requests) assert managed["Authorization"] == f"Bearer {API_KEY}" - assert managed[MANAGED_USER_HEADER] == EXTERNAL_ID + assert managed[MANAGED_USER_HEADER] == PINE_USER_ID assert "Pine-Client" not in managed assert user["Authorization"] == "Bearer user-token" assert MANAGED_USER_HEADER not in user and "Pine-Client" not in user @@ -386,8 +525,9 @@ async def test_caller_headers_cannot_override_identity(backend): def test_public_platform_names(): assert API_KEY_PREFIXES == ("pine_sk_live_", "pine_sk_test_") - assert MANAGED_USER_HEADER == "Pine-Managed-User" + assert MANAGED_USER_HEADER == "Pine-Managed-User-Id" assert validate_external_id(EXTERNAL_ID) == EXTERNAL_ID + assert validate_user_id(PINE_USER_ID) == PINE_USER_ID AUTH_ATTEMPT_ID = "a" * 22 @@ -396,20 +536,19 @@ def test_public_platform_names(): @pytest.mark.asyncio async def test_documented_managed_user_restrictions_raise_the_resource_error_type(backend): - async with AsyncPineAI(api_key=API_KEY, managed_user=EXTERNAL_ID, base_url=backend.base_url) as client: + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: backend.error_status, backend.error_code = 403, "platform_route_not_allowed" with pytest.raises(AuthError) as minted: await client.auth.mint_ticket( purpose="mcp", client_id="client", auth_attempt_id=AUTH_ATTEMPT_ID, code_challenge=CODE_CHALLENGE, ) - backend.error_code = "platform_managed_user_access_disabled" + backend.error_status, backend.error_code = 404, "platform_managed_user_not_found" with pytest.raises(SessionError) as listed: await client.sessions.list() - with pytest.raises(AuthError) as connected: - await client.connect() + with pytest.raises(AuthError) as identity: + await client.auth.me() assert (minted.value.code, minted.value.status_code) == ("platform_route_not_allowed", 403) - for excinfo in (listed, connected): - assert (excinfo.value.code, excinfo.value.status_code) == ("platform_managed_user_access_disabled", 403) + for excinfo in (listed, identity): + assert (excinfo.value.code, excinfo.value.status_code) == ("platform_managed_user_not_found", 404) _assert_no_secrets(str(excinfo.value)) - assert backend.handshakes == [] From 8b5e1c8426adc0570ebf8ad28b5420caa2430128 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Mon, 19 Oct 2026 21:40:22 +0800 Subject: [PATCH 09/12] docs: describe the managed-user API for partners Rewrite the README Platform API section for user_id, and add docs for partners: the SDK methods (docs/sdk-api.md), the raw HTTP API (docs/http-api.md) and a Socket.IO reference (docs/socketio.md). Co-Authored-By: Claude Opus 5.5 --- README.md | 108 ++++++++++++++++++++------------- docs/http-api.md | 152 +++++++++++++++++++++++++++++++++++++++++++++++ docs/sdk-api.md | 120 +++++++++++++++++++++++++++++++++++++ docs/socketio.md | 113 +++++++++++++++++++++++++++++++++++ 4 files changed, 453 insertions(+), 40 deletions(-) create mode 100644 docs/http-api.md create mode 100644 docs/sdk-api.md create mode 100644 docs/socketio.md diff --git a/README.md b/README.md index ed81bee..799ff70 100644 --- a/README.md +++ b/README.md @@ -108,17 +108,27 @@ cannot verify the original form request. ## Integrating as an enterprise (Platform API) -A Pine Platform integration (a *tenant*) holds a secret key, `pine_sk_live_...` -in production or `pine_sk_test_...` elsewhere. Keep it on your servers: read it +A *partner* is a business that builds on Pine through the Platform API. It +holds a secret key, `pine_sk_live_...`. Keep it on your servers: read it from an environment variable or secret store, and never ship it in browser, -mobile or other client-side code, prompts, or logs. Each end user of your -product becomes a *managed user*, identified by your own `external_id` -(1-128 of letters, digits, `.`, `_`, `:`, `|`, `-`, not starting with `.`; no -personal data). +mobile or other client-side code, prompts, or logs. To get a key, contact +support@19pine.ai. Each end user of your product becomes a *managed user*, +identified by your own `external_id` (1-128 of letters, digits, `.`, `_`, `:`, +`|`, `-`, not starting with `.`; no personal data). -Create the managed user once. The call is idempotent per `external_id`: a -repeat returns the stored user unchanged, even if the profile you send differs. -`phone` is optional, in E.164 form, and must be a number you have verified. +All managed users of one partner use that partner's credits. A partner can +limit the total credits that its managed users use. + +The SDK methods for managed users are listed in +[docs/sdk-api.md](docs/sdk-api.md). If you do not use Python, see the HTTP API +in [docs/http-api.md](docs/http-api.md) and the Socket.IO reference in +[docs/socketio.md](docs/socketio.md). + +Create the managed user once, and store its `id` in your own user table. The +call is idempotent per `external_id`: a repeat returns the stored user +unchanged, even if the profile you send differs. `phone` is optional, in E.164 +form, and must be a number you have verified. `get()` finds a user by +`external_id` if you did not store the `id`. ```python import os @@ -127,40 +137,26 @@ from pine_assistant import AsyncPineAI api_key = os.environ["PINE_API_KEY"] -async with AsyncPineAI(api_key=api_key) as tenant: - user = await tenant.platform.managed_users.create( +async with AsyncPineAI(api_key=api_key) as partner_client: + user = await partner_client.platform.managed_users.create( "usr_01J9Z3K8QF", email="ada@example.com", name="Ada Lovelace", phone="+14155550100", ) - same_user = await tenant.platform.managed_users.get("usr_01J9Z3K8QF") + same_user = await partner_client.platform.managed_users.get("usr_01J9Z3K8QF") +# Save user.id with your own user record. ``` -Then act as that user with `managed_user`. Every user-scoped REST request sends -the key plus a `Pine-Managed-User` header, and the Socket.IO handshake sends -both; `connect()` looks up the user's Pine user ID once through `auth.me()`. -Two restrictions apply. Every error below subclasses `PineAIError` and carries -`code` and `status_code`: - -- `client.auth.mint_ticket()` always fails with 403 - `platform_route_not_allowed`, raised as `AuthError`. Managed users cannot mint - login tickets. -- While the backend's managed-user access switch is off, every user-scoped call - fails with 403 `platform_managed_user_access_disabled`. The exception type is - the resource's usual one: `SessionError` from `sessions.*`, `AuthError` from - `auth.*` and from `connect()`, which reads `auth.me()` first. - -Billing and admission can also reject sessions and chat for a managed user: -402 `platform_spend_limit_reached`, 403 `platform_access_suspended` or -`platform_managed_user_unavailable`, 409 `platform_session_state_conflict`, 409 -`platform_session_limit_reached` from `sessions.start` when the running-task -limit is reached, and 429 when the tenant quota is exhausted. +`user.id` is the Pine user ID, a decimal string such as `"123456789"`. To act +as that user, pass it as `user_id` together with the key. Every user-scoped +REST request then sends the key and a `Pine-Managed-User-Id` header. The +Socket.IO handshake sends the key and `managed_user_id`, and every Socket.IO +message carries the same ID. The SDK checks the form of `user_id` when you +create the client. It accepts only a string, never an `int`. ```python -async with AsyncPineAI( - api_key=api_key, managed_user="usr_01J9Z3K8QF", -) as client: +async with AsyncPineAI(api_key=api_key, user_id=user.id) as client: session = await client.sessions.create() await client.connect() await client.join_session(session["id"]) @@ -169,15 +165,47 @@ async with AsyncPineAI( print(event.type, event.data) ``` -`PineAI` accepts the same `api_key` and `managed_user` for synchronous REST. +A client with `api_key` and no `user_id` can call only the Platform API. A +user-scoped call from it, such as `sessions.list()` or `connect()`, raises +`ValueError` before any request is sent. These also raise `ValueError`: +`api_key` together with `access_token`, `platform.*` on a client without +`api_key`, and `auth.verify_code()` or `set_token()` on a client with `api_key`. +A key client keeps its key; use a separate client for a user token. + +Every error below is a `PineAIError` with `code` and `status_code`, so catch +`PineAIError`. Some methods raise a subclass: `SessionError` from +`sessions.list`, `get`, `end_task`, `send_message`, `history` and `outcomes`, and +`AuthError` from `auth.*`. Other `sessions` methods, such as `create` and +`start_task`, raise `PineAIError` itself. + +- 400 `invalid_request`: the backend did not accept the managed-user ID. +- 404 `platform_managed_user_not_found`: the ID is not one of your managed users. +- 403 `platform_managed_user_unavailable`: the managed user is banned or + archived. +- `client.auth.mint_ticket()` always fails with 403 + `platform_route_not_allowed`, raised as `AuthError`. Managed users cannot mint + login tickets. Other routes closed to a partner key fail in the same way. + +Billing and admission can also reject sessions and chat for a managed user: +402 `platform_spend_limit_reached`, 403 `platform_access_suspended`, 409 +`platform_session_state_conflict`, 409 `platform_session_limit_reached` from +`sessions.start_task` when the running-task limit is reached. + +If the server refuses the Socket.IO handshake, it closes the connection and +sends no error code. `connect()` then raises `ConnectionError` when +`ready_timeout` (15 seconds by default) ends. Its `code` is always +`connection_error`, so it does not tell you the cause. The message tells you to +check `api_key` and `user_id`. + +`PineAI` accepts the same `api_key` and `user_id` for synchronous REST. `client_name="..."` sends a `Pine-Client` header on REST requests and on the Socket.IO handshake. The backend records it as the source only for requests made -with a tenant key, and only the value `mcp`; it changes no behavior. Platform +with a partner key, and only the value `mcp`; it changes no behavior. Platform API failures raise `PlatformError` with a stable `code` and `status_code` (for -example 401 for a bad key, 403 for a suspended integration, 404 -`platform_managed_user_not_found`, 409 `platform_managed_user_limit_reached`); -like every SDK error, it never reproduces the upstream body, the key or the -`external_id`. +example 401 for a bad key, 403 for a suspended partner, 404 +`platform_managed_user_not_found`, 409 `platform_managed_user_limit_reached`). +Like every SDK error, it never reproduces the upstream body, the key, the +`external_id` or the user ID. ## Quick Start (Sync REST) diff --git a/docs/http-api.md b/docs/http-api.md new file mode 100644 index 0000000..2156e9b --- /dev/null +++ b/docs/http-api.md @@ -0,0 +1,152 @@ +# HTTP API for managed users + +This page describes the raw HTTP API that a partner (a business that builds on +Pine) uses to act for its managed users. + +## Get a key + +To get a Platform API secret key, contact support@19pine.ai. + +Keep the key on your servers. Do not put it in browser or mobile code, in +prompts, or in logs. + +## Managed users + +All managed users of one partner use that partner's credits. A partner can +limit the total credits that its managed users use. + +Each end user of your product is a *managed user*. You name the user with your +own `external_id` when you create it. Pine gives it a Pine user ID, `id`. Store +`id` in your own user table: you need it for every request that acts for the +user. + +### Response format + +All paths start with `https://www.19pine.ai/api`. + +- Success: `{"status": "success", "data": ...}` +- Error: `{"status": "error", "error": {"code": "...", "message": "..."}}` + +Use `error.code` in your code. The `message` text can change. + +### Create a managed user + +`POST /platform/v1/managed-users` with `Authorization: Bearer `. + +| Field | Required | Rule | +|---|---|---| +| `external_id` | yes | 1-128 letters, digits, `.`, `_`, `:`, `|`, `-`; must not start with `.`; no personal data | +| `email` | yes | email address | +| `name` | yes | 1-100 characters | +| `phone` | no | E.164, for example `+14155550100`; a number you have verified | + +> **Caution:** use only the end user's own email address and phone number, and +> verify them first. Never use an email address or phone number that does not +> belong to that user. Pine trusts these values and does tasks with them: for +> example, the agent can send email for the user. If Pine finds misuse, Pine +> can revoke the key and cancel the partner's access. + +The call is idempotent per `external_id`. The first call returns 201. A +repeated call returns 200 with the stored user unchanged, even if the fields +you send are different. + +The response `data` is the managed user: +`{"id", "external_id", "email", "name", "phone", "created_at"}`. `phone` is +`null` when you did not give one. + +Errors: 400 `invalid_request`, 401 `unauthorized`, 403 +`platform_access_suspended`, 409 `platform_managed_user_limit_reached`. + +### Get a managed user + +`GET /platform/v1/managed-users/{external_id}` with `Authorization: Bearer `. +Percent-encode `external_id` in the path (for example `|` is `%7C`). + +It returns the same managed user as create. A user that does not exist, or that +belongs to another partner, returns 404 `platform_managed_user_not_found`. + +### Act for a managed user + +Send both headers on a `/v2` request: + +``` +Authorization: Bearer +Pine-Managed-User-Id: +``` + +`` is the `id` from create or get, as a decimal string: 1 to +9223372036854775807, with no sign, spaces or leading zeros. Do not send +`Pine-Managed-User-Id` on `/platform/v1` requests. + +| Status | Code | Cause | +|---|---|---| +| 400 | `invalid_request` | One of the two headers is missing, the header is sent more than once, or the ID is not in the form above. | +| 401 | `unauthorized` | The key is wrong or revoked. | +| 403 | `platform_access_suspended` | The partner is suspended. | +| 403 | `platform_managed_user_unavailable` | The managed user is banned or archived. | +| 403 | `platform_route_not_allowed` | The route is closed to keys: login tickets, phone verification, login linking, push registration, purchases and subscriptions, and computer settings. | +| 404 | `platform_managed_user_not_found` | The ID is not a managed user of this partner. | + +### Session routes + +These routes take the two headers above. + +| Request | What it does | SDK | +|---|---|---| +| `GET /v2/auth/me` | Returns `{"user_id": ""}`. | `auth.me()` | +| `POST /v2/sessions` | Creates a session. | `sessions.create()` | +| `GET /v2/sessions` | Lists the user's sessions. | `sessions.list()` | +| `GET /v2/sessions/{id}` | Returns one session. | `sessions.get()` | +| `POST /v2/sessions/{id}/messages` | Sends a message. Body: `{"content": "..."}`. | `sessions.send_message()` | +| `GET /v2/sessions/{id}/messages` | Reads the stored messages, newest first. | `sessions.history()` | +| `POST /v2/sessions/{id}/start` | Starts a prepared or paused task. | `sessions.start_task()` | +| `POST /v2/sessions/{id}/stop` | Stops a running task. | `sessions.stop_task()` | +| `GET /v2/sessions/{id}/outcomes` | Lists the session's outcomes. | `sessions.outcomes()` | + +Routes that make the agent work can also return these errors: + +| Status | Code | Cause | +|---|---|---| +| 402 | `platform_spend_limit_reached` | The partner's credit limit is reached. | +| 409 | `platform_session_limit_reached` | Start: the partner's running-task limit is reached. | +| 409 | `platform_session_state_conflict` | Message: the session is finished or cancelled. | + +`GET /v2/sessions/{id}/messages` takes two optional query values: `limit` +(1-100, default 30) and `before_message_id`. Each message has `message_id`, +`type`, `version`, `data` (a JSON string) and `created_at`. To read older +messages, send the returned `next_message_id` as `before_message_id`. An empty +`next_message_id` means there are no older messages. + +Other `/v2` routes accept the key and header in the same way. This page does +not describe them. + +### How a task runs + +1. Create a session and send the user's request as a message. +2. The agent talks with the user to get the information that it needs. +3. The agent prepares the task. The task then starts on its own, as it does for + a normal Pine user. +4. The agent does the task. It can ask the user more questions. +5. The task ends. + +The session's `state` shows where the task is: + +| `state` | Meaning | +|---|---| +| `init` | The session is new. | +| `chat` | The agent talks with the user. | +| `task_ready` | The task is prepared. It usually starts on its own. If the running-task limit is reached, it stays here: call `start` later. | +| `task_processing` | The task runs. | +| `task_paused` | The task is paused, for example because the credit limit is reached. When the cause is removed, call `start` to continue. | +| `task_finished` | The task is done. Read the result with `outcomes`. | +| `task_cancelled` | The task was cancelled. | + +### Talk with the agent + +We recommend Socket.IO ([docs/socketio.md](socketio.md)) to talk with the +agent. You get its replies, its questions and each change of `state` as they +happen. + +Over HTTP only, send messages with `POST /v2/sessions/{id}/messages`. Read the +replies and the `state` with `GET /v2/sessions/{id}/messages` and +`GET /v2/sessions/{id}`. diff --git a/docs/sdk-api.md b/docs/sdk-api.md new file mode 100644 index 0000000..8cefeb6 --- /dev/null +++ b/docs/sdk-api.md @@ -0,0 +1,120 @@ +# Python SDK for managed users + +This page lists the SDK API that a partner uses to act for its managed users. +For the raw HTTP API, see [docs/http-api.md](http-api.md). + +## Get a key + +To get a Platform API secret key, contact support@19pine.ai. Keep the key on +your servers. + +## Managed users + +All managed users of one partner use that partner's credits. A partner can +limit the total credits that its managed users use. + +### Clients + +`AsyncPineAI` has REST and real-time Socket.IO. `PineAI` has REST only, with +the same arguments. + +| Arguments | Mode | +|---|---| +| `api_key=key` | Partner client. It can call only `platform.*`. Other calls raise `ValueError`. | +| `api_key=key, user_id=user.id` | Acts for that managed user. | +| `api_key` with `access_token` | Not allowed: `ValueError`. | + +`user_id` must be a string such as `"123456789"`, never an `int`. A client +with `api_key` keeps its key: `set_token()` and `auth.verify_code()` raise +`ValueError`. + +```python +async with AsyncPineAI(api_key=key) as partner_client: + user = await partner_client.platform.managed_users.create( + "usr_42", email="ada@example.com", name="Ada Lovelace", + ) +# Store user.id with your own user record. + +async with AsyncPineAI(api_key=key, user_id=user.id) as client: + ... +``` + +### Platform API (partner client) + +| Method | Returns | +|---|---| +| `platform.managed_users.create(external_id, *, email, name, phone=None)` | `ManagedUser`. Idempotent per `external_id`: a repeat returns the stored user unchanged. | +| `platform.managed_users.get(external_id)` | `ManagedUser` | + +`ManagedUser` has `id`, `external_id`, `email`, `name`, `phone` and +`created_at`. Pass `id` as `user_id`. + +> **Caution:** use only the end user's own email address and phone number, and +> verify them first. Never use an email address or phone number that does not +> belong to that user. Pine trusts these values and does tasks with them: for +> example, the agent can send email for the user. If Pine finds misuse, Pine +> can revoke the key and cancel the partner's access. + +### REST (managed-user client) + +| Method | Returns | +|---|---| +| `auth.me()` | `AuthIdentity` with `user_id` | +| `sessions.create()` | the new session (`dict`) | +| `sessions.list(...)` | `SessionListResponse` | +| `sessions.get(session_id)` | `SessionInfo` | +| `sessions.send_message(session_id, content, ...)` | `SessionMessageStatus` | +| `sessions.history(session_id, *, limit=30, before_message_id=None)` | `SessionHistoryPage`: one page of stored messages, newest first | +| `sessions.start_task(session_id)` | `dict` | +| `sessions.stop_task(session_id)` | `dict` | +| `sessions.outcomes(session_id, ...)` | `SessionOutcomesPage` | + +### How a task runs + +The agent talks with the user, prepares the task, and then starts it on its +own, as it does for a normal Pine user. `sessions.get()` returns the session's +`state`. The states are listed in [docs/http-api.md](http-api.md#how-a-task-runs). +Use `sessions.start_task()` only for a task that did not start on its own or +that is paused. + +Without Socket.IO, send with `sessions.send_message()` and read the replies +with `sessions.history()`. Each message has `message_id`, `type`, `version`, +`data` (a JSON string) and `created_at`. To read older messages, pass +`next_message_id` as `before_message_id`. When `next_message_id` is `None`, +there are no older messages. + +### Real time (`AsyncPineAI` only) + +We recommend these methods to talk with the agent. You get its replies, its +questions and each change of `state` as they happen. + +| Method | What it does | +|---|---| +| `connect()` | Opens Socket.IO and waits for the server to be ready. | +| `join_session(session_id)` | Joins a session. Do this before the methods below. | +| `rebuild(session_id)` | Loads all stored messages of the session. | +| `chat(session_id, content, ...)` | Sends a message and yields the events of the turn. | +| `send_message(session_id, content, ...)` | Sends a message and does not wait. | +| `listen(session_id, ...)` / `subscribe(session_id)` | Yields events without sending. | +| `submit_form_response(session_id, message_id, answers)` | Replies to an agent form. | +| `leave_session(session_id)` | Leaves a session. | +| `disconnect()` / `aclose()` | Closes Socket.IO / closes everything. | + +### Errors + +Every error from the server is a `PineAIError` with `code` and `status_code`. +Some methods raise a subclass: `PlatformError` from `platform.*`, `AuthError` +from `auth.*`, and `SessionError` from `sessions.list`, `get`, +`send_message`, `history` and `outcomes`. `connect()` raises `ConnectionError`. + +The codes for managed users are listed in [docs/http-api.md](http-api.md). +Error messages never contain the key, the `external_id` or the user ID. + +### Helpers + +| Name | Use | +|---|---| +| `validate_user_id(value)` | Checks the form of a managed user's `id`. | +| `validate_external_id(value)` | Checks the form of an `external_id`. | +| `API_KEY_PREFIXES` | The prefixes that a valid key starts with. | +| `MANAGED_USER_HEADER` | `"Pine-Managed-User-Id"` | diff --git a/docs/socketio.md b/docs/socketio.md new file mode 100644 index 0000000..b95cafb --- /dev/null +++ b/docs/socketio.md @@ -0,0 +1,113 @@ +# Socket.IO for managed users (reference only) + +> **Caution:** this page is a reference, not a supported contract. + +This page covers only part of the Socket.IO protocol. While you use Socket.IO, +the server can also send events that this page does not describe. For the full +protocol, contact us. + +## Connect + +- URL: `wss://www.19pine.ai`, path `/api/v2/socket.io/`, WebSocket transport. +- Handshake `auth`: `{"token": "", "managed_user_id": ""}`. + `managed_user_id` is the managed user's `id` and must be a JSON string. +- When the connection is ready, the server sends `ready`. Send nothing before + it. +- If the server refuses the handshake, it closes the connection. It sends no + `connect_error` and no error code, and it never sends `ready`. + +The server checks the key and the user again while the connection is open. It +closes the connection if the partner is suspended, the key is revoked, or +the user is banned or archived. + +## Envelope + +Every event that the client sends is one envelope: + +```json +{ + "metadata": { + "event_id": "", + "request_id": "", + "timestamp": "", + "source": {"role": "user", "user_id": "", "device_id": ""} + }, + "type": "", + "payload": {"session_id": "", "message_id": null, "type": "", "data": {}} +} +``` + +`metadata.source.user_id` must be the managed user's `id`. Otherwise the server +replies with `session:error` and does not process the event. + +## Events + +| Event | Direction | `payload.data` | Use | +|---|---|---|---| +| `session:join` | client → server | `{"since_revision": ""}` | Join a session. Do this before the other events. Use `"0"` if you have nothing stored. | +| `session:leave` | client → server | `null` | Leave a session. | +| `session:history` | both | request: `{"max_messages", "max_bytes", "order", "from_message_id"}` | Read stored messages. The reply has `messages` and `next_message_id`. | +| `session:messages_since` | both | request: `{"since_revision", "limit"}` | Read the changes after a revision. See "Handle a disconnect". | +| `session:message` | client → server | `{"content": "...", "attachments": []}` | Send a message. | +| `session:message_status` | server → client | `{"status", "message_id", "request_id"}` | Report the state of a message: `received`, `delivered` or a failure. | +| `session:form_to_user` | server → client | `{"form": ...}` | The agent asks the user to fill in a form. | +| `session:form_to_user` | client → server | `{"content": {...}}` | Reply to a form. Set `payload.message_id` and `metadata.request_id` to the values of the form. | + +The server also sends other events, such as agent text and session state. See +"The supported surface" in the [README](../README.md). + +## Handle a disconnect + +A connection can drop at any time. The server does not send again the events +that you missed while you were disconnected. After a drop: + +1. Connect again and wait for `ready`. +2. Send `session:join` again for each session. +3. Load what you missed, in one of the two ways below. + +### Way 1: read the history again + +This way always works. + +Read the stored messages with `session:history` or with +`GET /v2/sessions/{id}/messages`. Start from the newest message and read older +pages until `next_message_id` is empty. Then replace your copy of the session. + +A stored message can also change or be deleted later. So read all pages, not +only the pages up to the last message that you have. + +The Python SDK uses this way: `rebuild()` reads all pages, and `on_reconnect()` +tells you when to call it. + +### Way 2: catch up from a revision + +This way loads only the changes, but the server does not support it on every +connection. Use Way 1 when it is not available. + +Each stored message has `payload.revision`, a number as a string. A message gets +a new, higher revision each time it is created, changed or deleted. Keep the +highest revision that you have stored for each session. A live event without +`revision` is not stored yet; do not use it to move your revision. + +1. Join with `{"since_revision": ""}`. Use `"0"` if you have + nothing stored. +2. Read the join reply: + - No `max_message_revision`: this connection does not support Way 2. Use + Way 1. + - `resync: true`: you have nothing stored, your revision is not valid, or + too many messages changed. Use Way 1, then set your revision to + `max_message_revision`. + - `max_message_revision` equals your revision: you missed nothing. +3. Otherwise, send `session:messages_since` with + `{"since_revision": "", "limit": 50}`. The limit is 100 at + most. +4. The reply has `messages`, `next_revision` and `has_more`. `messages` are in + the order of their change, oldest first. For each message: + - `payload.deleted` is `true`: remove the message with that `message_id`. + - Otherwise: add the message, or replace the message with the same + `message_id`. +5. Set your revision to `next_revision`. If `has_more` is `true`, go back to + step 3. + +The reply has the `metadata.request_id` of your request, so you can match +replies when you send more than one. From f68ff239078dd0f00405c8d88db64bc6bae2de6d Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Mon, 19 Oct 2026 22:54:43 +0800 Subject: [PATCH 10/12] docs: explain reply lag in history and how to answer a form A reply can take a few seconds to appear in the REST history, so a reader that sees a new state should read again. Add a short "Answer a form" section: read the fields from the live event or from the history, then pass one answer per field name to submit_form_response(). Co-Authored-By: Claude Opus 5.5 --- docs/http-api.md | 2 ++ docs/sdk-api.md | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/http-api.md b/docs/http-api.md index 2156e9b..fab8b04 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -116,6 +116,8 @@ Routes that make the agent work can also return these errors: `type`, `version`, `data` (a JSON string) and `created_at`. To read older messages, send the returned `next_message_id` as `before_message_id`. An empty `next_message_id` means there are no older messages. +A new reply can take a few seconds to appear in this list. If the `state` +changed but the reply is missing, read again. Other `/v2` routes accept the key and header in the same way. This page does not describe them. diff --git a/docs/sdk-api.md b/docs/sdk-api.md index 8cefeb6..4dff1c0 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -82,6 +82,8 @@ with `sessions.history()`. Each message has `message_id`, `type`, `version`, `data` (a JSON string) and `created_at`. To read older messages, pass `next_message_id` as `before_message_id`. When `next_message_id` is `None`, there are no older messages. +A new reply can take a few seconds to appear in this list. If the `state` +changed but the reply is missing, read again. ### Real time (`AsyncPineAI` only) @@ -100,6 +102,29 @@ questions and each change of `state` as they happen. | `leave_session(session_id)` | Leaves a session. | | `disconnect()` / `aclose()` | Closes Socket.IO / closes everything. | +### Answer a form + +When the agent needs information, it sends a `session:form_to_user` form. Read +its fields, then pass one answer per field `name` to `submit_form_response()`. + +- **Live:** the form arrives as an event from `chat()`, `listen()` or + `subscribe()`. Use `event.message_id`, and read the fields from `event.data`. +- **Later**, for example after a reconnect: find the newest + `session:form_to_user` message from the agent in `rebuild()` or + `sessions.history()`. For `sessions.history()`, read `data` with + `json.loads()`. + +Answer only a form whose `form.is_submitted` is `false`. + +```python +from pine_assistant.models import FormToUserData + +if event.type == "session:form_to_user": + form = FormToUserData.model_validate(event.data).form + answers = {field.name: ... for field in form.fields} + await client.submit_form_response(session_id, event.message_id, answers) +``` + ### Errors Every error from the server is a `PineAIError` with `code` and `status_code`. From eab8c0d0e857a58fd56565236f807aeaef3c76c5 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Mon, 19 Oct 2026 23:20:39 +0800 Subject: [PATCH 11/12] refactor(sdk): remove client_name and apply small review fixes The backend only logged Pine-Client: mcp and changed no behavior, so remove client_name and CLIENT_HEADER. Pine's MCP service drops it too. - A Platform key passed as access_token raises ValueError that says to pass it as api_key. - SessionHistoryPage.normalize_cursor uses if/else. - README: rename the section to "Platform API for partners" and drop end_task from the managed-user SessionError list. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++--- README.md | 9 ++---- src/pine_assistant/__init__.py | 3 +- src/pine_assistant/client.py | 22 ++++----------- src/pine_assistant/models/session.py | 3 +- src/pine_assistant/transport/http.py | 14 +++------- src/pine_assistant/transport/socketio.py | 4 --- tests/test_platform.py | 35 +++++++----------------- 8 files changed, 28 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc6d179..7163de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,11 +30,10 @@ Internal release candidate for 0.5.0. These also raise `ValueError`: `api_key` with `access_token`, identity overrides passed to `connect()` in key mode, `platform.*` on a client without `api_key`, and `auth.verify_code()` or `set_token()` on a client with - `api_key` (checked before the code is sent). -- `client_name` adds a `Pine-Client` header to every REST request and to the - Socket.IO handshake. + `api_key` (checked before the code is sent). A Platform key passed as + `access_token` raises `ValueError` that says to pass it as `api_key`. - `API_KEY_PREFIXES` (`pine_sk_live_`, `pine_sk_test_`), `validate_external_id`, - `validate_user_id`, `MANAGED_USER_HEADER` and `CLIENT_HEADER` are public. The + `validate_user_id` and `MANAGED_USER_HEADER` are public. The managed-user header is sent only with the API key it was configured with, and identity headers are applied after caller headers, so neither can be overridden per request. Error messages never contain the key, the diff --git a/README.md b/README.md index 799ff70..4081e6a 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ never retry or replay form submissions. A transport ACK is not a delivery receipt. The legacy synchronous `send_form_response()` is deprecated because it cannot verify the original form request. -## Integrating as an enterprise (Platform API) +## Platform API for partners A *partner* is a business that builds on Pine through the Platform API. It holds a secret key, `pine_sk_live_...`. Keep it on your servers: read it @@ -174,7 +174,7 @@ A key client keeps its key; use a separate client for a user token. Every error below is a `PineAIError` with `code` and `status_code`, so catch `PineAIError`. Some methods raise a subclass: `SessionError` from -`sessions.list`, `get`, `end_task`, `send_message`, `history` and `outcomes`, and +`sessions.list`, `get`, `send_message`, `history` and `outcomes`, and `AuthError` from `auth.*`. Other `sessions` methods, such as `create` and `start_task`, raise `PineAIError` itself. @@ -197,10 +197,7 @@ sends no error code. `connect()` then raises `ConnectionError` when `connection_error`, so it does not tell you the cause. The message tells you to check `api_key` and `user_id`. -`PineAI` accepts the same `api_key` and `user_id` for synchronous REST. -`client_name="..."` sends a `Pine-Client` header on REST requests and on the -Socket.IO handshake. The backend records it as the source only for requests made -with a partner key, and only the value `mcp`; it changes no behavior. Platform +`PineAI` accepts the same `api_key` and `user_id` for synchronous REST. Platform API failures raise `PlatformError` with a stable `code` and `status_code` (for example 401 for a bad key, 403 for a suspended partner, 404 `platform_managed_user_not_found`, 409 `platform_managed_user_limit_reached`). diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index f6c1166..8ac5f66 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -40,7 +40,7 @@ validate_user_id, ) from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI -from pine_assistant.transport.http import API_KEY_PREFIXES, CLIENT_HEADER, MANAGED_USER_HEADER +from pine_assistant.transport.http import API_KEY_PREFIXES, MANAGED_USER_HEADER __version__ = "0.5.0rc2" __all__ = [ @@ -57,7 +57,6 @@ "ManagedUser", "API_KEY_PREFIXES", "MANAGED_USER_HEADER", - "CLIENT_HEADER", "validate_external_id", "validate_user_id", "AuthIdentity", diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 0d771ff..8277f85 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -5,7 +5,6 @@ import asyncio import logging import os -import re import uuid from collections.abc import AsyncGenerator, Callable from pathlib import Path @@ -40,8 +39,6 @@ FORM_RESPONSE_TIMEOUT_S = 10.0 FORM_RESPONSE_MAX_TIMEOUT_S = 30.0 -_CLIENT_NAME = re.compile(r"[A-Za-z0-9._-]{1,64}") - _logger = logging.getLogger(__name__) @@ -49,14 +46,13 @@ def _platform_identity( access_token: str | None, user_id: str | None, api_key: str | None, - client_name: str | None, ) -> str | None: """Validate the credential mode and return the bearer the client sends. Error messages never include the key or the user ID. """ - if client_name is not None and not (isinstance(client_name, str) and _CLIENT_NAME.fullmatch(client_name)): - raise ValueError("client_name must be 1-64 letters, digits, '.', '_' or '-'") + if api_key is None and isinstance(access_token, str) and access_token.startswith(API_KEY_PREFIXES): + raise ValueError("pass a Pine Platform secret key as api_key, not as access_token") if api_key is None: return access_token if access_token is not None: @@ -112,8 +108,6 @@ class AsyncPineAI: ``Pine-Managed-User-Id`` header, and the Socket.IO handshake carries both. Without ``user_id``, a user-scoped call raises ``ValueError`` before any request is sent. - ``client_name`` adds a ``Pine-Client`` header to every REST request and - to the Socket.IO handshake. """ def __init__( @@ -129,15 +123,13 @@ def __init__( http_client: httpx.AsyncClient | None = None, http_transport: httpx.AsyncBaseTransport | None = None, api_key: str | None = None, - client_name: str | None = None, ): - access_token = _platform_identity(access_token, user_id, api_key, client_name) + access_token = _platform_identity(access_token, user_id, api_key) self._base_url = base_url self._access_token = access_token self._user_id = user_id self._managed_user_id = user_id if api_key is not None else None self._key_mode = api_key is not None - self._client_name = client_name self._device_id = _get_or_create_device_id(device_id) self._transports = transports self._ready_timeout = ready_timeout @@ -149,7 +141,6 @@ def __init__( client=http_client, transport=http_transport, managed_user_id=self._managed_user_id, - client_name=client_name, ) self.auth = Auth(self.http) self.sessions = SessionsAPI(self.http) @@ -193,7 +184,6 @@ async def connect(self, access_token: str | None = None, user_id: str | None = N transports=self._transports, ready_timeout=self._ready_timeout, managed_user_id=self._managed_user_id, - client_name=self._client_name, ) self._chat = ChatEngine(self._sio, check_session_state=self._session_state) await self._sio.connect() @@ -499,7 +489,7 @@ class PineAI: REST resources return concrete values through ``httpx.Client``. Real-time Socket.IO requires an event loop and is intentionally available only on :class:`AsyncPineAI`; this client does not create a hidden loop or thread. - ``api_key``, ``user_id`` and ``client_name`` behave as on + ``api_key`` and ``user_id`` behave as on :class:`AsyncPineAI`; ``user_id`` has no effect without ``api_key``. """ @@ -513,16 +503,14 @@ def __init__( http_transport: httpx.BaseTransport | None = None, api_key: str | None = None, user_id: str | None = None, - client_name: str | None = None, ) -> None: self.http = SyncHttpClient( base_url=base_url, - token=_platform_identity(access_token, user_id, api_key, client_name), + token=_platform_identity(access_token, user_id, api_key), api_base_path=api_base_path, client=http_client, transport=http_transport, managed_user_id=user_id if api_key is not None else None, - client_name=client_name, ) self.auth = SyncAuth(self.http) self.sessions = SyncSessionsAPI(self.http) diff --git a/src/pine_assistant/models/session.py b/src/pine_assistant/models/session.py index 5b3741b..7749d4a 100644 --- a/src/pine_assistant/models/session.py +++ b/src/pine_assistant/models/session.py @@ -139,4 +139,5 @@ class SessionHistoryPage(BaseModel): def normalize_cursor(cls, value: Any) -> str | None: if value is None or value == "": return None - return SessionInfo.normalize_id(value) + else: + return SessionInfo.normalize_id(value) diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index 3f764da..6ef3012 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -13,8 +13,7 @@ _USER_AGENT = "pine-assistant-sdk" API_KEY_PREFIXES = ("pine_sk_live_", "pine_sk_test_") MANAGED_USER_HEADER = "Pine-Managed-User-Id" -CLIENT_HEADER = "Pine-Client" -_IDENTITY_HEADERS = ("Authorization", MANAGED_USER_HEADER, CLIENT_HEADER) +_IDENTITY_HEADERS = ("Authorization", MANAGED_USER_HEADER) KEY_ONLY_MESSAGE = ( "a client built with api_key alone can call only the Platform API; " "pass user_id to act as a managed user" @@ -100,13 +99,12 @@ def _json_data(response: httpx.Response) -> Any: class _RequestIdentity: """The credential, managed user ID and client name shared by the sync and async transports.""" - def __init__(self, token: str | None, managed_user_id: str | None, client_name: str | None) -> None: + def __init__(self, token: str | None, managed_user_id: str | None) -> None: self._token = token self._managed_user_id = managed_user_id # A managed user is bound to the API key it was configured with and is # never asserted with any other token. self._api_key = token if token is not None and token.startswith(API_KEY_PREFIXES) else None - self._client_name = client_name def ensure_token_can_change(self) -> None: if self._api_key is not None: @@ -128,8 +126,6 @@ def _identity(self, authenticated: bool, token: str | None, as_partner: bool) -> if self._managed_user_id is None: raise ValueError(KEY_ONLY_MESSAGE) identity[MANAGED_USER_HEADER] = self._managed_user_id - if self._client_name: - identity[CLIENT_HEADER] = self._client_name return identity @staticmethod @@ -159,11 +155,10 @@ def __init__( transport: httpx.AsyncBaseTransport | None = None, timeout: TimeoutConfig = 30.0, managed_user_id: str | None = None, - client_name: str | None = None, ): if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") - super().__init__(token, managed_user_id, client_name) + super().__init__(token, managed_user_id) self._api_base_url = _api_url(base_url, api_base_path) self._owns_client = client is None self._client = client or httpx.AsyncClient( @@ -257,11 +252,10 @@ def __init__( transport: httpx.BaseTransport | None = None, timeout: TimeoutConfig = 30.0, managed_user_id: str | None = None, - client_name: str | None = None, ) -> None: if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") - super().__init__(token, managed_user_id, client_name) + super().__init__(token, managed_user_id) self._api_base_url = _api_url(base_url, api_base_path) self._owns_client = client is None self._client = client or httpx.Client( diff --git a/src/pine_assistant/transport/socketio.py b/src/pine_assistant/transport/socketio.py index 28b8fec..77bbb15 100644 --- a/src/pine_assistant/transport/socketio.py +++ b/src/pine_assistant/transport/socketio.py @@ -16,7 +16,6 @@ from pine_assistant.errors import ConnectionError as PineConnectionError from pine_assistant.models.form import FormSubmissionResult -from pine_assistant.transport.http import CLIENT_HEADER SOCKETIO_PATH = "/api/v2/socket.io/" @@ -44,12 +43,10 @@ def __init__( transports: list[str] | None = None, ready_timeout: float = 15.0, managed_user_id: str | None = None, - client_name: str | None = None, ): self._base_url = base_url self._token = token self._managed_user_id = managed_user_id - self._client_name = client_name self._user_id = user_id self._device_id = device_id or str(uuid.uuid4()) self._transports = transports or ["websocket"] @@ -151,7 +148,6 @@ async def disconnect(_reason: str = "") -> None: await self._sio.connect( self._base_url, auth=self._handshake_auth(), - headers={CLIENT_HEADER: self._client_name} if self._client_name else {}, transports=self._transports, socketio_path=SOCKETIO_PATH, wait_timeout=self._ready_timeout, diff --git a/tests/test_platform.py b/tests/test_platform.py index 9452091..e9d529f 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -54,7 +54,6 @@ class FakeBackend: base_url: str = "" requests: list[dict[str, Any]] = field(default_factory=list) handshakes: list[Any] = field(default_factory=list) - handshake_clients: list[str | None] = field(default_factory=list) envelopes: list[dict[str, Any]] = field(default_factory=list) error_status: int | None = None error_code: str = "platform_failure" @@ -69,7 +68,6 @@ def app(self) -> web.Application: @sio.event async def connect(sid, environ, auth): self.handshakes.append(auth) - self.handshake_clients.append(environ.get("HTTP_PINE_CLIENT")) if self.refuse_handshake: # Like the Pine backend: accept the connection, then close it # with no connect_error and no code. @@ -166,10 +164,8 @@ def _assert_no_secrets(text: str) -> None: @pytest.mark.asyncio -async def test_managed_user_rest_requests_carry_key_header_and_client_name(backend): - async with AsyncPineAI( - api_key=API_KEY, user_id=PINE_USER_ID, client_name="mcp", base_url=backend.base_url, - ) as client: +async def test_managed_user_rest_requests_carry_the_key_and_the_header(backend): + async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: listed = await client.sessions.list() assert listed.sessions[0].id == "7" @@ -177,7 +173,6 @@ async def test_managed_user_rest_requests_carry_key_header_and_client_name(backe assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" assert request["headers"]["Pine-Managed-User-Id"] == PINE_USER_ID assert "Pine-Managed-User" not in request["headers"] - assert request["headers"]["Pine-Client"] == "mcp" @pytest.mark.asyncio @@ -203,19 +198,9 @@ async def test_managed_user_connect_sends_the_id_in_handshake_and_envelope_witho assert backend.requests == [] assert backend.handshakes == [{"token": API_KEY, "managed_user_id": PINE_USER_ID}] * 2 assert all(isinstance(auth["managed_user_id"], str) for auth in backend.handshakes) - assert backend.handshake_clients == [None, None] assert backend.envelopes[0]["metadata"]["source"]["user_id"] == PINE_USER_ID -@pytest.mark.asyncio -async def test_client_name_is_sent_on_the_socketio_handshake(backend): - async with AsyncPineAI( - api_key=API_KEY, user_id=PINE_USER_ID, client_name="mcp", base_url=backend.base_url, - ) as client: - await client.connect() - assert backend.handshake_clients == ["mcp"] - - @pytest.mark.asyncio async def test_user_token_handshake_is_unchanged(backend): async with AsyncPineAI(access_token="user-token", user_id="17", base_url=backend.base_url) as client: @@ -357,7 +342,8 @@ def test_validate_user_id_accepts_canonical_ids(value): {"api_key": API_KEY, "user_id": "9223372036854775808"}, {"api_key": API_KEY, "user_id": 730194412688371}, {"api_key": API_KEY, "user_id": True}, - {"api_key": API_KEY, "client_name": "bad name"}, + {"access_token": API_KEY}, + {"access_token": API_KEY, "user_id": PINE_USER_ID}, ], ) def test_invalid_platform_configuration_is_rejected_without_echoing_values(kwargs): @@ -370,10 +356,11 @@ def test_invalid_platform_configuration_is_rejected_without_echoing_values(kwarg assert str(value) not in str(excinfo.value) -def test_managed_user_constructor_is_gone(): +@pytest.mark.parametrize("removed", [{"managed_user": EXTERNAL_ID}, {"client_name": "mcp"}]) +def test_removed_constructor_arguments_are_gone(removed): for client_type in (AsyncPineAI, PineAI): with pytest.raises(TypeError): - client_type(api_key=API_KEY, managed_user=EXTERNAL_ID) + client_type(api_key=API_KEY, user_id=PINE_USER_ID, **removed) @pytest.mark.asyncio @@ -424,7 +411,7 @@ def handler(request): return httpx.Response(200, json=success({"sessions": [], "total": 0, "limit": 30, "offset": 0})) with PineAI( - api_key=API_KEY, user_id=PINE_USER_ID, client_name="mcp", base_url="https://pine.test", + api_key=API_KEY, user_id=PINE_USER_ID, base_url="https://pine.test", http_transport=httpx.MockTransport(handler), ) as client: created = client.platform.managed_users.create(EXTERNAL_ID, email="ada@example.com", name="Ada Lovelace") @@ -435,7 +422,6 @@ def handler(request): assert MANAGED_USER_HEADER not in seen[0].headers assert seen[1].headers[MANAGED_USER_HEADER] == PINE_USER_ID assert all(request.headers["Authorization"] == f"Bearer {API_KEY}" for request in seen) - assert all(request.headers["Pine-Client"] == "mcp" for request in seen) @pytest.mark.asyncio @@ -509,7 +495,7 @@ async def test_platform_calls_need_a_key_client(backend): @pytest.mark.asyncio async def test_caller_headers_cannot_override_identity(backend): - forged = {"Authorization": "Bearer forged", MANAGED_USER_HEADER: "1", "Pine-Client": "forged"} + forged = {"Authorization": "Bearer forged", MANAGED_USER_HEADER: "1"} async with AsyncPineAI(api_key=API_KEY, user_id=PINE_USER_ID, base_url=backend.base_url) as client: await client.http.post("/v2/sessions", {}, headers=forged) async with AsyncPineAI(access_token="user-token", base_url=backend.base_url) as client: @@ -518,9 +504,8 @@ async def test_caller_headers_cannot_override_identity(backend): managed, user = (request["headers"] for request in backend.requests) assert managed["Authorization"] == f"Bearer {API_KEY}" assert managed[MANAGED_USER_HEADER] == PINE_USER_ID - assert "Pine-Client" not in managed assert user["Authorization"] == "Bearer user-token" - assert MANAGED_USER_HEADER not in user and "Pine-Client" not in user + assert MANAGED_USER_HEADER not in user def test_public_platform_names(): From b357cd3f40fa7999b35af7512bdaa34ce1201f2b Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Mon, 19 Oct 2026 23:38:25 +0800 Subject: [PATCH 12/12] fix(sdk): take key mode from api_key, not from the token prefix The transport treated any token that starts with pine_sk_ as a Platform key, so a transport built with token= sent Platform requests and refused set_token(), although no api_key was given. The clients now pass the checked api_key to HttpClient and SyncHttpClient, and the transport uses only that value. A transport built with token= is always a token client; passing both token and api_key raises ValueError. Co-Authored-By: Claude Opus 5.5 --- src/pine_assistant/client.py | 8 +++++--- src/pine_assistant/transport/http.py | 18 +++++++++++------- tests/test_platform.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 8277f85..43ff5b2 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -124,9 +124,8 @@ def __init__( http_transport: httpx.AsyncBaseTransport | None = None, api_key: str | None = None, ): - access_token = _platform_identity(access_token, user_id, api_key) self._base_url = base_url - self._access_token = access_token + self._access_token = _platform_identity(access_token, user_id, api_key) self._user_id = user_id self._managed_user_id = user_id if api_key is not None else None self._key_mode = api_key is not None @@ -140,6 +139,7 @@ def __init__( api_base_path=api_base_path, client=http_client, transport=http_transport, + api_key=api_key, managed_user_id=self._managed_user_id, ) self.auth = Auth(self.http) @@ -504,12 +504,14 @@ def __init__( api_key: str | None = None, user_id: str | None = None, ) -> None: + _platform_identity(access_token, user_id, api_key) self.http = SyncHttpClient( base_url=base_url, - token=_platform_identity(access_token, user_id, api_key), + token=access_token, api_base_path=api_base_path, client=http_client, transport=http_transport, + api_key=api_key, managed_user_id=user_id if api_key is not None else None, ) self.auth = SyncAuth(self.http) diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index 6ef3012..0989079 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -97,14 +97,16 @@ def _json_data(response: httpx.Response) -> Any: class _RequestIdentity: - """The credential, managed user ID and client name shared by the sync and async transports.""" + """The credential and managed user ID shared by the sync and async transports.""" - def __init__(self, token: str | None, managed_user_id: str | None) -> None: - self._token = token - self._managed_user_id = managed_user_id + def __init__(self, token: str | None, api_key: str | None, managed_user_id: str | None) -> None: + if token is not None and api_key is not None: + raise ValueError("pass either token or api_key, not both") + self._token = token if api_key is None else api_key # A managed user is bound to the API key it was configured with and is # never asserted with any other token. - self._api_key = token if token is not None and token.startswith(API_KEY_PREFIXES) else None + self._api_key = api_key + self._managed_user_id = managed_user_id def ensure_token_can_change(self) -> None: if self._api_key is not None: @@ -154,11 +156,12 @@ def __init__( client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, timeout: TimeoutConfig = 30.0, + api_key: str | None = None, managed_user_id: str | None = None, ): if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") - super().__init__(token, managed_user_id) + super().__init__(token, api_key, managed_user_id) self._api_base_url = _api_url(base_url, api_base_path) self._owns_client = client is None self._client = client or httpx.AsyncClient( @@ -251,11 +254,12 @@ def __init__( client: httpx.Client | None = None, transport: httpx.BaseTransport | None = None, timeout: TimeoutConfig = 30.0, + api_key: str | None = None, managed_user_id: str | None = None, ) -> None: if client is not None and transport is not None: raise ValueError("pass either client or transport, not both") - super().__init__(token, managed_user_id) + super().__init__(token, api_key, managed_user_id) self._api_base_url = _api_url(base_url, api_base_path) self._owns_client = client is None self._client = client or httpx.Client( diff --git a/tests/test_platform.py b/tests/test_platform.py index e9d529f..d6ce060 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -23,6 +23,7 @@ validate_external_id, validate_user_id, ) +from pine_assistant.transport.http import SyncHttpClient API_KEY = "pine_sk_test_c2VjcmV0LWtleS1mb3ItdGVzdHM" EXTERNAL_ID = "acme:user|42-x" @@ -401,6 +402,33 @@ async def handler(request): assert MANAGED_USER_HEADER not in seen[2].headers +def test_transport_takes_key_mode_from_api_key_not_from_the_token_text(): + seen = [] + + def handler(request): + seen.append(request) + return httpx.Response(200, json=success({"sessions": [], "total": 0, "limit": 30, "offset": 0})) + + transport = httpx.MockTransport(handler) + token_client = SyncHttpClient(base_url="https://pine.test", token=API_KEY, transport=transport) + with pytest.raises(ValueError, match="needs a client built with api_key"): + token_client.get("/platform/v1/managed-users/x", as_partner=True) + token_client.get("/v2/sessions") + token_client.set_token("user-jwt") + + key_client = SyncHttpClient(base_url="https://pine.test", api_key=API_KEY, transport=transport) + with pytest.raises(ValueError, match="pass user_id"): + key_client.get("/v2/sessions") + with pytest.raises(ValueError, match="cannot change its token"): + key_client.set_token("user-jwt") + with pytest.raises(ValueError, match="either token or api_key"): + SyncHttpClient(base_url="https://pine.test", token="user-jwt", api_key=API_KEY, transport=transport) + + [request] = seen + assert request.headers["Authorization"] == f"Bearer {API_KEY}" + assert MANAGED_USER_HEADER not in request.headers + + def test_sync_client_supports_platform_and_managed_user_requests(): seen = []