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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ 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 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

Internal release candidate for 0.5.0. See [MIGRATION.md](MIGRATION.md) before
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,79 @@ 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()`.
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.

```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"])
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`.

## Quick Start (Sync REST)

`PineAI` is a synchronous REST client. It returns values directly for auth and
Expand Down
22 changes: 21 additions & 1 deletion src/pine_assistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
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,
C2SEvent,
S2CEvent,
is_supported_event,
)
from pine_assistant.models.platform import ManagedUser
from pine_assistant.models.session import (
SessionInfo,
SessionListResponse,
Expand All @@ -28,7 +29,16 @@
SessionOutcomeRating,
SessionOutcomesPage,
)
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__ = [
Expand All @@ -38,6 +48,15 @@
"SyncAuth",
"SessionsAPI",
"SyncSessionsAPI",
"PlatformAPI",
"SyncPlatformAPI",
"ManagedUsersAPI",
"SyncManagedUsersAPI",
"ManagedUser",
"API_KEY_PREFIXES",
"MANAGED_USER_HEADER",
"CLIENT_HEADER",
"validate_external_id",
"AuthIdentity",
"AuthTicket",
"RedeemedTicket",
Expand All @@ -51,6 +70,7 @@
"PineAIError",
"AuthError",
"SessionError",
"PlatformError",
"ConnectionError",
"C2SEvent",
"S2CEvent",
Expand Down
70 changes: 69 additions & 1 deletion src/pine_assistant/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import logging
import os
import re
import uuid
from collections.abc import AsyncGenerator, Callable
from pathlib import Path
Expand All @@ -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_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
Expand All @@ -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_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


def _get_or_create_device_id(provided: str | None = None) -> str:
"""Resolve a stable device_id.

Expand Down Expand Up @@ -69,6 +97,16 @@ 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 and
to the Socket.IO handshake.
"""

def __init__(
Expand All @@ -83,10 +121,16 @@ 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._client_name = client_name
self._device_id = _get_or_create_device_id(device_id)
self._transports = transports
self._ready_timeout = ready_timeout
Expand All @@ -97,9 +141,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
Expand All @@ -115,6 +162,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:
Expand All @@ -128,6 +186,8 @@ 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,
client_name=self._client_name,
)
self._chat = ChatEngine(self._sio, check_session_state=self._session_state)
await self._sio.connect()
Expand Down Expand Up @@ -433,6 +493,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__(
Expand All @@ -443,16 +505,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
Expand Down
7 changes: 7 additions & 0 deletions src/pine_assistant/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
27 changes: 27 additions & 0 deletions src/pine_assistant/models/platform.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading