diff --git a/CHANGELOG.md b/CHANGELOG.md index 7163de3..94b2e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ 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/). +## [0.5.0rc3] - 2026-10-21 + +Release candidate for 0.5.0. + +### Upgrade from 0.5.0rc2 + +`client_now_date` must now have a UTC offset, such as +`2026-10-21T09:00:00-04:00`. A value without one raises `ValueError`. Pine +ignored such values before. + +### New + +- **Update a managed user.** `platform.managed_users.update()` changes the + email, name, phone or assistant. `phone=""` removes the phone. +- **Choose the assistant.** The assistant places and answers phone calls for + the user. Before, every managed user had Pine's built-in assistant. Now you + can choose its identity (voice and name) from + `settings.assistant_identities()` and rename it, with `assistant=` on + `create()` or `update()`. +- **Choose the caller ID.** `sessions.update_config(caller_id="platform")` + makes calls show a platform number instead of the user's phone. +- **Time zone on Socket.IO.** `chat()` and `send_message()` take + `client_now_date`, as REST `send_message()` does. + +### Fixed + +- Socket.IO messages sent a time without a UTC offset, so the agent always + used UTC. + +### Smaller changes + +- `ManagedUser.assistant` is `{identity_id, name}`, or `None` for Pine's + default assistant. +- `assistant={"identity_id": ...}` alone also replaces a custom name. +- `sessions.get_config()` reads the caller ID. +- An invalid `assistant`, `caller_id` or `client_now_date` raises `ValueError` + before any request. +- `PineAI` has the same new REST methods. + ## [0.5.0rc2] - 2026-10-19 Internal release candidate for 0.5.0. diff --git a/README.md b/README.md index 4081e6a..b7157ac 100644 --- a/README.md +++ b/README.md @@ -120,15 +120,18 @@ 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). +[docs/sdk-api.md](https://github.com/19PINE-AI/pine-assistant-python/blob/main/docs/sdk-api.md). +If you do not use Python, see the HTTP API in +[docs/http-api.md](https://github.com/19PINE-AI/pine-assistant-python/blob/main/docs/http-api.md) +and the Socket.IO reference in +[docs/socketio.md](https://github.com/19PINE-AI/pine-assistant-python/blob/main/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`. +`external_id` if you did not store the `id`. `update()` changes only the +fields you give; `phone=""` removes the phone. ```python import os @@ -145,6 +148,7 @@ async with AsyncPineAI(api_key=api_key) as partner_client: phone="+14155550100", ) same_user = await partner_client.platform.managed_users.get("usr_01J9Z3K8QF") + moved_user = await partner_client.platform.managed_users.update("usr_01J9Z3K8QF", phone="+14155550123") # Save user.id with your own user record. ``` @@ -174,7 +178,8 @@ 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`, `send_message`, `history` and `outcomes`, and +`sessions.list`, `get`, `get_config`, `update_config`, `send_message`, +`history` and `outcomes`, and `AuthError` from `auth.*`. Other `sessions` methods, such as `create` and `start_task`, raise `PineAIError` itself. @@ -418,8 +423,11 @@ Two conditions have no remedy once a session is running: - **Metered billing.** The account must be billed against a credit balance. On the alternative path a session halts at a payment step the SDK cannot answer. -- **Phone verification.** Must be completed at provisioning time. It has no - in-session remedy and no in-session signal. +- **Phone verification.** A Pine user account must verify its phone at + provisioning time. It has no in-session remedy and no in-session signal. A + managed user needs no verification: its phone is the one the partner gives. + Without a phone, the agent still places calls, from a number of the + platform's pool. ## Attachments diff --git a/docs/http-api.md b/docs/http-api.md index fab8b04..a747a52 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -39,6 +39,9 @@ Use `error.code` in your code. The `message` text can change. | `email` | yes | email address | | `name` | yes | 1-100 characters | | `phone` | no | E.164, for example `+14155550100`; a number you have verified | +| `assistant` | no | `{"identity_id": "...", "name": "..."}`; see "Assistant identity" | + +No value may be `null`: omit a field instead. Unknown fields return 400. > **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 @@ -51,8 +54,9 @@ 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. +`{"id", "external_id", "email", "name", "phone", "assistant", "created_at"}`. +`phone` is `null` when the user has no phone. `assistant` is +`{"identity_id", "name"}`, or `null` when the user uses Pine's default assistant. Errors: 400 `invalid_request`, 401 `unauthorized`, 403 `platform_access_suspended`, 409 `platform_managed_user_limit_reached`. @@ -65,6 +69,57 @@ 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`. +### Update a managed user + +`PATCH /platform/v1/managed-users/{external_id}` with `Authorization: Bearer `. + +Send only the fields that you change. A field that you omit keeps its stored +value. `{}` changes nothing. + +| Field | Rule | +|---|---| +| `email` | email address | +| `name` | 1-100 characters | +| `phone` | E.164, or `""` to remove the phone | +| `assistant` | `{"identity_id": "...", "name": "..."}`; either member can be omitted; see "Assistant identity" | + +The rules for `null` and unknown fields are the same as for create. +`external_id` cannot change. The agent uses the new profile from the user's +next message. A call in progress does not change. The response is the same as +for get. + +Errors: 400 `invalid_request` (nothing is changed), 401 `unauthorized`, 403 +`platform_access_suspended`, 404 `platform_managed_user_not_found`. + +### Assistant identity + +The assistant is the voice of Pine on the phone. It places calls for the +user, for example to a business, and it answers calls for the user. Each +user has one assistant. By default, it is Pine's built-in assistant. + +You can choose another identity from the catalog. The identity sets the +assistant's voice and the name that it gives on calls. You can also give it +your own name. + +The catalog: + +`GET /v2/app/settings/assistance_identities?identities=catalog` + +You can send it without any header. The response `data` is +`{"version", "identities": [{"id", "first_name", "last_name", "locale", "description", "greeting_audio_url", "status"}]}`. +Use only an identity whose `status` is `"active"`. To hear an identity, play +its `greeting_audio_url`. + +| `assistant` | Result | +|---|---| +| `{"identity_id": "agt_..."}` | The assistant gets the identity and the identity's own name. On update, this also replaces a custom name: to keep it, send `name` too. | +| `{"identity_id": "agt_...", "name": "Zoe Park"}` | The assistant gets the identity and this name. | +| `{"name": "Zoe Park"}` | Update only: the current identity gets a new name. A user with no identity gets 400. | + +The `name` has at most 100 characters. It can contain only letters (CJK +included) and spaces between words. One word is enough. A user created +without `assistant` uses Pine's default assistant. + ### Act for a managed user Send both headers on a `/v2` request: @@ -97,7 +152,9 @@ These routes take the two headers above. | `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}/config` | Returns the session config, for example `{"caller_id": "platform"}`, or `{}`. | `sessions.get_config()` | +| `PUT /v2/sessions/{id}/config` | Sets the caller ID. Body: `{"caller_id": "user"}` or `{"caller_id": "platform"}`. See "Caller ID". | `sessions.update_config()` | +| `POST /v2/sessions/{id}/messages` | Sends a message. Body: `{"content": "...", "client_now_date": "..."}`; `client_now_date` is optional, see "The user's time". | `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()` | @@ -116,12 +173,46 @@ 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. +A reply appears in this list only after the agent finishes it. This can take +a minute or more. Socket.IO shows the reply at once. If the `state` changed +but the reply is missing, read again later. Other `/v2` routes accept the key and header in the same way. This page does not describe them. +### Caller ID + +When the agent calls a third party, such as a business, the call shows a +caller ID. `caller_id` is a session setting that selects it: + +| `caller_id` | The call shows | +|---|---| +| `"user"`, or not set | The user's assistant number if it has one, else the user's phone, else a number of the platform's pool. | +| `"platform"` | A number of the platform's pool. | + +- Set or change it at any time. The agent uses the value for its next call. A + call already placed, including its retries, keeps its caller ID. A change + while the session is paused applies when the session resumes. +- `caller_id` does not change how the agent reaches the user. The agent still + uses the user's phone for that. +- Any other key or value returns 400 `invalid_request`. +- 503 `session_config_push_failed` means that the value is saved, but the + running agent did not get it yet. Send the same request again. + +### The user's time + +The agent must know the user's time zone to understand words such as +"tomorrow at 9 am". Send the user's current local time, with its UTC offset, +as `client_now_date` with each message. The Pine app does the same. + +- The value is an RFC 3339 time with an offset, for example + `2026-10-21T09:00:00-04:00`. Pine uses only the offset. +- A value without an offset is ignored. +- If a session never got `client_now_date`, the agent uses UTC. After one + message with it, the agent keeps that offset until a message brings a new + one. +- On Socket.IO, put the same field in the `session:message` data. + ### How a task runs 1. Create a session and send the user's request as a message. diff --git a/docs/sdk-api.md b/docs/sdk-api.md index 4dff1c0..f470eb8 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -43,11 +43,66 @@ async with AsyncPineAI(api_key=key, user_id=user.id) as 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.create(external_id, *, email, name, phone=None, assistant=None)` | `ManagedUser`. Idempotent per `external_id`: a repeat returns the stored user unchanged. | | `platform.managed_users.get(external_id)` | `ManagedUser` | +| `platform.managed_users.update(external_id, *, email=None, name=None, phone=None, assistant=None)` | `ManagedUser` after the change. | +| `settings.assistant_identities()` | `AssistantIdentityCatalog`: the identities to choose an assistant from. | -`ManagedUser` has `id`, `external_id`, `email`, `name`, `phone` and -`created_at`. Pass `id` as `user_id`. +`ManagedUser` has `id`, `external_id`, `email`, `name`, `phone`, `assistant` +and `created_at`. Pass `id` as `user_id`. `assistant` is a +`ManagedUserAssistant` with `identity_id` and `name`, or `None` when the user +uses Pine's default assistant. + +### Change a managed user + +`update()` changes only the arguments that you give. An argument that is +`None` is not sent, and its stored value does not change. + +- `phone=""` removes the phone. Any other phone must be in E.164 form. +- `external_id` cannot change. +- With no arguments, `update()` changes nothing and returns the stored user. +- The agent uses the new profile from the user's next message. A call in + progress does not change. + +```python +user = await partner_client.platform.managed_users.update("usr_42", phone="+14155550123") +user = await partner_client.platform.managed_users.update("usr_42", phone="") +``` + +### Assistant identity + +The assistant is the voice of Pine on the phone. It places calls for the +user, for example to a business, and it answers calls for the user. Each +user has one assistant. By default, it is Pine's built-in assistant. + +You can choose another identity from the catalog. The identity sets the +assistant's voice and the name that it gives on calls. You can also give it +your own name. + +1. Read the catalog with `settings.assistant_identities()`. A partner + client reads it without a user. Use only an identity whose `status` is + `"active"`. To hear an identity, play its `greeting_audio_url`. +2. Pass `assistant` to `create()` or `update()` as a `dict` with + `identity_id`, `name` or both: + +| `assistant` | Result | +|---|---| +| `{"identity_id": "agt_..."}` | The assistant gets the identity and the identity's own name. On update, this also replaces a custom name: to keep it, send `name` too. | +| `{"identity_id": "agt_...", "name": "Zoe Park"}` | The assistant gets the identity and this name. | +| `{"name": "Zoe Park"}` | `update()` only: the current identity gets a new name. A user with no identity gets 400. | + +The `name` has at most 100 characters. It can contain only letters (CJK +included) and spaces between words. One word is enough. A `dict` with other +keys or with values that are not strings raises `ValueError` before any +request is sent. + +```python +catalog = await partner_client.settings.assistant_identities() +identity = next(item for item in catalog.identities if item.status == "active") +user = await partner_client.platform.managed_users.update( + "usr_42", assistant={"identity_id": identity.id, "name": "Zoe Park"}, +) +``` > **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 @@ -63,7 +118,9 @@ async with AsyncPineAI(api_key=key, user_id=user.id) as client: | `sessions.create()` | the new session (`dict`) | | `sessions.list(...)` | `SessionListResponse` | | `sessions.get(session_id)` | `SessionInfo` | -| `sessions.send_message(session_id, content, ...)` | `SessionMessageStatus` | +| `sessions.get_config(session_id)` | `SessionConfig` | +| `sessions.update_config(session_id, *, caller_id)` | `SessionConfig` after the change | +| `sessions.send_message(session_id, content, ..., client_now_date=None)` | `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` | @@ -82,8 +139,57 @@ 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. +A reply appears in this list only after the agent finishes it. This can take +a minute or more. Socket.IO shows the reply at once. If the `state` changed +but the reply is missing, read again later. + +### Caller ID + +When the agent calls a third party, such as a business, the call shows a +caller ID. `caller_id` is a session setting that selects it: + +| `caller_id` | The call shows | +|---|---| +| `"user"`, or not set | The user's assistant number if it has one, else the user's phone, else a number of the platform's pool. | +| `"platform"` | A number of the platform's pool. | + +- Set or change it at any time. The agent uses the value for its next call. A + call already placed, including its retries, keeps its caller ID. A change + while the session is paused applies when the session resumes. +- `caller_id` does not change how the agent reaches the user. The agent still + uses the user's phone for that. +- A 503 `session_config_push_failed` means that the value is saved, but the + running agent did not get it yet. Send the same value again. +- Any other value raises `ValueError` before any request is sent. + +```python +session = await client.sessions.create() +await client.sessions.update_config(session["id"], caller_id="platform") +await client.sessions.send_message(session["id"], "Call the clinic to book a visit") +``` + +### The user's time + +The agent must know the user's time zone to understand words such as +"tomorrow at 9 am". Send the user's current local time, with its UTC offset, +as `client_now_date` with each message. The Pine app does the same. + +- The value is an RFC 3339 time with an offset, for example + `2026-10-21T09:00:00-04:00`. Pine uses only the offset. +- A value without an offset raises `ValueError` before any request is sent. +- If a session never got `client_now_date`, the agent uses UTC. After one + message with it, the agent keeps that offset until a message brings a new + one. + +```python +from datetime import datetime +from zoneinfo import ZoneInfo + +now = datetime.now(ZoneInfo("America/New_York")).isoformat(timespec="seconds") +await client.sessions.send_message(session_id, "Book a table for tomorrow at 7 pm", client_now_date=now) +``` + +`chat()` and `send_message()` on Socket.IO take the same `client_now_date`. ### Real time (`AsyncPineAI` only) @@ -129,8 +235,23 @@ if event.type == "session:form_to_user": 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`. +from `auth.*`, and `SessionError` from `sessions.list`, `get`, `get_config`, +`update_config`, `send_message`, `history` and `outcomes`. `connect()` raises +`ConnectionError`. + +`PlatformError` from `platform.managed_users.update()`: + +| Status | Code | Cause | +|---|---|---| +| 400 | `invalid_request` | A value is not valid. For example: the phone is not E.164, the identity is unknown or not active, or a name is sent for a user with no identity. Nothing is changed. | +| 401 | `unauthorized` | The key is unknown or revoked. | +| 403 | `platform_access_suspended` | Your access is suspended. | +| 404 | `platform_managed_user_not_found` | No managed user of yours has this `external_id`. | +| 429 | `too_many_requests` | Too many requests. | + +`SessionError` from `sessions.get_config()` and `update_config()`: 403 +`forbidden` when the client is neither a managed user nor a test account, and 503 +`session_config_push_failed` (see "Caller ID"). 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. @@ -141,5 +262,6 @@ Error messages never contain the key, the `external_id` or the user ID. |---|---| | `validate_user_id(value)` | Checks the form of a managed user's `id`. | | `validate_external_id(value)` | Checks the form of an `external_id`. | +| `validate_client_now_date(value)` | Checks that a `client_now_date` has a UTC offset. | | `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 index b95cafb..482b3b7 100644 --- a/docs/socketio.md +++ b/docs/socketio.md @@ -48,7 +48,7 @@ replies with `session:error` and does not process the event. | `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` | client → server | `{"content": "...", "attachments": [], "client_now_date": "2026-10-21T09:00:00-04:00"}` | Send a message. `client_now_date` is optional: the user's local time with its UTC offset. See "The user's time" in [docs/http-api.md](http-api.md#the-users-time). | | `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. | diff --git a/pyproject.toml b/pyproject.toml index 1173d3f..2facd49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pine-assistant" -version = "0.5.0rc2" +version = "0.5.0rc3" 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 8ac5f66..a33f840 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -20,8 +20,10 @@ S2CEvent, is_supported_event, ) -from pine_assistant.models.platform import ManagedUser +from pine_assistant.models.platform import ManagedUser, ManagedUserAssistant from pine_assistant.models.session import ( + CallerID, + SessionConfig, SessionHistoryMessage, SessionHistoryPage, SessionInfo, @@ -31,6 +33,7 @@ SessionOutcomeRating, SessionOutcomesPage, ) +from pine_assistant.models.settings import AssistantIdentity, AssistantIdentityCatalog from pine_assistant.platform_api import ( ManagedUsersAPI, PlatformAPI, @@ -39,10 +42,11 @@ validate_external_id, validate_user_id, ) -from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI +from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI, validate_client_now_date +from pine_assistant.settings import SettingsAPI, SyncSettingsAPI from pine_assistant.transport.http import API_KEY_PREFIXES, MANAGED_USER_HEADER -__version__ = "0.5.0rc2" +__version__ = "0.5.0rc3" __all__ = [ "PineAI", "AsyncPineAI", @@ -50,19 +54,27 @@ "SyncAuth", "SessionsAPI", "SyncSessionsAPI", + "SettingsAPI", + "SyncSettingsAPI", "PlatformAPI", "SyncPlatformAPI", "ManagedUsersAPI", "SyncManagedUsersAPI", "ManagedUser", + "ManagedUserAssistant", + "AssistantIdentity", + "AssistantIdentityCatalog", "API_KEY_PREFIXES", "MANAGED_USER_HEADER", "validate_external_id", "validate_user_id", + "validate_client_now_date", "AuthIdentity", "AuthTicket", "RedeemedTicket", "SessionInfo", + "SessionConfig", + "CallerID", "SessionHistoryMessage", "SessionHistoryPage", "SessionListResponse", diff --git a/src/pine_assistant/chat.py b/src/pine_assistant/chat.py index b562731..b3db264 100644 --- a/src/pine_assistant/chat.py +++ b/src/pine_assistant/chat.py @@ -13,6 +13,7 @@ from pine_assistant.models.events import C2SEvent, S2CEvent from pine_assistant.models.form import FormSubmissionResult +from pine_assistant.sessions import validate_client_now_date from pine_assistant.transport.socketio import SocketIOManager # States in which nothing further arrives until something changes outside the @@ -138,14 +139,16 @@ def _build_message_data( content: str, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, + client_now_date: str | None = None, ) -> dict[str, Any]: - from datetime import datetime - return { + data: dict[str, Any] = { "content": content, "attachments": attachments or [], "referenced_sessions": referenced_sessions or [], - "client_now_date": datetime.now().isoformat(), } + if client_now_date is not None: + data["client_now_date"] = validate_client_now_date(client_now_date) + return data async def chat( self, @@ -154,12 +157,13 @@ async def chat( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, + client_now_date: str | None = None, turn_timeout: float | None = None, ) -> AsyncGenerator[ChatEvent, None]: """Send a message and yield the events that follow.""" self._sio.emit( C2SEvent.SESSION_MESSAGE, - self._build_message_data(content, attachments, referenced_sessions), + self._build_message_data(content, attachments, referenced_sessions, client_now_date), session_id, ) async for event in self._listen( @@ -174,11 +178,12 @@ def send_message( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, + client_now_date: str | None = None, ) -> None: """Fire-and-forget message send (no event listening).""" self._sio.emit( C2SEvent.SESSION_MESSAGE, - self._build_message_data(content, attachments, referenced_sessions), + self._build_message_data(content, attachments, referenced_sessions, client_now_date), session_id, ) diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 43ff5b2..72ba34c 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -19,6 +19,7 @@ from pine_assistant.models.form import FormSubmissionResult, FormToUserData, encode_form_answers from pine_assistant.platform_api import PlatformAPI, SyncPlatformAPI, validate_user_id from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI +from pine_assistant.settings import SettingsAPI, SyncSettingsAPI from pine_assistant.transport.http import ( API_KEY_PREFIXES, DEFAULT_API_BASE_PATH, @@ -145,6 +146,7 @@ def __init__( self.auth = Auth(self.http) self.sessions = SessionsAPI(self.http) self.platform = PlatformAPI(self.http) + self.settings = SettingsAPI(self.http) self._sio: SocketIOManager | None = None self._chat: ChatEngine | None = None @@ -360,6 +362,7 @@ async def chat( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, + client_now_date: str | None = None, turn_timeout: float | None = None, ) -> AsyncGenerator[ChatEvent, None]: """Send a message and yield the events that follow. @@ -367,6 +370,9 @@ async def chat( Events the SDK does not recognise are yielded unchanged alongside the rest; ignore what you do not handle. + `client_now_date` is the user's current local time with its UTC + offset; see :func:`pine_assistant.validate_client_now_date`. + `turn_timeout` bounds the call in wall-clock seconds, keeping whatever arrived before it elapsed. Without one, a turn the session never closes is waited on indefinitely. @@ -376,6 +382,7 @@ async def chat( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, + client_now_date=client_now_date, turn_timeout=turn_timeout, ): yield event @@ -387,6 +394,7 @@ def send_message( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, + client_now_date: str | None = None, ) -> None: """Send a message without waiting for events (fire-and-forget).""" self._ensure_connected() @@ -394,6 +402,7 @@ def send_message( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, + client_now_date=client_now_date, ) async def listen( @@ -517,6 +526,7 @@ def __init__( self.auth = SyncAuth(self.http) self.sessions = SyncSessionsAPI(self.http) self.platform = SyncPlatformAPI(self.http) + self.settings = SyncSettingsAPI(self.http) def __enter__(self) -> "PineAI": return self diff --git a/src/pine_assistant/models/platform.py b/src/pine_assistant/models/platform.py index b125b67..2395f24 100644 --- a/src/pine_assistant/models/platform.py +++ b/src/pine_assistant/models/platform.py @@ -5,6 +5,15 @@ from pine_assistant.models.session import SessionInfo +class ManagedUserAssistant(BaseModel): + """The managed user's assistant: a catalog identity and the name it uses.""" + + model_config = ConfigDict(extra="allow") + + identity_id: str + name: str + + class ManagedUser(BaseModel): """One end user of a Platform integration, addressed by its ``external_id``. @@ -20,6 +29,7 @@ class ManagedUser(BaseModel): email: str name: str phone: str | None + assistant: ManagedUserAssistant | None = None created_at: str @field_validator("id", mode="before") diff --git a/src/pine_assistant/models/session.py b/src/pine_assistant/models/session.py index 7749d4a..6677051 100644 --- a/src/pine_assistant/models/session.py +++ b/src/pine_assistant/models/session.py @@ -2,10 +2,12 @@ Session models — the REST session object. """ -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, field_validator +CallerID = Literal["user", "platform"] + class SessionInfo(BaseModel): model_config = ConfigDict(extra="allow") @@ -32,6 +34,14 @@ def normalize_id(cls, value: Any) -> str: return value +class SessionConfig(BaseModel): + """A session's config. ``caller_id`` is ``None`` until set, which acts as ``"user"``.""" + + model_config = ConfigDict(extra="allow") + + caller_id: str | None = None + + class SessionListResponse(BaseModel): sessions: list[SessionInfo] total: int diff --git a/src/pine_assistant/models/settings.py b/src/pine_assistant/models/settings.py new file mode 100644 index 0000000..d8ca575 --- /dev/null +++ b/src/pine_assistant/models/settings.py @@ -0,0 +1,26 @@ +"""Settings models — the assistant identity catalog.""" + +from pydantic import BaseModel, ConfigDict + + +class AssistantIdentity(BaseModel): + """One catalog identity. Its ``id`` sets the assistant's voice; use only ``status == "active"``.""" + + model_config = ConfigDict(extra="allow") + + id: str + first_name: str = "" + last_name: str = "" + locale: str = "" + description: str = "" + greeting_audio_url: str = "" + status: str = "" + + +class AssistantIdentityCatalog(BaseModel): + """The identities visible to the caller, with a ``version`` that changes when they change.""" + + model_config = ConfigDict(extra="allow") + + version: str + identities: list[AssistantIdentity] diff --git a/src/pine_assistant/platform_api.py b/src/pine_assistant/platform_api.py index bb754eb..a12a16c 100644 --- a/src/pine_assistant/platform_api.py +++ b/src/pine_assistant/platform_api.py @@ -9,6 +9,7 @@ from __future__ import annotations import re +from typing import Any from urllib.parse import quote from pydantic import ValidationError @@ -17,6 +18,7 @@ from pine_assistant.models.platform import ManagedUser from pine_assistant.transport.http import HttpClient, SyncHttpClient +_ASSISTANT_KEYS = frozenset({"identity_id", "name"}) _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}") @@ -42,13 +44,34 @@ def validate_user_id(value: str, field: str = "user_id") -> str: 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 +def _assistant(assistant: dict[str, str]) -> dict[str, str]: + if ( + not isinstance(assistant, dict) + or not assistant.keys() <= _ASSISTANT_KEYS + or not all(isinstance(value, str) for value in assistant.values()) + ): + raise ValueError("assistant must be a dict with only the string members identity_id and name") + return dict(assistant) + + +def _optional_members(**members: Any) -> dict[str, Any]: + body = {key: value for key, value in members.items() if value is not None} + if "assistant" in body: + body["assistant"] = _assistant(body["assistant"]) return body +def _create_body( + external_id: str, email: str, name: str, phone: str | None, assistant: dict[str, str] | None, +) -> dict[str, Any]: + return { + "external_id": validate_external_id(external_id), + "email": email, + "name": name, + **_optional_members(phone=phone, assistant=assistant), + } + + def _path(external_id: str) -> str: return f"{_MANAGED_USERS}/{quote(validate_external_id(external_id), safe='')}" @@ -68,14 +91,24 @@ 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: + async def create( + self, + external_id: str, + *, + email: str, + name: str, + phone: str | None = None, + assistant: dict[str, str] | None = None, + ) -> ManagedUser: """Create the managed user for ``external_id``, or return the existing one unchanged. 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. + the stored user even when ``email``, ``name``, ``phone`` or + ``assistant`` differ. ``phone`` is an E.164 number you have verified. + ``assistant`` is ``{"identity_id": ..., "name": ...}``; ``name`` needs + ``identity_id``. """ - body = _create_body(external_id, email, name, phone) + body = _create_body(external_id, email, name, phone, assistant) try: payload = await self._http.post(_MANAGED_USERS, body, as_partner=True) except PineAIError as exc: @@ -91,13 +124,45 @@ async def get(self, external_id: str) -> ManagedUser: raise _platform_failure(exc) from None return _parse_managed_user(payload) + async def update( + self, + external_id: str, + *, + email: str | None = None, + name: str | None = None, + phone: str | None = None, + assistant: dict[str, str] | None = None, + ) -> ManagedUser: + """Change only the members given and return the stored user. + + ``None`` leaves a member unchanged; ``phone=""`` removes the phone. + ``assistant`` takes ``identity_id``, ``name`` or both; a name alone + renames the current identity. The agent uses the new profile from the + user's next message. + """ + body = _optional_members(email=email, name=name, phone=phone, assistant=assistant) + path = _path(external_id) + try: + payload = await self._http.patch(path, body, as_partner=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) + def create( + self, + external_id: str, + *, + email: str, + name: str, + phone: str | None = None, + assistant: dict[str, str] | None = None, + ) -> ManagedUser: + body = _create_body(external_id, email, name, phone, assistant) try: payload = self._http.post(_MANAGED_USERS, body, as_partner=True) except PineAIError as exc: @@ -112,6 +177,23 @@ def get(self, external_id: str) -> ManagedUser: raise _platform_failure(exc) from None return _parse_managed_user(payload) + def update( + self, + external_id: str, + *, + email: str | None = None, + name: str | None = None, + phone: str | None = None, + assistant: dict[str, str] | None = None, + ) -> ManagedUser: + body = _optional_members(email=email, name=name, phone=phone, assistant=assistant) + path = _path(external_id) + try: + payload = self._http.patch(path, body, as_partner=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``.""" diff --git a/src/pine_assistant/sessions.py b/src/pine_assistant/sessions.py index 5ecfe98..20d641e 100644 --- a/src/pine_assistant/sessions.py +++ b/src/pine_assistant/sessions.py @@ -4,12 +4,16 @@ from __future__ import annotations -from typing import Any +import re +from datetime import datetime +from typing import Any, get_args from pydantic import ValidationError from pine_assistant.errors import PineAIError, SessionError from pine_assistant.models.session import ( + CallerID, + SessionConfig, SessionHistoryPage, SessionInfo, SessionListResponse, @@ -18,6 +22,41 @@ ) from pine_assistant.transport.http import HttpClient, SyncHttpClient +_CLIENT_NOW_DATE = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)") + + +def _is_calendar_time(text: str) -> bool: + try: + datetime.fromisoformat(text) + except ValueError: + return False + else: + return True + + +def validate_client_now_date(value: str | None) -> str | None: + """Check the backend's rule: an RFC 3339 time with a UTC offset, such as ``2026-10-21T09:00:00-04:00``. + + Pine reads only the offset, as the user's time zone. A value without one + would be dropped by the backend, so it is refused here. + """ + if value is None: + return None + match = _CLIENT_NOW_DATE.fullmatch(value) if isinstance(value, str) else None + if match is None or not _is_calendar_time(match.group(1)): + raise ValueError("client_now_date must be an RFC 3339 time with a UTC offset, such as 2026-10-21T09:00:00-04:00") + return value + + +def _caller_id(caller_id: CallerID) -> CallerID: + if caller_id not in get_args(CallerID): + raise ValueError('caller_id must be "user" or "platform"') + return caller_id + + +def _config_path(session_id: str | int) -> str: + return f"/v2/sessions/{_session_id(session_id)}/config" + def _session_id(session_id: str | int) -> str: if isinstance(session_id, bool) or not isinstance(session_id, (str, int)): @@ -35,6 +74,13 @@ def _parse_session(payload: Any) -> SessionInfo: raise SessionError("Pine API returned an invalid session response", "invalid_response") from None +def _parse_session_config(payload: Any) -> SessionConfig: + try: + return SessionConfig.model_validate(payload) + except ValidationError: + raise SessionError("Pine API returned an invalid session config response", "invalid_response") from None + + def _parse_session_list(payload: Any) -> SessionListResponse: try: return SessionListResponse.model_validate(payload) @@ -128,6 +174,29 @@ async def get(self, session_id: str | int) -> SessionInfo: except PineAIError as exc: raise SessionError(str(exc), exc.code, status_code=exc.status_code) from exc + async def get_config(self, session_id: str | int) -> SessionConfig: + """Read the session config. Only managed users and test accounts may; others get 403.""" + try: + return _parse_session_config(await self._http.get(_config_path(session_id))) + except PineAIError as exc: + raise _session_error(exc) from exc + + async def update_config(self, session_id: str | int, *, caller_id: CallerID) -> SessionConfig: + """Set the caller ID of the calls the agent places in this session. + + ``"user"``, or not set, presents the user's assistant number if it has + one, else the user's phone, else a number of the platform's pool; + ``"platform"`` presents a number of the platform's pool. Set it at any + time: the agent uses the value for its next call. A 503 + ``session_config_push_failed`` means the value is saved but has not + reached the running agent yet; send the same value again. + """ + body = {"caller_id": _caller_id(caller_id)} + try: + return _parse_session_config(await self._http.put(_config_path(session_id), body)) + except PineAIError as exc: + raise _session_error(exc) from exc + async def end_task(self, session_id: str | int) -> SessionInfo: """Request that an eligible continuous session be closed by its user. @@ -172,7 +241,7 @@ async def send_message( "library_artifact_ids": library_artifact_ids, "referenced_sessions": referenced_sessions, "quote": quote, - "client_now_date": client_now_date, + "client_now_date": validate_client_now_date(client_now_date), }.items(): if value is not None: body[key] = value @@ -287,6 +356,19 @@ def get(self, session_id: str | int) -> SessionInfo: except PineAIError as exc: raise SessionError(str(exc), exc.code, status_code=exc.status_code) from exc + def get_config(self, session_id: str | int) -> SessionConfig: + try: + return _parse_session_config(self._http.get(_config_path(session_id))) + except PineAIError as exc: + raise _session_error(exc) from exc + + def update_config(self, session_id: str | int, *, caller_id: CallerID) -> SessionConfig: + body = {"caller_id": _caller_id(caller_id)} + try: + return _parse_session_config(self._http.put(_config_path(session_id), body)) + except PineAIError as exc: + raise _session_error(exc) from exc + def end_task(self, session_id: str | int) -> SessionInfo: """Request that an eligible continuous session be closed by its user. @@ -326,7 +408,7 @@ def send_message( "library_artifact_ids": library_artifact_ids, "referenced_sessions": referenced_sessions, "quote": quote, - "client_now_date": client_now_date, + "client_now_date": validate_client_now_date(client_now_date), }.items(): if value is not None: body[key] = value diff --git a/src/pine_assistant/settings.py b/src/pine_assistant/settings.py new file mode 100644 index 0000000..f6245a1 --- /dev/null +++ b/src/pine_assistant/settings.py @@ -0,0 +1,45 @@ +""" +Settings — the assistant identity catalog. + +A client with ``api_key`` alone reads the catalog anonymously; a managed-user +or user-token client reads it as that user. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import ValidationError + +from pine_assistant.errors import PineAIError +from pine_assistant.models.settings import AssistantIdentityCatalog +from pine_assistant.transport.http import HttpClient, SyncHttpClient + +_ASSISTANT_IDENTITIES = "/v2/app/settings/assistance_identities" +_CATALOG: dict[str, str | int] = {"identities": "catalog"} + + +def _parse_catalog(payload: Any) -> AssistantIdentityCatalog: + try: + return AssistantIdentityCatalog.model_validate(payload) + except ValidationError: + raise PineAIError("invalid_response", "Pine API returned an invalid assistant identity catalog") from None + + +class SettingsAPI: + def __init__(self, http: HttpClient): + self._http = http + + async def assistant_identities(self) -> AssistantIdentityCatalog: + """List the assistant identities to choose from; pass an active one's ``id`` as ``identity_id``.""" + payload = await self._http.get(_ASSISTANT_IDENTITIES, authenticated=not self._http.key_only, params=_CATALOG) + return _parse_catalog(payload) + + +class SyncSettingsAPI: + def __init__(self, http: SyncHttpClient): + self._http = http + + def assistant_identities(self) -> AssistantIdentityCatalog: + payload = self._http.get(_ASSISTANT_IDENTITIES, authenticated=not self._http.key_only, params=_CATALOG) + return _parse_catalog(payload) diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index 0989079..dfda978 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -108,6 +108,10 @@ def __init__(self, token: str | None, api_key: str | None, managed_user_id: str self._api_key = api_key self._managed_user_id = managed_user_id + @property + def key_only(self) -> bool: + return self._api_key is not None and self._managed_user_id is None + def ensure_token_can_change(self) -> None: if self._api_key is not None: raise ValueError(FIXED_KEY_MESSAGE) @@ -224,6 +228,9 @@ async def put(self, path: str, body: dict[str, Any] | None = None, authenticated *, token: str | None = None) -> Any: return await self._request("PUT", path, body=body, authenticated=authenticated, token=token) + async def patch(self, path: str, body: dict[str, Any] | None = None, *, as_partner: bool = False) -> Any: + return await self._request("PATCH", path, body=body, as_partner=as_partner) + async def delete(self, path: str, params: dict[str, str | int] | None = None, authenticated: bool = True, *, token: str | None = None) -> Any: return await self._request("DELETE", path, params=params, authenticated=authenticated, token=token) @@ -319,6 +326,9 @@ def put(self, path: str, body: dict[str, Any] | None = None, authenticated: bool *, token: str | None = None) -> Any: return self._request("PUT", path, body=body, authenticated=authenticated, token=token) + def patch(self, path: str, body: dict[str, Any] | None = None, *, as_partner: bool = False) -> Any: + return self._request("PATCH", path, body=body, as_partner=as_partner) + def delete(self, path: str, params: dict[str, str | int] | None = None, authenticated: bool = True, *, token: str | None = None) -> Any: return self._request("DELETE", path, params=params, authenticated=authenticated, token=token) diff --git a/tests/test_m1_resources.py b/tests/test_m1_resources.py index a719cc8..75180e4 100644 --- a/tests/test_m1_resources.py +++ b/tests/test_m1_resources.py @@ -265,7 +265,7 @@ async def handler(request): library_artifact_ids=["artifact-1"], referenced_sessions=[{"session_id": "6", "title": "Earlier work"}], quote={"text": "The prior quote"}, - client_now_date="2026-09-18", + client_now_date="2026-09-18T09:30:00-07:00", request_id="click-18", )) @@ -282,7 +282,7 @@ async def handler(request): "library_artifact_ids": ["artifact-1"], "referenced_sessions": [{"session_id": "6", "title": "Earlier work"}], "quote": {"text": "The prior quote"}, - "client_now_date": "2026-09-18", + "client_now_date": "2026-09-18T09:30:00-07:00", } diff --git a/tests/test_profile_and_session_config.py b/tests/test_profile_and_session_config.py new file mode 100644 index 0000000..7d3792e --- /dev/null +++ b/tests/test_profile_and_session_config.py @@ -0,0 +1,350 @@ +"""Managed-user update, assistant, session config, identity catalog and client_now_date.""" + +import json + +import httpx +import pytest + +from pine_assistant import ( + MANAGED_USER_HEADER, + AssistantIdentityCatalog, + AsyncPineAI, + ManagedUser, + ManagedUserAssistant, + PineAI, + PlatformError, + SessionConfig, + SessionError, + validate_client_now_date, +) +from pine_assistant.chat import ChatEngine + +API_KEY = "pine_sk_test_c2VjcmV0LWtleS1mb3ItdGVzdHM" +EXTERNAL_ID = "acme:user|42-x" +ENCODED_EXTERNAL_ID = "acme%3Auser%7C42-x" +PINE_USER_ID = "730194412688371" +BASE_URL = "https://pine.test" + + +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, + "assistant": {"identity_id": "agt_ava", "name": "Zoe Park"}, + "created_at": "2026-10-21T00:00:00Z", + **overrides, + } + + +CATALOG = { + "version": "0123456789abcdef", + "identities": [ + {"id": "agt_ava", "first_name": "Ava", "last_name": "Reed", "locale": "en-US", "status": "active"}, + {"id": "agt_old", "first_name": "Old", "status": "deprecated"}, + ], +} + + +class Recorder: + def __init__(self, response: httpx.Response | None = None): + self.requests: list[httpx.Request] = [] + self.response = response + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if self.response is not None: + return self.response + if request.url.path.startswith("/api/platform/"): + return httpx.Response(200, json=success(managed_user())) + if request.url.path.endswith("/config"): + body = json.loads(request.content) if request.content else {} + return httpx.Response(200, json=success(body)) + if request.url.path == "/api/v2/app/settings/assistance_identities": + return httpx.Response(200, json=success(CATALOG)) + return httpx.Response(200, json=success({"message_id": "18", "status": "received"})) + + def body(self, index: int = -1): + return json.loads(self.requests[index].content) + + +def async_client(recorder: Recorder, user_id: str | None = None) -> AsyncPineAI: + return AsyncPineAI( + api_key=API_KEY, user_id=user_id, base_url=BASE_URL, http_transport=httpx.MockTransport(recorder), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("kwargs", "body"), + [ + ({}, {}), + ({"phone": ""}, {"phone": ""}), + ({"email": "new@example.com", "name": "Ada King"}, {"email": "new@example.com", "name": "Ada King"}), + ({"phone": "+14155550100", "email": None}, {"phone": "+14155550100"}), + ({"assistant": {}}, {"assistant": {}}), + ({"assistant": {"name": "Zoe Park"}}, {"assistant": {"name": "Zoe Park"}}), + ( + {"assistant": {"identity_id": "agt_ava", "name": "Zoe Park"}}, + {"assistant": {"identity_id": "agt_ava", "name": "Zoe Park"}}, + ), + ], +) +async def test_update_sends_only_the_given_members_as_the_partner(kwargs, body): + recorder = Recorder() + async with async_client(recorder, user_id=PINE_USER_ID) as client: + user = await client.platform.managed_users.update(EXTERNAL_ID, **kwargs) + + assert isinstance(user, ManagedUser) + assert user.assistant == ManagedUserAssistant(identity_id="agt_ava", name="Zoe Park") + [request] = recorder.requests + assert request.method == "PATCH" + assert request.url.raw_path == f"/api/platform/v1/managed-users/{ENCODED_EXTERNAL_ID}".encode() + assert request.headers["Authorization"] == f"Bearer {API_KEY}" + assert MANAGED_USER_HEADER not in request.headers + assert recorder.body() == body + + +@pytest.mark.asyncio +async def test_create_sends_the_assistant(): + recorder = Recorder() + async with async_client(recorder) as client: + await client.platform.managed_users.create( + EXTERNAL_ID, email="ada@example.com", name="Ada Lovelace", assistant={"identity_id": "agt_ava"}, + ) + + assert recorder.body() == { + "external_id": EXTERNAL_ID, + "email": "ada@example.com", + "name": "Ada Lovelace", + "assistant": {"identity_id": "agt_ava"}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "assistant", + [{"identity": "agt_ava"}, {"identity_id": None}, {"name": 7}, ["agt_ava"], "agt_ava"], +) +async def test_invalid_assistant_is_refused_before_any_request(assistant): + recorder = Recorder() + async with async_client(recorder) as client: + with pytest.raises(ValueError, match="assistant must be a dict"): + await client.platform.managed_users.update(EXTERNAL_ID, assistant=assistant) + with pytest.raises(ValueError, match="assistant must be a dict"): + await client.platform.managed_users.create( + EXTERNAL_ID, email="ada@example.com", name="Ada", assistant=assistant, + ) + + assert recorder.requests == [] + + +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + ({"assistant": None}, None), + ({"assistant": {"identity_id": "agt_ava", "name": "Ava Reed"}}, ManagedUserAssistant(identity_id="agt_ava", name="Ava Reed")), + ], +) +def test_managed_user_assistant_parses(overrides, expected): + assert ManagedUser.model_validate(managed_user(**overrides)).assistant == expected + + +def test_managed_user_without_assistant_member_still_parses(): + payload = managed_user() + del payload["assistant"] + assert ManagedUser.model_validate(payload).assistant is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status", "code"), + [(400, "invalid_request"), (403, "platform_access_suspended"), (404, "platform_managed_user_not_found")], +) +async def test_update_errors_keep_code_and_status_without_upstream_body(status, code): + recorder = Recorder(httpx.Response( + status, json={"status": "error", "error": {"code": code, "message": f"{API_KEY} {EXTERNAL_ID}"}}, + )) + async with async_client(recorder) as client: + with pytest.raises(PlatformError) as excinfo: + await client.platform.managed_users.update(EXTERNAL_ID, phone="") + + assert (excinfo.value.code, excinfo.value.status_code) == (code, status) + assert API_KEY not in repr(excinfo.value) + assert EXTERNAL_ID not in repr(excinfo.value) + + +def test_sync_update_and_config(): + recorder = Recorder() + with PineAI( + api_key=API_KEY, user_id=PINE_USER_ID, base_url=BASE_URL, http_transport=httpx.MockTransport(recorder), + ) as client: + user = client.platform.managed_users.update(EXTERNAL_ID, assistant={"name": "Zoe Park"}) + config = client.sessions.update_config(7, caller_id="platform") + + assert user.assistant is not None + assert config == SessionConfig(caller_id="platform") + assert [request.method for request in recorder.requests] == ["PATCH", "PUT"] + assert MANAGED_USER_HEADER not in recorder.requests[0].headers + assert recorder.requests[1].headers[MANAGED_USER_HEADER] == PINE_USER_ID + + +@pytest.mark.asyncio +@pytest.mark.parametrize("caller_id", ["user", "platform"]) +async def test_update_config_puts_the_caller_id_as_the_managed_user(caller_id): + recorder = Recorder() + async with async_client(recorder, user_id=PINE_USER_ID) as client: + config = await client.sessions.update_config("7", caller_id=caller_id) + + assert config.caller_id == caller_id + [request] = recorder.requests + assert (request.method, request.url.path) == ("PUT", "/api/v2/sessions/7/config") + assert request.headers[MANAGED_USER_HEADER] == PINE_USER_ID + assert recorder.body() == {"caller_id": caller_id} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("caller_id", ["pine", "", None, "PLATFORM"]) +async def test_invalid_caller_id_is_refused_before_any_request(caller_id): + recorder = Recorder() + async with async_client(recorder, user_id=PINE_USER_ID) as client: + with pytest.raises(ValueError, match="caller_id"): + await client.sessions.update_config(7, caller_id=caller_id) + + assert recorder.requests == [] + + +@pytest.mark.asyncio +async def test_config_push_failure_keeps_code_and_status(): + recorder = Recorder(httpx.Response( + 503, json={"status": "error", "error": {"code": "session_config_push_failed", "message": "retry"}}, + )) + async with async_client(recorder, user_id=PINE_USER_ID) as client: + with pytest.raises(SessionError) as excinfo: + await client.sessions.update_config(7, caller_id="platform") + + assert (excinfo.value.code, excinfo.value.status_code) == ("session_config_push_failed", 503) + + +@pytest.mark.asyncio +async def test_key_only_client_cannot_touch_session_config(): + recorder = Recorder() + async with async_client(recorder) as client: + with pytest.raises(ValueError): + await client.sessions.get_config(7) + + assert recorder.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "caller_id", "extra"), + [({}, None, {}), ({"caller_id": "platform"}, "platform", {}), ({"caller_id": "user", "debug": 1}, "user", {"debug": 1})], +) +async def test_get_config_parses_set_and_unset_values(payload, caller_id, extra): + recorder = Recorder(httpx.Response(200, json=success(payload))) + async with async_client(recorder, user_id=PINE_USER_ID) as client: + config = await client.sessions.get_config(7) + + assert config.caller_id == caller_id + assert config.model_extra == extra + assert recorder.requests[0].method == "GET" + + +@pytest.mark.asyncio +async def test_key_only_client_reads_the_catalog_anonymously(): + recorder = Recorder() + async with async_client(recorder) as client: + catalog = await client.settings.assistant_identities() + + assert isinstance(catalog, AssistantIdentityCatalog) + assert catalog.version == "0123456789abcdef" + assert [(identity.id, identity.status) for identity in catalog.identities] == [ + ("agt_ava", "active"), ("agt_old", "deprecated"), + ] + [request] = recorder.requests + assert request.url.path == "/api/v2/app/settings/assistance_identities" + assert request.url.params["identities"] == "catalog" + assert "Authorization" not in request.headers + assert MANAGED_USER_HEADER not in request.headers + + +def test_managed_user_client_reads_the_catalog_as_the_user(): + recorder = Recorder() + with PineAI( + api_key=API_KEY, user_id=PINE_USER_ID, base_url=BASE_URL, http_transport=httpx.MockTransport(recorder), + ) as client: + catalog = client.settings.assistant_identities() + + assert catalog.identities[0].id == "agt_ava" + [request] = recorder.requests + assert request.headers["Authorization"] == f"Bearer {API_KEY}" + assert request.headers[MANAGED_USER_HEADER] == PINE_USER_ID + + +@pytest.mark.parametrize( + "value", + [ + "2026-10-21T09:00:00-04:00", + "2026-10-21T09:00:00+08:00", + "2026-10-21T13:00:00Z", + "2026-10-21T09:00:00.123456-04:00", + "2026-10-21T09:00:00.1+05:30", + ], +) +def test_client_now_date_accepts_rfc3339_with_an_offset(value): + assert validate_client_now_date(value) == value + + +@pytest.mark.parametrize( + "value", + [ + "2026-09-18", + "2026-10-21T09:00:00", + "2026-10-21T09:00:00.123456", + "2026-10-21 09:00:00-04:00", + "2026-10-21T09:00-04:00", + "2026-13-21T09:00:00Z", + "2026-10-21T09:00:00+24:00", + "2026-10-21T09:00:00z", + "", + 1729501200, + ], +) +def test_client_now_date_refuses_values_the_backend_would_drop(value): + with pytest.raises(ValueError, match="client_now_date"): + validate_client_now_date(value) + + +@pytest.mark.asyncio +async def test_rest_message_refuses_a_client_now_date_without_offset_before_any_request(): + recorder = Recorder() + async with async_client(recorder, user_id=PINE_USER_ID) as client: + with pytest.raises(ValueError, match="client_now_date"): + await client.sessions.send_message(7, "hi", client_now_date="2026-10-21T09:00:00") + + assert recorder.requests == [] + + +@pytest.mark.asyncio +async def test_rest_message_omits_client_now_date_when_not_given(): + recorder = Recorder() + async with async_client(recorder, user_id=PINE_USER_ID) as client: + await client.sessions.send_message(7, "hi") + + assert recorder.body() == {"content": "hi"} + + +def test_socketio_message_carries_client_now_date_only_when_given(): + assert ChatEngine._build_message_data("hi") == {"content": "hi", "attachments": [], "referenced_sessions": []} + assert ChatEngine._build_message_data("hi", client_now_date="2026-10-21T09:00:00-04:00")["client_now_date"] == ( + "2026-10-21T09:00:00-04:00" + ) + with pytest.raises(ValueError, match="client_now_date"): + ChatEngine._build_message_data("hi", client_now_date="2026-10-21T09:00:00")