From dafceab329f26e41beefdff3913af039ac87d646 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Wed, 21 Oct 2026 19:40:14 +0800 Subject: [PATCH 1/5] feat(platform): add managed-user update, caller ID config and identity catalog - platform.managed_users.update() (PATCH): only the given members are sent, phone="" removes the phone. create() and update() take assistant as a dict with identity_id and/or name; ManagedUser.assistant defaults to None, so responses without the field still parse. - sessions.get_config() / update_config(caller_id="user" | "platform") on GET/PUT /v2/sessions/{id}/config; other values fail before any request. - app_settings.assistant_identities() reads the identity catalog (/v2/app/settings/assistance_identities?identities=catalog); a key-only client reads it anonymously. - client_now_date: Socket.IO chat() and send_message() take it, and every path requires an RFC 3339 time with a UTC offset. The Socket.IO default of a naive local time is removed: the server could not parse it, so the agent always used UTC. Docs, CHANGELOG and version 0.5.0rc3. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 48 +++ README.md | 22 +- docs/http-api.md | 104 ++++++- docs/sdk-api.md | 143 ++++++++- docs/socketio.md | 2 +- pyproject.toml | 2 +- src/pine_assistant/__init__.py | 18 +- src/pine_assistant/app_settings.py | 45 +++ src/pine_assistant/chat.py | 15 +- src/pine_assistant/client.py | 10 + src/pine_assistant/models/app_settings.py | 26 ++ src/pine_assistant/models/platform.py | 10 + src/pine_assistant/models/session.py | 12 +- src/pine_assistant/platform_api.py | 102 ++++++- src/pine_assistant/sessions.py | 88 +++++- src/pine_assistant/transport/http.py | 10 + tests/test_m1_resources.py | 4 +- tests/test_profile_and_session_config.py | 350 ++++++++++++++++++++++ 18 files changed, 965 insertions(+), 46 deletions(-) create mode 100644 src/pine_assistant/app_settings.py create mode 100644 src/pine_assistant/models/app_settings.py create mode 100644 tests/test_profile_and_session_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7163de3..2f6201b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ 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. + +To upgrade from 0.5.0rc2: + +- If you pass `client_now_date`, make sure that it has a UTC offset, for + example `2026-10-21T09:00:00-04:00`. A value without one now raises + `ValueError`. +- Everything else is new. Existing code needs no change. + +`update()`, `assistant`, the identity catalog and the session config need the +Pine API that supports them. Until it is live, these calls return an error. + +### Added + +- `platform.managed_users.update(external_id, *, email, name, phone, assistant)` + (`PATCH /platform/v1/managed-users/{external_id}`). Only the given arguments + are sent; `phone=""` removes the phone. Errors raise `PlatformError`. +- `assistant` on `managed_users.create()` and `update()`: a `dict` with + `identity_id`, `name` or both. Other keys or non-string values raise + `ValueError` before any request. `ManagedUser.assistant` is a + `ManagedUserAssistant` or `None`, and also `None` when the backend does not + send it. +- `app_settings.assistant_identities()` returns the `AssistantIdentityCatalog` + from `GET /v2/app/settings/assistance_identities?identities=catalog`. A + client with `api_key` alone reads it anonymously. +- `sessions.get_config()` and `sessions.update_config(caller_id=...)` read and + set the session's caller ID, `"user"` or `"platform"` + (`GET/PUT /v2/sessions/{id}/config`). Other values raise `ValueError` before + any request. A 503 `session_config_push_failed` is a `SessionError`; the + value is saved. +- `client_now_date` on Socket.IO `chat()` and `send_message()`, and + `validate_client_now_date()`. +- `PineAI` has the same new methods. + +### Changed + +- `client_now_date` must be an RFC 3339 time with a UTC offset, such as + `2026-10-21T09:00:00-04:00`, on REST and Socket.IO. Other values raise + `ValueError`; the backend used to drop them and treat the user as UTC. + +### Fixed + +- Socket.IO messages no longer send the host's local time without an offset + as `client_now_date`. The backend could not parse it, so the agent always + used UTC. A message now carries `client_now_date` only when you pass it. + ## [0.5.0rc2] - 2026-10-19 Internal release candidate for 0.5.0. diff --git a/README.md b/README.md index 4081e6a..4f74623 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/v0.5.0rc3/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/v0.5.0rc3/docs/http-api.md) +and the Socket.IO reference in +[docs/socketio.md](https://github.com/19PINE-AI/pine-assistant-python/blob/v0.5.0rc3/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..c239958 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 has no assistant. Errors: 400 `invalid_request`, 401 `unauthorized`, 403 `platform_access_suspended`, 409 `platform_managed_user_limit_reached`. @@ -65,6 +69,50 @@ 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 identity sets the assistant's voice and its default name. Read +the identities from 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. | +| `{"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` has no assistant. + ### Act for a managed user Send both headers on a `/v2` request: @@ -97,7 +145,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 +166,56 @@ 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"` (the default) | The assistant's number if it has one, then the user's phone, then a number of the platform's pool. | +| `"platform"` | Always a number of the platform's pool. | + +- Create the session, `PUT` the config, then send the first message. Then the + first call already uses it. Session create and session responses do not + carry the config. +- You can change it while a task runs. The agent uses the new value for its + next call. A call in progress does not change. +- It cannot be removed. Set `"user"` to go back to the default. +- `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. + +> **Caution:** with `"user"`, a call can show the user's own phone. Carriers +> check caller IDs (STIR/SHAKEN). A number that does not belong to Pine gets a +> lower level of trust (B or C). The person who is called can see it as +> suspicious, or not answer. Some lines cannot show the user's number. A retry +> of a call can show a number of the platform's pool. You are responsible +> for the phone numbers you give for your users. + +### 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..78ee7ce 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -43,11 +43,62 @@ 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. | +| `app_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 +has no 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 identity sets the assistant's voice and its default name. Each +user can have one assistant. Without one, the user has no assistant, as +before. + +1. Read the catalog with `app_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. | +| `{"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.app_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 +114,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 +135,66 @@ 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"` (the default) | The assistant's number if it has one, then the user's phone, then a number of the platform's pool. | +| `"platform"` | Always a number of the platform's pool. | + +- Set it after `sessions.create()` and before the first message. Then the + first call already uses it. +- You can change it while a task runs. The agent uses the new value for its + next call. A call in progress does not change. +- It cannot be removed. Set `"user"` to go back to the default. +- `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") +``` + +> **Caution:** with `"user"`, a call can show the user's own phone. Carriers +> check caller IDs (STIR/SHAKEN). A number that does not belong to Pine gets a +> lower level of trust (B or C). The person who is called can see it as +> suspicious, or not answer. Some lines cannot show the user's number. A retry +> of a call can show a number of the platform's pool. You are responsible +> for the phone numbers you give for your users. + +### 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 +240,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` for a client that is not a managed user, 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 +267,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..cb9d323 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -9,10 +9,12 @@ them. `is_supported_event` tells the two apart. """ +from pine_assistant.app_settings import AppSettingsAPI, SyncAppSettingsAPI 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, PlatformError, SessionError +from pine_assistant.models.app_settings import AssistantIdentity, AssistantIdentityCatalog from pine_assistant.models.auth import AuthIdentity, AuthTicket, RedeemedTicket from pine_assistant.models.events import ( SUPPORTED_EVENTS, @@ -20,8 +22,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, @@ -39,10 +43,10 @@ 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.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", + "AppSettingsAPI", + "SyncAppSettingsAPI", "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/app_settings.py b/src/pine_assistant/app_settings.py new file mode 100644 index 0000000..17492b0 --- /dev/null +++ b/src/pine_assistant/app_settings.py @@ -0,0 +1,45 @@ +""" +App 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.app_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 AppSettingsAPI: + 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 SyncAppSettingsAPI: + 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/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..d7e4767 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -12,6 +12,7 @@ import httpx +from pine_assistant.app_settings import AppSettingsAPI, SyncAppSettingsAPI from pine_assistant.auth import Auth, SyncAuth from pine_assistant.chat import ChatEngine, ChatEvent, Deduplicator, event_from_envelope from pine_assistant.errors import ConnectionError @@ -145,6 +146,7 @@ def __init__( self.auth = Auth(self.http) self.sessions = SessionsAPI(self.http) self.platform = PlatformAPI(self.http) + self.app_settings = AppSettingsAPI(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.app_settings = SyncAppSettingsAPI(self.http) def __enter__(self) -> "PineAI": return self diff --git a/src/pine_assistant/models/app_settings.py b/src/pine_assistant/models/app_settings.py new file mode 100644 index 0000000..2eff9bb --- /dev/null +++ b/src/pine_assistant/models/app_settings.py @@ -0,0 +1,26 @@ +"""App 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/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/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..34bbefd 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"`` (the default) presents the assistant's number, then the + user's phone, then a platform number; ``"platform"`` always presents a + number of the platform's pool. Set it before the first message, or + any time later: a running agent gets it before 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/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..211d7cf --- /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.app_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.app_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") From d8ecaf383cadeb676132d6442036004de07df6de Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Wed, 21 Oct 2026 19:57:52 +0800 Subject: [PATCH 2/5] docs: make the caller ID section shorter and more exact Drop the lines on when the first call uses the setting and on removing it. "user" and no value behave the same; the assistant number applies only when the user has one; "platform" shows a pool number without "always". The STIR/SHAKEN caution now says the rating depends on whether the carrier that places the call owns the number, so any caller ID can get B or C. Co-Authored-By: Claude Opus 5.5 --- docs/http-api.md | 22 ++++++++++------------ docs/sdk-api.md | 21 ++++++++++----------- src/pine_assistant/sessions.py | 13 +++++++------ 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/docs/http-api.md b/docs/http-api.md index c239958..5851267 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -180,27 +180,25 @@ caller ID. `caller_id` is a session setting that selects it: | `caller_id` | The call shows | |---|---| -| `"user"` (the default) | The assistant's number if it has one, then the user's phone, then a number of the platform's pool. | -| `"platform"` | Always a number of the platform's pool. | +| `"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. | -- Create the session, `PUT` the config, then send the first message. Then the - first call already uses it. Session create and session responses do not - carry the config. +- Set it after you create the session and before the first message. - You can change it while a task runs. The agent uses the new value for its next call. A call in progress does not change. -- It cannot be removed. Set `"user"` to go back to the default. - `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. -> **Caution:** with `"user"`, a call can show the user's own phone. Carriers -> check caller IDs (STIR/SHAKEN). A number that does not belong to Pine gets a -> lower level of trust (B or C). The person who is called can see it as -> suspicious, or not answer. Some lines cannot show the user's number. A retry -> of a call can show a number of the platform's pool. You are responsible -> for the phone numbers you give for your users. +> **Caution:** carriers rate each caller ID (STIR/SHAKEN). The rating depends +> on whether the carrier that places the call owns the number. So a call can +> get a lower rating (B or C) with any caller ID, most often with the user's +> phone. The person who is called can then see the call as suspicious, or not +> answer. Some lines cannot show the user's phone, and a retry of a call can +> show a number of the platform's pool. You are responsible for the phone +> numbers you give for your users. ### The user's time diff --git a/docs/sdk-api.md b/docs/sdk-api.md index 78ee7ce..4e26e50 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -146,14 +146,12 @@ caller ID. `caller_id` is a session setting that selects it: | `caller_id` | The call shows | |---|---| -| `"user"` (the default) | The assistant's number if it has one, then the user's phone, then a number of the platform's pool. | -| `"platform"` | Always a number of the platform's pool. | +| `"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 it after `sessions.create()` and before the first message. Then the - first call already uses it. +- Set it after `sessions.create()` and before the first message. - You can change it while a task runs. The agent uses the new value for its next call. A call in progress does not change. -- It cannot be removed. Set `"user"` to go back to the default. - `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 @@ -166,12 +164,13 @@ await client.sessions.update_config(session["id"], caller_id="platform") await client.sessions.send_message(session["id"], "Call the clinic to book a visit") ``` -> **Caution:** with `"user"`, a call can show the user's own phone. Carriers -> check caller IDs (STIR/SHAKEN). A number that does not belong to Pine gets a -> lower level of trust (B or C). The person who is called can see it as -> suspicious, or not answer. Some lines cannot show the user's number. A retry -> of a call can show a number of the platform's pool. You are responsible -> for the phone numbers you give for your users. +> **Caution:** carriers rate each caller ID (STIR/SHAKEN). The rating depends +> on whether the carrier that places the call owns the number. So a call can +> get a lower rating (B or C) with any caller ID, most often with the user's +> phone. The person who is called can then see the call as suspicious, or not +> answer. Some lines cannot show the user's phone, and a retry of a call can +> show a number of the platform's pool. You are responsible for the phone +> numbers you give for your users. ### The user's time diff --git a/src/pine_assistant/sessions.py b/src/pine_assistant/sessions.py index 34bbefd..ec1861b 100644 --- a/src/pine_assistant/sessions.py +++ b/src/pine_assistant/sessions.py @@ -184,12 +184,13 @@ async def get_config(self, session_id: str | int) -> SessionConfig: 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"`` (the default) presents the assistant's number, then the - user's phone, then a platform number; ``"platform"`` always presents a - number of the platform's pool. Set it before the first message, or - any time later: a running agent gets it before 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. + ``"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 before + the first message, or any time later: a running agent gets it before + 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: From 8d40f3b30d80e1428180be46a2fd7662fc9144a2 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Wed, 21 Oct 2026 20:33:49 +0800 Subject: [PATCH 3/5] feat(sessions): take the user's timezone instead of client_now_date send_message() on REST and chat()/send_message() on Socket.IO take timezone (an IANA name or a tzinfo). The SDK sends the current local time in it as client_now_date, so callers cannot send a malformed value. An unknown time zone raises ValueError before any request. tzdata is a dependency on Windows, which has no system time zone database. Docs: the caller ID section is shorter and drops the STIR/SHAKEN caution; it can be set or changed at any time. README links point to the docs on main, so they stay the same across releases and open on PyPI. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++--- README.md | 6 +- docs/http-api.md | 13 +--- docs/sdk-api.md | 40 ++++------ pyproject.toml | 1 + src/pine_assistant/__init__.py | 3 +- src/pine_assistant/chat.py | 18 +++-- src/pine_assistant/client.py | 13 ++-- src/pine_assistant/sessions.py | 54 +++++++------- tests/test_m1_resources.py | 2 - tests/test_profile_and_session_config.py | 93 ++++++++++++++---------- 11 files changed, 127 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6201b..f5c3cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ Release candidate for 0.5.0. To upgrade from 0.5.0rc2: -- If you pass `client_now_date`, make sure that it has a UTC offset, for - example `2026-10-21T09:00:00-04:00`. A value without one now raises - `ValueError`. -- Everything else is new. Existing code needs no change. +- `sessions.send_message()` no longer takes `client_now_date`. Pass the + user's time zone as `timezone` instead, for example + `timezone="America/New_York"`. The SDK sends the current local time itself. +- Everything else is new and needs no change to existing code. `update()`, `assistant`, the identity catalog and the session config need the Pine API that supports them. Until it is live, these calls return an error. @@ -37,21 +37,21 @@ Pine API that supports them. Until it is live, these calls return an error. (`GET/PUT /v2/sessions/{id}/config`). Other values raise `ValueError` before any request. A 503 `session_config_push_failed` is a `SessionError`; the value is saved. -- `client_now_date` on Socket.IO `chat()` and `send_message()`, and - `validate_client_now_date()`. +- `timezone` on Socket.IO `chat()` and `send_message()`: an IANA time zone + name or a `tzinfo`. The message carries the user's current local time. - `PineAI` has the same new methods. ### Changed -- `client_now_date` must be an RFC 3339 time with a UTC offset, such as - `2026-10-21T09:00:00-04:00`, on REST and Socket.IO. Other values raise - `ValueError`; the backend used to drop them and treat the user as UTC. +- `sessions.send_message()` takes `timezone` in place of `client_now_date`. + An unknown time zone raises `ValueError` before any request. On Windows, the + SDK now installs `tzdata` for the time zone names. ### Fixed - Socket.IO messages no longer send the host's local time without an offset as `client_now_date`. The backend could not parse it, so the agent always - used UTC. A message now carries `client_now_date` only when you pass it. + used UTC. A message now carries the time only when you pass `timezone`. ## [0.5.0rc2] - 2026-10-19 diff --git a/README.md b/README.md index 4f74623..b7157ac 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,11 @@ 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](https://github.com/19PINE-AI/pine-assistant-python/blob/v0.5.0rc3/docs/sdk-api.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/v0.5.0rc3/docs/http-api.md) +[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/v0.5.0rc3/docs/socketio.md). +[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 diff --git a/docs/http-api.md b/docs/http-api.md index 5851267..8506255 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -183,23 +183,14 @@ caller ID. `caller_id` is a session setting that selects it: | `"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 it after you create the session and before the first message. -- You can change it while a task runs. The agent uses the new value for its - next call. A call in progress does not change. +- Set or change it at any time. The agent uses the value for its next call. A + call in progress does not change. - `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. -> **Caution:** carriers rate each caller ID (STIR/SHAKEN). The rating depends -> on whether the carrier that places the call owns the number. So a call can -> get a lower rating (B or C) with any caller ID, most often with the user's -> phone. The person who is called can then see the call as suspicious, or not -> answer. Some lines cannot show the user's phone, and a retry of a call can -> show a number of the platform's pool. You are responsible for the phone -> numbers you give for your users. - ### The user's time The agent must know the user's time zone to understand words such as diff --git a/docs/sdk-api.md b/docs/sdk-api.md index 4e26e50..c7d6b44 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -116,7 +116,7 @@ user = await partner_client.platform.managed_users.update( | `sessions.get(session_id)` | `SessionInfo` | | `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.send_message(session_id, content, ..., timezone=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` | @@ -149,9 +149,8 @@ caller ID. `caller_id` is a session setting that selects it: | `"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 it after `sessions.create()` and before the first message. -- You can change it while a task runs. The agent uses the new value for its - next call. A call in progress does not change. +- Set or change it at any time. The agent uses the value for its next call. A + call in progress does not change. - `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 @@ -164,36 +163,24 @@ await client.sessions.update_config(session["id"], caller_id="platform") await client.sessions.send_message(session["id"], "Call the clinic to book a visit") ``` -> **Caution:** carriers rate each caller ID (STIR/SHAKEN). The rating depends -> on whether the carrier that places the call owns the number. So a call can -> get a lower rating (B or C) with any caller ID, most often with the user's -> phone. The person who is called can then see the call as suspicious, or not -> answer. Some lines cannot show the user's phone, and a retry of a call can -> show a number of the platform's pool. You are responsible for the phone -> numbers you give for your users. - ### 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. +"tomorrow at 9 am". Pass the user's time zone as `timezone` with each +message. The SDK then sends the user's current local time with the message, +as the Pine app does. -- 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. +- `timezone` is an IANA time zone name, such as `"America/New_York"`, or a + `tzinfo`. Daylight saving time is handled for you. +- An unknown name raises `ValueError` before any request is sent. +- If a session never got a time zone, the agent uses UTC. After one message + with it, the agent keeps that time zone 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) +await client.sessions.send_message(session_id, "Book a table for tomorrow at 7 pm", timezone="America/New_York") ``` -`chat()` and `send_message()` on Socket.IO take the same `client_now_date`. +`chat()` and `send_message()` on Socket.IO take the same `timezone`. ### Real time (`AsyncPineAI` only) @@ -266,6 +253,5 @@ 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/pyproject.toml b/pyproject.toml index 2facd49..b141e2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "aiohttp>=3.9.0", "httpx>=0.27.0", "pydantic>=2.0.0", + "tzdata; sys_platform == 'win32'", ] [project.optional-dependencies] diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index cb9d323..8abe2c8 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -43,7 +43,7 @@ validate_external_id, validate_user_id, ) -from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI, validate_client_now_date +from pine_assistant.sessions import SessionsAPI, SyncSessionsAPI from pine_assistant.transport.http import API_KEY_PREFIXES, MANAGED_USER_HEADER __version__ = "0.5.0rc3" @@ -68,7 +68,6 @@ "MANAGED_USER_HEADER", "validate_external_id", "validate_user_id", - "validate_client_now_date", "AuthIdentity", "AuthTicket", "RedeemedTicket", diff --git a/src/pine_assistant/chat.py b/src/pine_assistant/chat.py index b3db264..407dac8 100644 --- a/src/pine_assistant/chat.py +++ b/src/pine_assistant/chat.py @@ -9,11 +9,12 @@ import asyncio import time from collections.abc import AsyncGenerator, Callable, Coroutine +from datetime import tzinfo from typing import Any 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.sessions import client_now_date from pine_assistant.transport.socketio import SocketIOManager # States in which nothing further arrives until something changes outside the @@ -139,15 +140,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, + timezone: str | tzinfo | None = None, ) -> dict[str, Any]: data: dict[str, Any] = { "content": content, "attachments": attachments or [], "referenced_sessions": referenced_sessions or [], } - if client_now_date is not None: - data["client_now_date"] = validate_client_now_date(client_now_date) + now = client_now_date(timezone) + if now is not None: + data["client_now_date"] = now return data async def chat( @@ -157,13 +159,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, + timezone: str | tzinfo | 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, client_now_date), + self._build_message_data(content, attachments, referenced_sessions, timezone), session_id, ) async for event in self._listen( @@ -178,12 +180,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, + timezone: str | tzinfo | 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, client_now_date), + self._build_message_data(content, attachments, referenced_sessions, timezone), session_id, ) diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index d7e4767..2278316 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -7,6 +7,7 @@ import os import uuid from collections.abc import AsyncGenerator, Callable +from datetime import tzinfo from pathlib import Path from typing import Any @@ -362,7 +363,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, + timezone: str | tzinfo | None = None, turn_timeout: float | None = None, ) -> AsyncGenerator[ChatEvent, None]: """Send a message and yield the events that follow. @@ -370,8 +371,8 @@ 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`. + `timezone` is the user's time zone, an IANA name or a `tzinfo`. The + message then carries the user's current local time. `turn_timeout` bounds the call in wall-clock seconds, keeping whatever arrived before it elapsed. Without one, a turn the session never closes @@ -382,7 +383,7 @@ async def chat( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - client_now_date=client_now_date, + timezone=timezone, turn_timeout=turn_timeout, ): yield event @@ -394,7 +395,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, + timezone: str | tzinfo | None = None, ) -> None: """Send a message without waiting for events (fire-and-forget).""" self._ensure_connected() @@ -402,7 +403,7 @@ def send_message( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - client_now_date=client_now_date, + timezone=timezone, ) async def listen( diff --git a/src/pine_assistant/sessions.py b/src/pine_assistant/sessions.py index ec1861b..fba07cc 100644 --- a/src/pine_assistant/sessions.py +++ b/src/pine_assistant/sessions.py @@ -4,9 +4,9 @@ from __future__ import annotations -import re -from datetime import datetime +from datetime import datetime, tzinfo from typing import Any, get_args +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import ValidationError @@ -22,30 +22,27 @@ ) 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)") +_TIMEZONE_MESSAGE = 'timezone must be an IANA time zone name such as "America/New_York", or a tzinfo' -def _is_calendar_time(text: str) -> bool: +def _zone(timezone: str | tzinfo) -> tzinfo: + if isinstance(timezone, tzinfo): + return timezone try: - datetime.fromisoformat(text) - except ValueError: - return False - else: - return True - + return ZoneInfo(timezone) + except (TypeError, ValueError, ZoneInfoNotFoundError): + raise ValueError(_TIMEZONE_MESSAGE) from None -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: +def client_now_date(timezone: str | tzinfo | None) -> str | None: + if timezone 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 + zone = _zone(timezone) + try: + now = datetime.now(zone) + except ValueError: + raise ValueError(_TIMEZONE_MESSAGE) from None + return now.isoformat(timespec="seconds") def _caller_id(caller_id: CallerID) -> CallerID: @@ -186,11 +183,10 @@ async def update_config(self, session_id: str | int, *, caller_id: CallerID) -> ``"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 before - the first message, or any time later: a running agent gets it before - 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. + ``"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: @@ -222,7 +218,7 @@ async def send_message( library_artifact_ids: list[str] | None = None, referenced_sessions: list[dict[str, str]] | None = None, quote: dict[str, str] | None = None, - client_now_date: str | None = None, + timezone: str | tzinfo | None = None, request_id: str | None = None, ) -> SessionMessageStatus: """Persist and hand off a new user message without waiting for task completion. @@ -242,7 +238,7 @@ async def send_message( "library_artifact_ids": library_artifact_ids, "referenced_sessions": referenced_sessions, "quote": quote, - "client_now_date": validate_client_now_date(client_now_date), + "client_now_date": client_now_date(timezone), }.items(): if value is not None: body[key] = value @@ -394,7 +390,7 @@ def send_message( library_artifact_ids: list[str] | None = None, referenced_sessions: list[dict[str, str]] | None = None, quote: dict[str, str] | None = None, - client_now_date: str | None = None, + timezone: str | tzinfo | None = None, request_id: str | None = None, ) -> SessionMessageStatus: """Persist and hand off a new user message without waiting for task completion.""" @@ -409,7 +405,7 @@ def send_message( "library_artifact_ids": library_artifact_ids, "referenced_sessions": referenced_sessions, "quote": quote, - "client_now_date": validate_client_now_date(client_now_date), + "client_now_date": client_now_date(timezone), }.items(): if value is not None: body[key] = value diff --git a/tests/test_m1_resources.py b/tests/test_m1_resources.py index 75180e4..09d42f9 100644 --- a/tests/test_m1_resources.py +++ b/tests/test_m1_resources.py @@ -265,7 +265,6 @@ 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-18T09:30:00-07:00", request_id="click-18", )) @@ -282,7 +281,6 @@ 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-18T09:30:00-07:00", } diff --git a/tests/test_profile_and_session_config.py b/tests/test_profile_and_session_config.py index 211d7cf..83c03be 100644 --- a/tests/test_profile_and_session_config.py +++ b/tests/test_profile_and_session_config.py @@ -1,6 +1,10 @@ -"""Managed-user update, assistant, session config, identity catalog and client_now_date.""" +"""Managed-user update, assistant, session config, identity catalog and the user's time zone.""" import json +import re +from datetime import datetime, timedelta, tzinfo +from datetime import timezone as dt_timezone +from zoneinfo import ZoneInfo import httpx import pytest @@ -15,7 +19,6 @@ PlatformError, SessionConfig, SessionError, - validate_client_now_date, ) from pine_assistant.chat import ChatEngine @@ -288,52 +291,68 @@ def test_managed_user_client_reads_the_catalog_as_the_user(): 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 +RFC3339_SECONDS = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}") + + +class NoOffset(tzinfo): + def utcoffset(self, dt): + return None + + def dst(self, dt): + return None + + +def assert_now_in(value, zone): + assert RFC3339_SECONDS.fullmatch(value) + sent = datetime.fromisoformat(value) + assert sent.utcoffset() == datetime.now(zone).utcoffset() + assert abs((datetime.now(zone) - sent).total_seconds()) < 5 @pytest.mark.parametrize( - "value", + ("timezone", "offset"), [ - "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, + ("Asia/Tokyo", "+09:00"), + ("Asia/Kolkata", "+05:30"), + ("UTC", "+00:00"), + (dt_timezone(timedelta(hours=-4)), "-04:00"), + (ZoneInfo("Asia/Shanghai"), "+08:00"), ], ) -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_carries_the_current_time_in_the_timezone(timezone, offset): + recorder = Recorder() + async with async_client(recorder, user_id=PINE_USER_ID) as client: + await client.sessions.send_message(7, "hi", timezone=timezone) + + sent = recorder.body()["client_now_date"] + assert sent.endswith(offset) + assert_now_in(sent, timezone if isinstance(timezone, tzinfo) else ZoneInfo(timezone)) + + +def test_daylight_saving_zone_uses_its_current_offset(): + recorder = Recorder() + with PineAI( + api_key=API_KEY, user_id=PINE_USER_ID, base_url=BASE_URL, http_transport=httpx.MockTransport(recorder), + ) as client: + client.sessions.send_message(7, "hi", timezone="America/New_York") + + assert_now_in(recorder.body()["client_now_date"], ZoneInfo("America/New_York")) @pytest.mark.asyncio -async def test_rest_message_refuses_a_client_now_date_without_offset_before_any_request(): +@pytest.mark.parametrize("timezone", ["Mars/Olympus", "", "../etc/passwd", "EST5EDT/x", 330, NoOffset()]) +async def test_invalid_timezone_is_refused_before_any_request(timezone): 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") + with pytest.raises(ValueError, match="timezone"): + await client.sessions.send_message(7, "hi", timezone=timezone) assert recorder.requests == [] @pytest.mark.asyncio -async def test_rest_message_omits_client_now_date_when_not_given(): +async def test_rest_message_omits_client_now_date_without_timezone(): recorder = Recorder() async with async_client(recorder, user_id=PINE_USER_ID) as client: await client.sessions.send_message(7, "hi") @@ -341,10 +360,8 @@ async def test_rest_message_omits_client_now_date_when_not_given(): assert recorder.body() == {"content": "hi"} -def test_socketio_message_carries_client_now_date_only_when_given(): +def test_socketio_message_carries_client_now_date_only_with_a_timezone(): 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") + assert_now_in(ChatEngine._build_message_data("hi", timezone="Asia/Tokyo")["client_now_date"], ZoneInfo("Asia/Tokyo")) + with pytest.raises(ValueError, match="timezone"): + ChatEngine._build_message_data("hi", timezone="Mars/Olympus") From dbb5db2304eb952c45404a3c8c96914cdb99be83 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Wed, 21 Oct 2026 20:44:52 +0800 Subject: [PATCH 4/5] refactor: name the identity catalog client.settings; clarify docs client.app_settings is now client.settings (SettingsAPI, SyncSettingsAPI); the request path is unchanged. Docs: on update(), an identity without name also replaces a custom name; a call already placed, including its retries, keeps its caller ID, and a change while the session is paused applies when it resumes; the session config is open to managed users and test accounts. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/http-api.md | 5 +++-- docs/sdk-api.md | 13 +++++++------ src/pine_assistant/__init__.py | 8 ++++---- src/pine_assistant/client.py | 6 +++--- .../models/{app_settings.py => settings.py} | 2 +- src/pine_assistant/{app_settings.py => settings.py} | 8 ++++---- tests/test_profile_and_session_config.py | 4 ++-- 8 files changed, 25 insertions(+), 23 deletions(-) rename src/pine_assistant/models/{app_settings.py => settings.py} (91%) rename src/pine_assistant/{app_settings.py => settings.py} (89%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c3cde..0ca1e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Pine API that supports them. Until it is live, these calls return an error. `ValueError` before any request. `ManagedUser.assistant` is a `ManagedUserAssistant` or `None`, and also `None` when the backend does not send it. -- `app_settings.assistant_identities()` returns the `AssistantIdentityCatalog` +- `settings.assistant_identities()` returns the `AssistantIdentityCatalog` from `GET /v2/app/settings/assistance_identities?identities=catalog`. A client with `api_key` alone reads it anonymously. - `sessions.get_config()` and `sessions.update_config(caller_id=...)` read and diff --git a/docs/http-api.md b/docs/http-api.md index 8506255..1a3f872 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -105,7 +105,7 @@ its `greeting_audio_url`. | `assistant` | Result | |---|---| -| `{"identity_id": "agt_..."}` | The assistant gets the identity and the identity's own name. | +| `{"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. | @@ -184,7 +184,8 @@ caller ID. `caller_id` is a session setting that selects it: | `"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 in progress does not change. + 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`. diff --git a/docs/sdk-api.md b/docs/sdk-api.md index c7d6b44..3b09466 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -46,7 +46,7 @@ async with AsyncPineAI(api_key=key, user_id=user.id) as client: | `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. | -| `app_settings.assistant_identities()` | `AssistantIdentityCatalog`: the identities to choose an assistant from. | +| `settings.assistant_identities()` | `AssistantIdentityCatalog`: the identities to choose an assistant from. | `ManagedUser` has `id`, `external_id`, `email`, `name`, `phone`, `assistant` and `created_at`. Pass `id` as `user_id`. `assistant` is a @@ -75,7 +75,7 @@ The assistant identity sets the assistant's voice and its default name. Each user can have one assistant. Without one, the user has no assistant, as before. -1. Read the catalog with `app_settings.assistant_identities()`. A partner +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 @@ -83,7 +83,7 @@ before. | `assistant` | Result | |---|---| -| `{"identity_id": "agt_..."}` | The assistant gets the identity and the identity's own name. | +| `{"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. | @@ -93,7 +93,7 @@ keys or with values that are not strings raises `ValueError` before any request is sent. ```python -catalog = await partner_client.app_settings.assistant_identities() +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"}, @@ -150,7 +150,8 @@ caller ID. `caller_id` is a session setting that selects it: | `"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 in progress does not change. + 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 @@ -241,7 +242,7 @@ from `auth.*`, and `SessionError` from `sessions.list`, `get`, `get_config`, | 429 | `too_many_requests` | Too many requests. | `SessionError` from `sessions.get_config()` and `update_config()`: 403 -`forbidden` for a client that is not a managed user, and 503 +`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). diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index 8abe2c8..a939a6a 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -9,12 +9,10 @@ them. `is_supported_event` tells the two apart. """ -from pine_assistant.app_settings import AppSettingsAPI, SyncAppSettingsAPI 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, PlatformError, SessionError -from pine_assistant.models.app_settings import AssistantIdentity, AssistantIdentityCatalog from pine_assistant.models.auth import AuthIdentity, AuthTicket, RedeemedTicket from pine_assistant.models.events import ( SUPPORTED_EVENTS, @@ -35,6 +33,7 @@ SessionOutcomeRating, SessionOutcomesPage, ) +from pine_assistant.models.settings import AssistantIdentity, AssistantIdentityCatalog from pine_assistant.platform_api import ( ManagedUsersAPI, PlatformAPI, @@ -44,6 +43,7 @@ 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, MANAGED_USER_HEADER __version__ = "0.5.0rc3" @@ -54,8 +54,8 @@ "SyncAuth", "SessionsAPI", "SyncSessionsAPI", - "AppSettingsAPI", - "SyncAppSettingsAPI", + "SettingsAPI", + "SyncSettingsAPI", "PlatformAPI", "SyncPlatformAPI", "ManagedUsersAPI", diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 2278316..44bceec 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -13,7 +13,6 @@ import httpx -from pine_assistant.app_settings import AppSettingsAPI, SyncAppSettingsAPI from pine_assistant.auth import Auth, SyncAuth from pine_assistant.chat import ChatEngine, ChatEvent, Deduplicator, event_from_envelope from pine_assistant.errors import ConnectionError @@ -21,6 +20,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, @@ -147,7 +147,7 @@ def __init__( self.auth = Auth(self.http) self.sessions = SessionsAPI(self.http) self.platform = PlatformAPI(self.http) - self.app_settings = AppSettingsAPI(self.http) + self.settings = SettingsAPI(self.http) self._sio: SocketIOManager | None = None self._chat: ChatEngine | None = None @@ -527,7 +527,7 @@ def __init__( self.auth = SyncAuth(self.http) self.sessions = SyncSessionsAPI(self.http) self.platform = SyncPlatformAPI(self.http) - self.app_settings = SyncAppSettingsAPI(self.http) + self.settings = SyncSettingsAPI(self.http) def __enter__(self) -> "PineAI": return self diff --git a/src/pine_assistant/models/app_settings.py b/src/pine_assistant/models/settings.py similarity index 91% rename from src/pine_assistant/models/app_settings.py rename to src/pine_assistant/models/settings.py index 2eff9bb..d8ca575 100644 --- a/src/pine_assistant/models/app_settings.py +++ b/src/pine_assistant/models/settings.py @@ -1,4 +1,4 @@ -"""App settings models — the assistant identity catalog.""" +"""Settings models — the assistant identity catalog.""" from pydantic import BaseModel, ConfigDict diff --git a/src/pine_assistant/app_settings.py b/src/pine_assistant/settings.py similarity index 89% rename from src/pine_assistant/app_settings.py rename to src/pine_assistant/settings.py index 17492b0..f6245a1 100644 --- a/src/pine_assistant/app_settings.py +++ b/src/pine_assistant/settings.py @@ -1,5 +1,5 @@ """ -App settings — the assistant identity catalog. +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. @@ -12,7 +12,7 @@ from pydantic import ValidationError from pine_assistant.errors import PineAIError -from pine_assistant.models.app_settings import AssistantIdentityCatalog +from pine_assistant.models.settings import AssistantIdentityCatalog from pine_assistant.transport.http import HttpClient, SyncHttpClient _ASSISTANT_IDENTITIES = "/v2/app/settings/assistance_identities" @@ -26,7 +26,7 @@ def _parse_catalog(payload: Any) -> AssistantIdentityCatalog: raise PineAIError("invalid_response", "Pine API returned an invalid assistant identity catalog") from None -class AppSettingsAPI: +class SettingsAPI: def __init__(self, http: HttpClient): self._http = http @@ -36,7 +36,7 @@ async def assistant_identities(self) -> AssistantIdentityCatalog: return _parse_catalog(payload) -class SyncAppSettingsAPI: +class SyncSettingsAPI: def __init__(self, http: SyncHttpClient): self._http = http diff --git a/tests/test_profile_and_session_config.py b/tests/test_profile_and_session_config.py index 83c03be..86cf051 100644 --- a/tests/test_profile_and_session_config.py +++ b/tests/test_profile_and_session_config.py @@ -264,7 +264,7 @@ async def test_get_config_parses_set_and_unset_values(payload, caller_id, extra) async def test_key_only_client_reads_the_catalog_anonymously(): recorder = Recorder() async with async_client(recorder) as client: - catalog = await client.app_settings.assistant_identities() + catalog = await client.settings.assistant_identities() assert isinstance(catalog, AssistantIdentityCatalog) assert catalog.version == "0123456789abcdef" @@ -283,7 +283,7 @@ def test_managed_user_client_reads_the_catalog_as_the_user(): with PineAI( api_key=API_KEY, user_id=PINE_USER_ID, base_url=BASE_URL, http_transport=httpx.MockTransport(recorder), ) as client: - catalog = client.app_settings.assistant_identities() + catalog = client.settings.assistant_identities() assert catalog.identities[0].id == "agt_ava" [request] = recorder.requests From 54947f34a10b868e5c352e3fd1f1c29fea58d158 Mon Sep 17 00:00:00 2001 From: Charlie-Qilin Date: Wed, 21 Oct 2026 22:20:49 +0800 Subject: [PATCH 5/5] refactor(sessions): keep client_now_date instead of timezone Go back to one client_now_date parameter on REST send_message() and on Socket.IO chat()/send_message(): it keeps 0.5.0rc2 code working and matches the HTTP field. A value without a UTC offset still raises ValueError, and validate_client_now_date() is exported again. The tzdata dependency is removed. Docs: managed users have Pine's default assistant until you choose one; the CHANGELOG entry is shorter. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 69 ++++++++---------- docs/http-api.md | 15 +++- docs/sdk-api.md | 39 ++++++---- pyproject.toml | 1 - src/pine_assistant/__init__.py | 3 +- src/pine_assistant/chat.py | 18 ++--- src/pine_assistant/client.py | 13 ++-- src/pine_assistant/sessions.py | 45 ++++++------ tests/test_m1_resources.py | 2 + tests/test_profile_and_session_config.py | 93 ++++++++++-------------- 10 files changed, 145 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca1e8a..94b2e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,49 +9,40 @@ project adheres to [Semantic Versioning](https://semver.org/). Release candidate for 0.5.0. -To upgrade from 0.5.0rc2: +### 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. -- `sessions.send_message()` no longer takes `client_now_date`. Pass the - user's time zone as `timezone` instead, for example - `timezone="America/New_York"`. The SDK sends the current local time itself. -- Everything else is new and needs no change to existing code. - -`update()`, `assistant`, the identity catalog and the session config need the -Pine API that supports them. Until it is live, these calls return an error. - -### Added - -- `platform.managed_users.update(external_id, *, email, name, phone, assistant)` - (`PATCH /platform/v1/managed-users/{external_id}`). Only the given arguments - are sent; `phone=""` removes the phone. Errors raise `PlatformError`. -- `assistant` on `managed_users.create()` and `update()`: a `dict` with - `identity_id`, `name` or both. Other keys or non-string values raise - `ValueError` before any request. `ManagedUser.assistant` is a - `ManagedUserAssistant` or `None`, and also `None` when the backend does not - send it. -- `settings.assistant_identities()` returns the `AssistantIdentityCatalog` - from `GET /v2/app/settings/assistance_identities?identities=catalog`. A - client with `api_key` alone reads it anonymously. -- `sessions.get_config()` and `sessions.update_config(caller_id=...)` read and - set the session's caller ID, `"user"` or `"platform"` - (`GET/PUT /v2/sessions/{id}/config`). Other values raise `ValueError` before - any request. A 503 `session_config_push_failed` is a `SessionError`; the - value is saved. -- `timezone` on Socket.IO `chat()` and `send_message()`: an IANA time zone - name or a `tzinfo`. The message carries the user's current local time. -- `PineAI` has the same new methods. - -### Changed +### Fixed -- `sessions.send_message()` takes `timezone` in place of `client_now_date`. - An unknown time zone raises `ValueError` before any request. On Windows, the - SDK now installs `tzdata` for the time zone names. +- Socket.IO messages sent a time without a UTC offset, so the agent always + used UTC. -### Fixed +### Smaller changes -- Socket.IO messages no longer send the host's local time without an offset - as `client_now_date`. The backend could not parse it, so the agent always - used UTC. A message now carries the time only when you pass `timezone`. +- `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 diff --git a/docs/http-api.md b/docs/http-api.md index 1a3f872..a747a52 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -56,7 +56,7 @@ you send are different. The response `data` is the managed user: `{"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 has no assistant. +`{"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`. @@ -93,8 +93,15 @@ Errors: 400 `invalid_request` (nothing is changed), 401 `unauthorized`, 403 ### Assistant identity -The assistant identity sets the assistant's voice and its default name. Read -the identities from the catalog: +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` @@ -111,7 +118,7 @@ its `greeting_audio_url`. 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` has no assistant. +without `assistant` uses Pine's default assistant. ### Act for a managed user diff --git a/docs/sdk-api.md b/docs/sdk-api.md index 3b09466..f470eb8 100644 --- a/docs/sdk-api.md +++ b/docs/sdk-api.md @@ -51,7 +51,7 @@ async with AsyncPineAI(api_key=key, user_id=user.id) as client: `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 -has no assistant. +uses Pine's default assistant. ### Change a managed user @@ -71,9 +71,13 @@ user = await partner_client.platform.managed_users.update("usr_42", phone="") ### Assistant identity -The assistant identity sets the assistant's voice and its default name. Each -user can have one assistant. Without one, the user has no assistant, as -before. +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 @@ -116,7 +120,7 @@ user = await partner_client.platform.managed_users.update( | `sessions.get(session_id)` | `SessionInfo` | | `sessions.get_config(session_id)` | `SessionConfig` | | `sessions.update_config(session_id, *, caller_id)` | `SessionConfig` after the change | -| `sessions.send_message(session_id, content, ..., timezone=None)` | `SessionMessageStatus` | +| `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` | @@ -167,21 +171,25 @@ await client.sessions.send_message(session["id"], "Call the clinic to book a vis ### The user's time The agent must know the user's time zone to understand words such as -"tomorrow at 9 am". Pass the user's time zone as `timezone` with each -message. The SDK then sends the user's current local time with the message, -as the Pine app does. +"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. -- `timezone` is an IANA time zone name, such as `"America/New_York"`, or a - `tzinfo`. Daylight saving time is handled for you. -- An unknown name raises `ValueError` before any request is sent. -- If a session never got a time zone, the agent uses UTC. After one message - with it, the agent keeps that time zone until a message brings a new one. +- 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 -await client.sessions.send_message(session_id, "Book a table for tomorrow at 7 pm", timezone="America/New_York") +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 `timezone`. +`chat()` and `send_message()` on Socket.IO take the same `client_now_date`. ### Real time (`AsyncPineAI` only) @@ -254,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/pyproject.toml b/pyproject.toml index b141e2b..2facd49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "aiohttp>=3.9.0", "httpx>=0.27.0", "pydantic>=2.0.0", - "tzdata; sys_platform == 'win32'", ] [project.optional-dependencies] diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index a939a6a..a33f840 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -42,7 +42,7 @@ 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 @@ -68,6 +68,7 @@ "MANAGED_USER_HEADER", "validate_external_id", "validate_user_id", + "validate_client_now_date", "AuthIdentity", "AuthTicket", "RedeemedTicket", diff --git a/src/pine_assistant/chat.py b/src/pine_assistant/chat.py index 407dac8..b3db264 100644 --- a/src/pine_assistant/chat.py +++ b/src/pine_assistant/chat.py @@ -9,12 +9,11 @@ import asyncio import time from collections.abc import AsyncGenerator, Callable, Coroutine -from datetime import tzinfo from typing import Any from pine_assistant.models.events import C2SEvent, S2CEvent from pine_assistant.models.form import FormSubmissionResult -from pine_assistant.sessions import client_now_date +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 @@ -140,16 +139,15 @@ def _build_message_data( content: str, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, - timezone: str | tzinfo | None = None, + client_now_date: str | None = None, ) -> dict[str, Any]: data: dict[str, Any] = { "content": content, "attachments": attachments or [], "referenced_sessions": referenced_sessions or [], } - now = client_now_date(timezone) - if now is not None: - data["client_now_date"] = now + if client_now_date is not None: + data["client_now_date"] = validate_client_now_date(client_now_date) return data async def chat( @@ -159,13 +157,13 @@ async def chat( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, - timezone: str | tzinfo | 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, timezone), + self._build_message_data(content, attachments, referenced_sessions, client_now_date), session_id, ) async for event in self._listen( @@ -180,12 +178,12 @@ def send_message( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, - timezone: str | tzinfo | 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, timezone), + 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 44bceec..72ba34c 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -7,7 +7,6 @@ import os import uuid from collections.abc import AsyncGenerator, Callable -from datetime import tzinfo from pathlib import Path from typing import Any @@ -363,7 +362,7 @@ async def chat( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, - timezone: str | tzinfo | 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. @@ -371,8 +370,8 @@ async def chat( Events the SDK does not recognise are yielded unchanged alongside the rest; ignore what you do not handle. - `timezone` is the user's time zone, an IANA name or a `tzinfo`. The - message then carries the user's current local time. + `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 @@ -383,7 +382,7 @@ async def chat( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - timezone=timezone, + client_now_date=client_now_date, turn_timeout=turn_timeout, ): yield event @@ -395,7 +394,7 @@ def send_message( *, attachments: list[dict[str, Any]] | None = None, referenced_sessions: list[dict[str, str]] | None = None, - timezone: str | tzinfo | None = None, + client_now_date: str | None = None, ) -> None: """Send a message without waiting for events (fire-and-forget).""" self._ensure_connected() @@ -403,7 +402,7 @@ def send_message( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - timezone=timezone, + client_now_date=client_now_date, ) async def listen( diff --git a/src/pine_assistant/sessions.py b/src/pine_assistant/sessions.py index fba07cc..20d641e 100644 --- a/src/pine_assistant/sessions.py +++ b/src/pine_assistant/sessions.py @@ -4,9 +4,9 @@ from __future__ import annotations -from datetime import datetime, tzinfo +import re +from datetime import datetime from typing import Any, get_args -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import ValidationError @@ -22,27 +22,30 @@ ) from pine_assistant.transport.http import HttpClient, SyncHttpClient -_TIMEZONE_MESSAGE = 'timezone must be an IANA time zone name such as "America/New_York", or a tzinfo' +_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 _zone(timezone: str | tzinfo) -> tzinfo: - if isinstance(timezone, tzinfo): - return timezone +def _is_calendar_time(text: str) -> bool: try: - return ZoneInfo(timezone) - except (TypeError, ValueError, ZoneInfoNotFoundError): - raise ValueError(_TIMEZONE_MESSAGE) from None + 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``. -def client_now_date(timezone: str | tzinfo | None) -> str | None: - if timezone is None: + 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 - zone = _zone(timezone) - try: - now = datetime.now(zone) - except ValueError: - raise ValueError(_TIMEZONE_MESSAGE) from None - return now.isoformat(timespec="seconds") + 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: @@ -218,7 +221,7 @@ async def send_message( library_artifact_ids: list[str] | None = None, referenced_sessions: list[dict[str, str]] | None = None, quote: dict[str, str] | None = None, - timezone: str | tzinfo | None = None, + client_now_date: str | None = None, request_id: str | None = None, ) -> SessionMessageStatus: """Persist and hand off a new user message without waiting for task completion. @@ -238,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(timezone), + "client_now_date": validate_client_now_date(client_now_date), }.items(): if value is not None: body[key] = value @@ -390,7 +393,7 @@ def send_message( library_artifact_ids: list[str] | None = None, referenced_sessions: list[dict[str, str]] | None = None, quote: dict[str, str] | None = None, - timezone: str | tzinfo | None = None, + client_now_date: str | None = None, request_id: str | None = None, ) -> SessionMessageStatus: """Persist and hand off a new user message without waiting for task completion.""" @@ -405,7 +408,7 @@ def send_message( "library_artifact_ids": library_artifact_ids, "referenced_sessions": referenced_sessions, "quote": quote, - "client_now_date": client_now_date(timezone), + "client_now_date": validate_client_now_date(client_now_date), }.items(): if value is not None: body[key] = value diff --git a/tests/test_m1_resources.py b/tests/test_m1_resources.py index 09d42f9..75180e4 100644 --- a/tests/test_m1_resources.py +++ b/tests/test_m1_resources.py @@ -265,6 +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-18T09:30:00-07:00", request_id="click-18", )) @@ -281,6 +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-18T09:30:00-07:00", } diff --git a/tests/test_profile_and_session_config.py b/tests/test_profile_and_session_config.py index 86cf051..7d3792e 100644 --- a/tests/test_profile_and_session_config.py +++ b/tests/test_profile_and_session_config.py @@ -1,10 +1,6 @@ -"""Managed-user update, assistant, session config, identity catalog and the user's time zone.""" +"""Managed-user update, assistant, session config, identity catalog and client_now_date.""" import json -import re -from datetime import datetime, timedelta, tzinfo -from datetime import timezone as dt_timezone -from zoneinfo import ZoneInfo import httpx import pytest @@ -19,6 +15,7 @@ PlatformError, SessionConfig, SessionError, + validate_client_now_date, ) from pine_assistant.chat import ChatEngine @@ -291,68 +288,52 @@ def test_managed_user_client_reads_the_catalog_as_the_user(): assert request.headers[MANAGED_USER_HEADER] == PINE_USER_ID -RFC3339_SECONDS = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}") - - -class NoOffset(tzinfo): - def utcoffset(self, dt): - return None - - def dst(self, dt): - return None - - -def assert_now_in(value, zone): - assert RFC3339_SECONDS.fullmatch(value) - sent = datetime.fromisoformat(value) - assert sent.utcoffset() == datetime.now(zone).utcoffset() - assert abs((datetime.now(zone) - sent).total_seconds()) < 5 - - @pytest.mark.parametrize( - ("timezone", "offset"), + "value", [ - ("Asia/Tokyo", "+09:00"), - ("Asia/Kolkata", "+05:30"), - ("UTC", "+00:00"), - (dt_timezone(timedelta(hours=-4)), "-04:00"), - (ZoneInfo("Asia/Shanghai"), "+08:00"), + "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", ], ) -@pytest.mark.asyncio -async def test_rest_message_carries_the_current_time_in_the_timezone(timezone, offset): - recorder = Recorder() - async with async_client(recorder, user_id=PINE_USER_ID) as client: - await client.sessions.send_message(7, "hi", timezone=timezone) - - sent = recorder.body()["client_now_date"] - assert sent.endswith(offset) - assert_now_in(sent, timezone if isinstance(timezone, tzinfo) else ZoneInfo(timezone)) +def test_client_now_date_accepts_rfc3339_with_an_offset(value): + assert validate_client_now_date(value) == value -def test_daylight_saving_zone_uses_its_current_offset(): - recorder = Recorder() - with PineAI( - api_key=API_KEY, user_id=PINE_USER_ID, base_url=BASE_URL, http_transport=httpx.MockTransport(recorder), - ) as client: - client.sessions.send_message(7, "hi", timezone="America/New_York") - - assert_now_in(recorder.body()["client_now_date"], ZoneInfo("America/New_York")) +@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 -@pytest.mark.parametrize("timezone", ["Mars/Olympus", "", "../etc/passwd", "EST5EDT/x", 330, NoOffset()]) -async def test_invalid_timezone_is_refused_before_any_request(timezone): +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="timezone"): - await client.sessions.send_message(7, "hi", timezone=timezone) + 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_without_timezone(): +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") @@ -360,8 +341,10 @@ async def test_rest_message_omits_client_now_date_without_timezone(): assert recorder.body() == {"content": "hi"} -def test_socketio_message_carries_client_now_date_only_with_a_timezone(): +def test_socketio_message_carries_client_now_date_only_when_given(): assert ChatEngine._build_message_data("hi") == {"content": "hi", "attachments": [], "referenced_sessions": []} - assert_now_in(ChatEngine._build_message_data("hi", timezone="Asia/Tokyo")["client_now_date"], ZoneInfo("Asia/Tokyo")) - with pytest.raises(ValueError, match="timezone"): - ChatEngine._build_message_data("hi", timezone="Mars/Olympus") + 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")