From bac174093a42f90020211bfe6d6ed8bcc9fa412f Mon Sep 17 00:00:00 2001 From: jasinluo <1127097451@qq.com> Date: Mon, 9 Nov 2026 20:07:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=20=E6=94=AF=E6=8C=81=20systemone?= =?UTF-8?q?=20model=20=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typesafe_sdk: official jev model - custom http: laya --- docs/design/02-systemone-design.md | 291 ++++++++++++++++++ examples/systemone_model/.env | 7 + examples/systemone_model/README.md | 147 +++++++++ examples/systemone_model/config.py | 56 ++++ examples/systemone_model/run.py | 146 +++++++++ pyproject.toml | 5 + requirements-test.txt | 3 + tests/models/systemone/__init__.py | 6 + tests/models/systemone/typesafe/__init__.py | 6 + tests/models/systemone/typesafe/conftest.py | 18 ++ .../models/systemone/typesafe/test_answers.py | 80 +++++ .../systemone/typesafe/test_client_laya.py | 283 +++++++++++++++++ .../typesafe/test_client_typesafe.py | 235 ++++++++++++++ .../systemone/typesafe/test_validate.py | 156 ++++++++++ .../systemone/typesafe/testdata/jev.json | 37 +++ .../systemone/typesafe/testdata/laya.json | 62 ++++ trpc_agent_sdk/models/systemone/__init__.py | 86 ++++++ trpc_agent_sdk/models/systemone/_answers.py | 48 +++ trpc_agent_sdk/models/systemone/_backends.py | 270 ++++++++++++++++ trpc_agent_sdk/models/systemone/_client.py | 198 ++++++++++++ trpc_agent_sdk/models/systemone/_errors.py | 63 ++++ trpc_agent_sdk/models/systemone/_laya.py | 149 +++++++++ trpc_agent_sdk/models/systemone/_validate.py | 196 ++++++++++++ 23 files changed, 2548 insertions(+) create mode 100644 docs/design/02-systemone-design.md create mode 100644 examples/systemone_model/.env create mode 100644 examples/systemone_model/README.md create mode 100644 examples/systemone_model/config.py create mode 100644 examples/systemone_model/run.py create mode 100644 tests/models/systemone/__init__.py create mode 100644 tests/models/systemone/typesafe/__init__.py create mode 100644 tests/models/systemone/typesafe/conftest.py create mode 100644 tests/models/systemone/typesafe/test_answers.py create mode 100644 tests/models/systemone/typesafe/test_client_laya.py create mode 100644 tests/models/systemone/typesafe/test_client_typesafe.py create mode 100644 tests/models/systemone/typesafe/test_validate.py create mode 100644 tests/models/systemone/typesafe/testdata/jev.json create mode 100644 tests/models/systemone/typesafe/testdata/laya.json create mode 100644 trpc_agent_sdk/models/systemone/__init__.py create mode 100644 trpc_agent_sdk/models/systemone/_answers.py create mode 100644 trpc_agent_sdk/models/systemone/_backends.py create mode 100644 trpc_agent_sdk/models/systemone/_client.py create mode 100644 trpc_agent_sdk/models/systemone/_errors.py create mode 100644 trpc_agent_sdk/models/systemone/_laya.py create mode 100644 trpc_agent_sdk/models/systemone/_validate.py diff --git a/docs/design/02-systemone-design.md b/docs/design/02-systemone-design.md new file mode 100644 index 000000000..ff8fb91c8 --- /dev/null +++ b/docs/design/02-systemone-design.md @@ -0,0 +1,291 @@ +# System One 接入设计 + +> **结论摘要**:System One 对一份内容做结构化判断。调用方提交 `state`(待判断的内容)和一组带类型的问题,按问题名取回答案。`SystemOneClient` 用同一套问题对接两个后端:`typesafe` 调官方 API,必须带 API key,并且总会带上模型名;`laya` 调本地 `laya-serve`,key 和模型都可以省略,响应里多一份 checkpoint 路由,并且可以一次提交 1 到 64 条 `state`。目前它是独立客户端,不进入 `LlmAgent` 的对话循环。 + +## 1. 背景与目标 + +System One 对一份内容做结构化判断。调用方提交 `state`(待判断的内容)和一组带类型的问题,按问题名取回答案。 + +仓库需要同时接到两种部署: + +- 官方 TypeSafe API。模型缺省是 `jev-latest`,地址缺省是 `https://api.typesafe.ai`。 +- 本地 Laya HTTP 服务(`laya-serve`)。不指定模型时,由服务端按内容选择 checkpoint(一份已发布的模型检查点)。 + +问题类型、答案读取和失败处理如果各写一套,两边会慢慢对不齐。所以对外只有一个 `SystemOneClient`,传输上的差别留在内部后端。 + +可运行的调用示例在 [examples/systemone_model/README.md](../../examples/systemone_model/README.md)。本文只说明接入为什么这样拆、一次调用经过哪些步骤。 + +### 1.1 目标 + +- **同一组问题打两个后端**:`Choice`、`Score`、`Noul` 的构造和读取方式不变,只换客户端的创建方法。 +- **类型跟官方 SDK 对齐**:问题和答案使用 `typesafe-sdk` 的对象,不再维护一套平行结构。 +- **发出去之前就拦住坏参数**:空内容、空问题、非法地址、带换行的 key 不会变成一次 HTTP 请求。 +- **拿回来之后核对答案**:缺答案、类型不对、选项不在候选里、分数档位数对不上,都视为这次调用失败。 +- **失败可以用同一个异常基类接住**:本地问题和 HTTP 失败都是 `TypeSafeError` 的子类。 +- **依赖可选**:`pip install 'trpc-agent-py[typesafe]'`,对应 `typesafe-sdk>=0.7.5`。未安装时,导入 `trpc_agent_sdk.models.systemone` 会提示安装这个 extra。 + +### 1.2 非目标 + +- 不做流式对话,也不实现成 `OpenAIModel` 那种可放进 `LlmAgent.model` 的对话模型。 +- 不替 Laya 选择 checkpoint,也不重试 Laya 请求。 +- 不跟随 HTTP 重定向。 +- 不给官方 API 补它没有的批量接口。`system_one_batch` 只存在于 Laya。 +- 不把结果自动接进 agent 的工具循环。需要放进 agent 时,由调用方把问题构造和答案读取包进自己的工具。 + +## 2. 方案设计 + +### 2.1 设计原则与总体架构 + +**设计原则** + +1. **一个入口,两个后端**。调用方写一次 `state` 和 `questions`。`SystemOneClient.typesafe` 和 `SystemOneClient.laya` 返回同一种客户端,单次调用都是 `system_one`。 +2. **问题和答案跟官方 SDK 对齐**。公开导出的 `Noul`、`Choice`、`Score` 以及对应答案,就是 SDK 里的那些类。 +3. **检查放在客户端,传输放在后端**。参数校验和答案核对两个后端都走。谁来组 HTTP、要不要重试、有没有批量,由后端决定。 +4. **官方已经做好的传输留给官方客户端**。TypeSafe 的鉴权、模型名、zstd 和重试策略由 `AsyncTypeSafeClient` 处理。Laya 的协议和官方不完全相同,所以单独发 JSON。 +5. **公开名字只从包入口导出**。实现放在 `_` 开头的模块里。原始 SDK 仍可从 `trpc_agent_sdk.models.systemone._typesafe_sdk` 使用,日常调用走 `SystemOneClient`。 + +**总体架构** + +```text +调用方 + state + questions + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SystemOneClient │ +│ 1. 校验 state / questions / timeout / url / api key │ +│ 2. 交给当前后端 │ +│ 3. check_answers:答案类型和取值必须对得上问题 │ +│ 4. timeout > 0 时用 wait_for 包住整次调用 │ +└───────────────┬──────────────────────┬───────────────────┘ + │ │ + ▼ ▼ + TypeSafeBackend LayaHttpBackend + AsyncTypeSafeClient 纯 JSON,httpx2 + POST {base}/v1/systemone POST {base}/v1/systemone + 无批量 POST {base}/v1/systemone/batch + │ │ + ▼ ▼ + SystemOneResponse LayaSystemOneResponse + 官方答案对象 官方答案对象 + routing +``` + +`base` 是去掉末尾斜杠后的服务根地址。根地址上的路径前缀会保留,例如 `https://host/gateway` 会请求 `https://host/gateway/v1/systemone`。 + +### 2.2 一次调用怎么走 + +单次判断: + +```text +system_one(state, questions, model=...) + ├─ state 不能是 None + ├─ questions 至少有一个,问题 id 不能为空白 + ├─ Choice / Score 的 criteria 不能为空 + ├─ backend.system_one(...) + ├─ 每个问题都要有同类型的答案 + └─ 返回 SystemOneResponse +``` + +Laya 批量: + +```text +system_one_batch(states, questions, model=...) + ├─ questions 的检查与单次相同 + ├─ states 是 1 到 64 条,每条都不能是 None + ├─ 当前后端 supports_batch 为 False 时直接失败 + ├─ POST /v1/systemone/batch + ├─ results 的条数必须等于提交的条数 + ├─ 每一条都做答案核对 + └─ 按提交顺序返回响应列表 +``` + +批量 JSON 里的 `total_usage` 不单独返回。调用方使用列表里每一条响应自己的 `usage`。`results` 里多出来的字段仍留在该条响应的原始 HTTP 正文中。 + +超时包在后端调用和答案核对之外。`timeout > 0` 时,`asyncio.wait_for` 到点就抛出 `TypeSafeAPITimeoutError`;HTTP 层使用同一个秒数。调用被取消时仍是 `CancelledError`。`timeout == 0` 表示这次调用没有截止时间,HTTP 层也不设期限。官方 SDK 不接受 `0`,所以这条「没有期限」会写成不设截止时间的 HTTP timeout,再交给它。默认是 30 秒。 + +### 2.3 问题与答案 + +一次请求有两部分: + +- **`state`**:要判断的内容。字符串、字典和 Pydantic 模型都可以。Laya 会把 Pydantic 模型 `model_dump` 成 JSON 再发送。TypeSafe 把原始对象交给官方客户端,由官方客户端序列化,因此图片等 SDK 支持的内容也可以走这条路径。`None` 会在发请求前被拒绝。 +- **`questions`**:问题名到问题对象的映射。答案用同一个名字取回。 + +三种常用问题: + +| 类型 | 问的是什么 | 请求里要给什么 | 答案里读什么 | +|---|---|---|---| +| `Choice` | 从若干标签里选一个 | `criteria` 是「标签 → 说明」。说明可以是 `None` | `choice`:选中的标签;`probabilities`:各标签的概率 | +| `Score` | 按有序标准打分 | `criteria` 是从 0 分开始的说明列表 | `score`:分数;`probabilities` 的个数必须等于标准条数 | +| `Noul` | 是否题 | `instructions` 写出要判断的事。也可以用 `criteria` 说明怎样算「是」、怎样算「否」 | `noul`:0 到 1 的概率。靠近 1 表示是,靠近 0 表示否 | + +同一条工单可以同时问这三件事: + +```python +from trpc_agent_sdk.models.systemone import Choice +from trpc_agent_sdk.models.systemone import Noul +from trpc_agent_sdk.models.systemone import Score +from trpc_agent_sdk.models.systemone import choice_answer +from trpc_agent_sdk.models.systemone import noul_answer +from trpc_agent_sdk.models.systemone import score_answer + +questions = { + "department": Choice( + instructions="Which team should handle this?", + criteria={ + "billing": "invoices, payments, refunds", + "technical": "bugs, outages", + "other": "everything else", + }, + ), + "urgency": Score( + instructions="How urgent is this?", + criteria=["not urgent", "soon", "blocking"], + ), + "refund": Noul(instructions="Does the user request a refund?"), +} + +department = choice_answer(response, "department").choice +urgency = score_answer(response, "urgency").score +refund = noul_answer(response, "refund").noul +``` + +`choice_answer`、`score_answer`、`noul_answer` 按问题名取出对应类型。名字不存在、值是 `None`、或实际类型不对,都会抛出 `SystemOneError`,错误信息里带上这个问题名。响应对象上也可以直接用 `response.choices`、`response.scores`、`response.nouls`,这三个字典只包含对应类型的答案。 + +官方 SDK 还有 `Box`,用来在图片里定位一块区域。校验层认识这种问题和它的答案类型。包的日常导出以 `Choice`、`Score`、`Noul` 为主;要用 `Box` 时,从 `_typesafe_sdk` 取类型。 + +问题也可以写成带 `type` 字段的字典,例如 `{"type": "noul", "instructions": "..."}`。客户端接受字典,也接受上面的问题对象。 + +Laya 的成功响应是 `LayaSystemOneResponse`,在官方字段之外多一个 `routing`: + +- `routing.model`:这次选中的 checkpoint 名称,例如 `english`、`multilingual`。 +- `routing.repo`:提供该 checkpoint 的仓库。服务端没给时是 `None`。 +- 服务端省略整个 `routing` 时,这个字段是 `None`。 + +Laya 正文里还有官方模型未声明的字段,例如每个答案上的 `action`,以及 `usage.truncated`。这些字段留在 `response.raw_http_response` 里,不提升成单独属性,避免客户端跟着服务端的扩展字段一起变。 + +### 2.4 两个后端 + +两个后端都提供 `system_one`。`system_one_batch` 只由 Laya 实现。差别如下。 + +| | TypeSafe | Laya | +|---|---|---| +| 创建方式 | `SystemOneClient.typesafe(...)` | `SystemOneClient.laya(base_url, ...)` | +| 传输 | `AsyncTypeSafeClient` | 自己发 JSON,`Content-Type: application/json` | +| 地址 | 参数、`TYPESAFE_BASE_URL`,否则 SDK 默认地址 | 调用时必须传入 `base_url` | +| API key | 参数或 `TYPESAFE_API_KEY`,官方客户端要求有效 key | 有 key 才发送 `Authorization: Bearer ...`;空 key 不发送 | +| 模型 | 单次 `model` 优先,否则用 `default_model`、`TYPESAFE_DEFAULT_MODEL` 或 `jev-latest` | 单次 `model` 优先于 `default_model`。两者都空时省略 `model`,由 Laya 选 checkpoint | +| 压缩 | SDK 在装了 zstd 时自行压缩请求体 | 明文 JSON,不设置 `Content-Encoding` | +| 重试 | 默认不重试。需要时传入 `RetryPolicy` | 任何失败都不重试 | +| 批量 | `supports_batch = False`,调用即 `SystemOneError` | `POST /v1/systemone/batch`,1 到 64 条 | +| 响应类型 | `SystemOneResponse` | `LayaSystemOneResponse` | +| 请求 ID | 响应头 `x-typesafe-request-id` | 响应头 `X-Request-ID`,写入 `response.request_id` | + +模型名的空值在 Laya 上特意收成「不发送」。单次参数和默认值里,`None` 和 `""` 都省略字段。非字符串会在发请求前失败。 + +TypeSafe 不重试是有意覆盖 SDK 的默认行为。SDK 自己的 `RetryPolicy` 默认会重试;本客户端在调用方没有传入 `retry` 时,传给 SDK 的是 `RetryPolicy(max_retries=0)`。要重试时显式传入策略,例如限流后再试一次。 + +两边都不跟随重定向。客户端自己创建 HTTP 连接时使用 `follow_redirects=False`;调用方传入的 `httpx2.AsyncClient` 在单次请求上也会再次指定不跟随。因此 `307` 会变成对应状态码的 API 错误,不会改去访问 `Location`。 + +HTTP 连接的关闭规则:没传入 `http_client` 时,由本客户端创建,`aclose()` 或离开 `async with` 时关闭。传入的客户端保持打开,方便多个 `SystemOneClient` 共用连接池。 + +### 2.5 校验、超时与错误 + +**发请求之前** + +- `timeout` 必须是非负数字。`bool` 不算合法数字。`0` 表示没有截止时间。 +- `base_url` 必须是带主机名的 `http` 或 `https`。不能带用户名、密码、查询串或片段。路径前缀保留,末尾斜杠去掉。百分号编码不完整的地址会被拒绝。 +- Laya 的 API key 可以空。非空时必须是字符串,且不能包含换行,避免一个 key 变成两行请求头。 +- `state` 不能是 `None`。批量里的每一条同样不能是 `None`。 +- `questions` 不能是空映射。问题名必须是非空白字符串。`instructions` 如果给了,不能是空白字符串。 +- `Choice` 的 `criteria` 必须是非空映射,`Score` 的 `criteria` 必须是非空列表。 + +这些检查失败都是 `SystemOneError`,请求不会发出。 + +**拿到响应之后** + +`check_answers` 按本次提交的问题逐个核对: + +- 每个问题名都要有答案,而且答案类型要一致。`Choice` 不能拿回 `Noul`。 +- `Choice` 的选中标签必须在 `criteria` 里;`probabilities` 里的每个标签也必须在 `criteria` 里。 +- `Score` 的 `probabilities` 个数必须等于 `criteria` 的条数。 +- `Noul` 和 `Box` 核对答案类型。`noul` 的概率范围、框的坐标范围留给服务端返回的数据,客户端不再加一层数值检查。 + +核对失败同样是 `SystemOneError`。这一步在超时计时之内:响应已经返回,但答案对不上,仍算这次调用失败。 + +**HTTP 失败** + +Laya 把非 2xx 状态映射成官方 SDK 的异常,这样调用方不用为两个后端写两套 `except`: + +| 状态码 | 异常 | +|---|---| +| 400 | `TypeSafeBadRequestError` | +| 401 | `TypeSafeAuthenticationError` | +| 403 | `TypeSafePermissionDeniedError` | +| 404 | `TypeSafeNotFoundError` | +| 422 | `TypeSafeUnprocessableEntityError` | +| 429 | `TypeSafeRateLimitError` | +| 500 及以上 | `TypeSafeInternalServerError` | +| 其他非 2xx | `TypeSafeAPIError` | + +Laya 构造这些异常时,传入的说明只有状态码。`str(error)` 里是状态码和请求地址,响应正文留在 `error.body`。正文超过 4 MiB 时截断后再放进 `error.body`。TypeSafe 路径的 HTTP 错误由官方 SDK 生成,说明文字沿用 SDK 的规则。 + +连接失败是 `TypeSafeAPIConnectionError`。HTTP 层超时和 `wait_for` 超时都是 `TypeSafeAPITimeoutError`。 + +`SystemOneError` 继承 `TypeSafeError`,表示请求还没有得到可用结果:参数不合法、响应过大、答案对不上、或者在官方后端上调用了批量。因此: + +```python +from trpc_agent_sdk.models.systemone import SystemOneError +from trpc_agent_sdk.models.systemone import TypeSafeError + +try: + response = await client.system_one(state, questions) +except SystemOneError: + # 参数、批量或答案核对失败 + ... +except TypeSafeError: + # 超时、连接失败、HTTP 状态错误 + ... +``` + +`except TypeSafeError` 单独使用时,也能同时接住 `SystemOneError` 和 HTTP 失败。 + +Laya 读响应时最多读取 4 MiB 加 1 字节。多出来的那 1 字节用来区分「刚好等于上限」和「已经超出上限」。成功正文超出 4 MiB 时抛出 `SystemOneError`。这个上限只加在 Laya 这条自己读取字节的路径上;TypeSafe 的正文由官方客户端读取。 + +### 2.6 包结构 + +```text +trpc_agent_sdk/models/systemone/ +├── __init__.py # 公开导出:客户端、三种问题、答案读取、Laya 路由、错误 +├── _client.py # SystemOneClient:创建后端、校验、超时、批量入口 +├── _backends.py # TypeSafeBackend 与 LayaHttpBackend +├── _laya.py # Laya 的请求编码、响应解码、routing、批量拆分 +├── _validate.py # 地址、key、问题、答案核对 +├── _answers.py # 按问题名读取 Noul / Choice / Score +├── _errors.py # SystemOneError,以及 Laya 的 HTTP 状态映射 +└── _typesafe_sdk.py # 官方 SDK 的再导出;未安装时给出安装提示 +``` + +公开导出集中在 `__init__.py`。调用方从 `trpc_agent_sdk.models.systemone` 导入 `SystemOneClient`、问题类型、答案函数和错误类型。`_typesafe_sdk` 保留给需要官方客户端全部类型的场景,例如 `Box`、同步 `TypeSafeClient`,或直接使用 `AsyncTypeSafeClient`。 + +安装: + +```bash +pip install 'trpc-agent-py[typesafe]' +``` + +## 3. 采用什么、自己做什么 + +官方 SDK 负责 TypeSafe 那一侧已经定义好的协议。本仓库负责把 Laya 接进同一个调用方式,并让两个后端共用参数检查和答案核对。 + +| 事项 | 采用官方 SDK | 本仓库实现 | +|---|---|---| +| `Noul` / `Choice` / `Score` / `Box` 及答案对象 | 是 | 从包入口再导出常用类型 | +| TypeSafe 的 URL、key、模型名、zstd、重试 | 是 | 未传 `retry` 时改成不重试 | +| Laya 的 JSON 请求、省略 key、省略模型 | | `LayaHttpBackend` 与 `_laya.py` | +| Laya 的 `routing` 和批量 | | `LayaSystemOneResponse`、`system_one_batch` | +| 发请求前的参数检查、返回后的答案核对 | | `_validate.py`,两个后端都经过 | +| 按问题名读取一种答案 | | `noul_answer`、`choice_answer`、`score_answer` | +| Laya 响应大小上限、错误正文不进入 `str(error)` | | `_errors.py` 与 Laya 的限长读取 | +| 调用方传入的 HTTP client 保持打开 | | 只有客户端自己创建的连接会在 `aclose` 时关闭 | + +这样,换后端时改的是创建客户端的那一行。问题怎么写、答案怎么读、失败怎么接,保持同一套。 diff --git a/examples/systemone_model/.env b/examples/systemone_model/.env new file mode 100644 index 000000000..9cef044b8 --- /dev/null +++ b/examples/systemone_model/.env @@ -0,0 +1,7 @@ +TYPESAFE_API_KEY= +TYPESAFE_BASE_URL= +TYPESAFE_DEFAULT_MODEL=jev-1.14.0 + +LAYA_BASE_URL=http://127.0.0.1:8000 +LAYA_API_KEY= +LAYA_MODEL= diff --git a/examples/systemone_model/README.md b/examples/systemone_model/README.md new file mode 100644 index 000000000..4fe54cafb --- /dev/null +++ b/examples/systemone_model/README.md @@ -0,0 +1,147 @@ +# System One TypeSafe 与 Laya 示例 + +本示例用同一组客服工单问题,分别调用官方 TypeSafe API 和本地 Laya HTTP 服务。问题类型覆盖 `Choice`、`Score` 和 `Noul`。Laya 路径还会打印 checkpoint 路由,并对两条工单调用 `system_one_batch`。 + +## 关键特性 + +- **同一组问题,两个客户端**:`SystemOneClient.typesafe` 调用官方 API,`SystemOneClient.laya` 调用 `laya-serve` +- **三种答案**:部门用 `Choice`,紧急程度用 `Score`,是否退款用 `Noul`,再用 `choice_answer`、`score_answer`、`noul_answer` 按问题名取出 +- **Laya 可省略鉴权和模型**:不设置 `LAYA_API_KEY` 时不发送 `Authorization`;不设置 `LAYA_MODEL` 时由服务端选择 checkpoint +- **Laya 批量**:`system_one_batch` 一次提交两条工单。官方后端不提供该接口,本示例只在 Laya 路径调用它 +- **缺配置即跳过**:`TYPESAFE_API_KEY` 为空时跳过 TypeSafe,`LAYA_BASE_URL` 为空时跳过 Laya,另一侧仍会运行 + +## 调用结构 + +```text +run.py +├── questions: department (Choice), urgency (Score), refund (Noul) +├── typesafe: SystemOneClient.typesafe -> system_one +└── laya: SystemOneClient.laya -> system_one, system_one_batch + └── response.routing +``` + +关键文件: + +- [examples/systemone_model/config.py](./config.py):读取两个客户端的环境变量 +- [examples/systemone_model/run.py](./run.py):构造问题、调用客户端并打印答案 +- [examples/systemone_model/.env](./.env):环境变量占位 + +## 关键代码解释 + +### 1) 官方 TypeSafe 客户端(`run.py`) + +- `SystemOneClient.typesafe(api_key=..., base_url=..., default_model=...)` 创建客户端 +- `api_key` 来自 `TYPESAFE_API_KEY`。`base_url` 和 `default_model` 留空时,由官方 SDK 使用它自己的默认地址和模型 `jev-latest` +- 用 `async with` 保证客户端关闭 +- 对一条工单调用 `system_one` + +### 2) Laya 客户端(`run.py`) + +- `SystemOneClient.laya(base_url, api_key=..., default_model=...)` 创建客户端 +- `base_url` 是服务根地址,例如 `http://127.0.0.1:8000` +- 单条结果若带 `routing`,打印 checkpoint 名称和仓库 +- 随后用同一组问题调用 `system_one_batch` + +### 3) 读取答案(`run.py`) + +- `choice_answer(response, "department").choice` 是选中的部门标签 +- `score_answer(response, "urgency").score` 是紧急程度分数 +- `noul_answer(response, "refund").noul` 是是否退款的 0/1 答案 + +## 环境要求 + +- Python3.10+,推荐 Python3.12 +- TypeSafe 路径需要可访问官方 API 的 key +- Laya 路径需要已启动的 `laya-serve`。安装项目时已包含 `typesafe-sdk` + +## 构建步骤 + +```bash +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +./build.sh +source .venv/bin/activate +``` + +## 运行步骤 + +### 配置环境变量 + +在 [examples/systemone_model/.env](./.env) 中配置(或通过 `export`): + +| 变量 | 必填 | 说明 | +|------|------|------| +| `TYPESAFE_API_KEY` | TypeSafe 必填 | 官方 API key。为空时跳过 TypeSafe | +| `TYPESAFE_BASE_URL` | 否 | 官方 API 地址。为空时使用 SDK 默认地址 | +| `TYPESAFE_DEFAULT_MODEL` | 否 | 默认模型。为空时使用 `jev-latest` | +| `LAYA_BASE_URL` | Laya 必填 | Laya 服务根地址,例如 `http://127.0.0.1:8000` | +| `LAYA_API_KEY` | 否 | 为空时不发送 `Authorization` | +| `LAYA_MODEL` | 否 | 为空时由 Laya 选择 checkpoint | + +### 运行命令 + +```bash +cd examples/systemone_model +python3 run.py +``` + +## 运行结果(示例) + +下面是一次成功调用时的输出形态。部门标签、分数和 `noul` 的具体取值由服务端模型决定。 + +```text +============================================================ +Backend: typesafe +============================================================ +state: We were billed twice. Please refund the duplicate. +department: billing +urgency: 1.4 +refund: 1 +model: jev-latest +usage: input_tokens=120 output_tokens=0 +---------------------------------------- +============================================================ +Backend: laya +============================================================ +state: We were billed twice. Please refund the duplicate. +department: billing +urgency: 1.7 +refund: 1 +model: laya-rl-agent +usage: input_tokens=300 output_tokens=0 +routing: model=multilingual repo=convaiinnovations/laya-multilingual +--- batch --- +[1/2] +state: We were billed twice. Please refund the duplicate. +department: billing +urgency: 1.7 +refund: 1 +model: laya-rl-agent +usage: input_tokens=300 output_tokens=0 +routing: model=multilingual repo=convaiinnovations/laya-multilingual +[2/2] +state: The dashboard has been down since this morning. +department: technical +urgency: 2.0 +refund: 0 +model: laya-rl-agent +usage: input_tokens=280 output_tokens=0 +routing: model=english repo=convaiinnovations/laya-english +---------------------------------------- +``` + +## 结果分析(是否符合要求) + +跑通后可以核对这几件事: + +- **两个客户端都能回答同一组问题**:TypeSafe 与 Laya 都打印 `department`、`urgency`、`refund` +- **答案类型正确**:部门是 `criteria` 里的标签,紧急程度是分数,退款是 `noul` +- **Laya 带回路由**:`routing` 给出 checkpoint 名称;服务端报告仓库时一并打印 +- **批量条数一致**:`system_one_batch` 的两条结果与提交的两条工单一一对应 +- **缺配置不会拖垮另一条路径**:只配置其中一个后端时,另一条打印 `[SKIP]` + +## 适用场景建议 + +- 验证 System One 问题构造,以及 TypeSafe / Laya 两个客户端的单次调用:适合使用本示例 +- 验证 Laya 的 checkpoint 路由和 `system_one_batch`:适合使用本示例 +- 需要把分类结果接到 `LlmAgent` 工具循环里:本示例只覆盖客户端调用,可把 `run.py` 里的问题构造和答案读取搬进自己的工具函数 diff --git a/examples/systemone_model/config.py b/examples/systemone_model/config.py new file mode 100644 index 000000000..a3275ef06 --- /dev/null +++ b/examples/systemone_model/config.py @@ -0,0 +1,56 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Read System One client settings from the environment.""" + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TypeSafeSettings: + """Settings for ``SystemOneClient.typesafe``.""" + + api_key: str + base_url: str | None + default_model: str | None + + +@dataclass(frozen=True) +class LayaSettings: + """Settings for ``SystemOneClient.laya``.""" + + base_url: str + api_key: str | None + default_model: str | None + + +def typesafe_settings() -> TypeSafeSettings | None: + """Return TypeSafe settings, or ``None`` when ``TYPESAFE_API_KEY`` is empty.""" + api_key = os.getenv("TYPESAFE_API_KEY", "").strip() + if not api_key: + return None + return TypeSafeSettings( + api_key=api_key, + base_url=_optional("TYPESAFE_BASE_URL"), + default_model=_optional("TYPESAFE_DEFAULT_MODEL"), + ) + + +def laya_settings() -> LayaSettings | None: + """Return Laya settings, or ``None`` when ``LAYA_BASE_URL`` is empty.""" + base_url = os.getenv("LAYA_BASE_URL", "").strip() + if not base_url: + return None + return LayaSettings( + base_url=base_url, + api_key=_optional("LAYA_API_KEY"), + default_model=_optional("LAYA_MODEL"), + ) + + +def _optional(name: str) -> str | None: + value = os.getenv(name, "").strip() + return value or None diff --git a/examples/systemone_model/run.py b/examples/systemone_model/run.py new file mode 100644 index 000000000..020977d05 --- /dev/null +++ b/examples/systemone_model/run.py @@ -0,0 +1,146 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Classify the same support ticket with the TypeSafe API and with Laya.""" + +import asyncio + +from dotenv import load_dotenv +from trpc_agent_sdk.models.systemone import Choice +from trpc_agent_sdk.models.systemone import LayaSystemOneResponse +from trpc_agent_sdk.models.systemone import Noul +from trpc_agent_sdk.models.systemone import Score +from trpc_agent_sdk.models.systemone import SystemOneClient +from trpc_agent_sdk.models.systemone import SystemOneResponse +from trpc_agent_sdk.models.systemone import choice_answer +from trpc_agent_sdk.models.systemone import noul_answer +from trpc_agent_sdk.models.systemone import score_answer + +from config import LayaSettings +from config import TypeSafeSettings +from config import laya_settings +from config import typesafe_settings + +load_dotenv() + +QUESTIONS = { + "department": Choice( + instructions="Which team should handle this?", + criteria={ + "billing": "invoices, payments, refunds", + "technical": "bugs, outages", + "other": "everything else", + }, + ), + "urgency": Score( + instructions="How urgent is this?", + criteria=["not urgent", "soon", "blocking"], + ), + "refund": Noul(instructions="Does the user request a refund?"), +} + +TICKET = {"body": "We were billed twice. Please refund the duplicate."} +BATCH_TICKETS = [ + TICKET, + {"body": "The dashboard has been down since this morning."}, +] + + +def print_answers(response: SystemOneResponse) -> None: + """Print the three ticket answers plus model, usage, and Laya routing.""" + print(f"department: {choice_answer(response, 'department').choice}") + print(f"urgency: {score_answer(response, 'urgency').score}") + print(f"refund: {noul_answer(response, 'refund').noul}") + print(f"model: {response.model}") + usage = response.usage + print(f"usage: input_tokens={usage.input_tokens} output_tokens={usage.output_tokens}") + if isinstance(response, LayaSystemOneResponse) and response.routing is not None: + routing = response.routing + repo = f" repo={routing.repo}" if routing.repo else "" + print(f"routing: model={routing.model}{repo}") + + +def print_state(state: dict[str, str]) -> None: + """Print the ticket text that was sent as ``state``.""" + print(f"state: {state['body']}") + + +async def run_typesafe(settings: TypeSafeSettings) -> None: + """Evaluate one ticket on the official TypeSafe API.""" + async with SystemOneClient.typesafe( + api_key=settings.api_key, + base_url=settings.base_url, + default_model=settings.default_model, + ) as client: + response = await client.system_one(TICKET, QUESTIONS) + print_state(TICKET) + print_answers(response) + + +async def run_laya(settings: LayaSettings) -> None: + """Evaluate one ticket, then a two-ticket batch, on a Laya server.""" + async with SystemOneClient.laya( + settings.base_url, + api_key=settings.api_key, + default_model=settings.default_model, + ) as client: + response = await client.system_one(TICKET, QUESTIONS) + batch = await client.system_one_batch(BATCH_TICKETS, QUESTIONS) + print_state(TICKET) + print_answers(response) + print("--- batch ---") + for index, (state, item) in enumerate(zip(BATCH_TICKETS, batch), start=1): + print(f"[{index}/{len(BATCH_TICKETS)}]") + print_state(state) + print_answers(item) + + +async def _run_typesafe() -> bool: + """Run TypeSafe when ``TYPESAFE_API_KEY`` is set.""" + settings = typesafe_settings() + if settings is None: + print("[SKIP] TYPESAFE_API_KEY is not set") + return False + await run_typesafe(settings) + return True + + +async def _run_laya() -> bool: + """Run Laya when ``LAYA_BASE_URL`` is set.""" + settings = laya_settings() + if settings is None: + print("[SKIP] LAYA_BASE_URL is not set") + return False + await run_laya(settings) + return True + + +async def main() -> None: + """Run TypeSafe and Laya, skipping a client whose required setting is missing.""" + attempted = 0 + succeeded = 0 + for label, runner in (("typesafe", _run_typesafe), ("laya", _run_laya)): + print("=" * 60) + print(f"Backend: {label}") + print("=" * 60) + try: + sent = await runner() + except Exception as exc: + print(f"[FAIL] {exc}") + attempted += 1 + continue + if not sent: + continue + attempted += 1 + succeeded += 1 + print("-" * 40) + if attempted == 0: + raise SystemExit("No backend ran. Set TYPESAFE_API_KEY and/or LAYA_BASE_URL.") + if succeeded == 0: + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index f3017edfb..c5934c44b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,6 +140,10 @@ hy-memory = [ "hy-memory>=2.0.6,<3.0.0", ] +typesafe = [ + "typesafe-sdk>=0.7.5", +] + langchain_tool = [ "langchain_tavily", "langchain-core>=0.3.27", @@ -181,6 +185,7 @@ all = [ "e2b-code-interpreter>=2.0.0", "gepa>=0.0.7", "rich>=13.0.0", + "typesafe-sdk>=0.7.5", ] [project.scripts] diff --git a/requirements-test.txt b/requirements-test.txt index b29c62061..c1af31343 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -56,3 +56,6 @@ aiofiles mem0ai>=1.0.3 fastapi mempalace==3.3.4 + +# Test Typesafe(Jev) +typesafe-sdk>=0.7.5 diff --git a/tests/models/systemone/__init__.py b/tests/models/systemone/__init__.py new file mode 100644 index 000000000..eb984da87 --- /dev/null +++ b/tests/models/systemone/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for the System One clients.""" diff --git a/tests/models/systemone/typesafe/__init__.py b/tests/models/systemone/typesafe/__init__.py new file mode 100644 index 000000000..de5e2fb25 --- /dev/null +++ b/tests/models/systemone/typesafe/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for the TypeSafe and Laya System One clients.""" diff --git a/tests/models/systemone/typesafe/conftest.py b/tests/models/systemone/typesafe/conftest.py new file mode 100644 index 000000000..d336b733d --- /dev/null +++ b/tests/models/systemone/typesafe/conftest.py @@ -0,0 +1,18 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Keep TypeSafe environment variables from changing these tests.""" + +import pytest + +pytest.importorskip("typesafe_sdk") + +_ENV_VARS = ("TYPESAFE_API_KEY", "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_LOG_LEVEL") + + +@pytest.fixture(autouse=True) +def _clear_typesafe_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in _ENV_VARS: + monkeypatch.delenv(name, raising=False) diff --git a/tests/models/systemone/typesafe/test_answers.py b/tests/models/systemone/typesafe/test_answers.py new file mode 100644 index 000000000..2c0b7e233 --- /dev/null +++ b/tests/models/systemone/typesafe/test_answers.py @@ -0,0 +1,80 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. + +import json + +import httpx2 +import pytest + +from trpc_agent_sdk.models.systemone import SystemOneError +from trpc_agent_sdk.models.systemone import SystemOneResponse +from trpc_agent_sdk.models.systemone import choice_answer +from trpc_agent_sdk.models.systemone import noul_answer +from trpc_agent_sdk.models.systemone import score_answer + + +def _response(answers: dict) -> SystemOneResponse: + raw = json.dumps({ + "model": "test", + "answers": answers, + "usage": { + "input_tokens": 0, + "output_tokens": 0 + }, + }).encode() + http = httpx2.Response( + 200, + content=raw, + headers={"x-typesafe-request-id": "req"}, + request=httpx2.Request("POST", "https://example.test/v1/systemone"), + ) + return SystemOneResponse.from_http_response(http) + + +def test_reads_each_answer_by_name(): + response = _response({ + "refund": { + "type": "noul", + "noul": 0.25 + }, + "route": { + "type": "choice", + "choice": "billing", + "confidence": 0.5, + "probabilities": { + "billing": 0.7, + "other": 0.3 + }, + }, + "urgency": { + "type": "score", + "score": 1.0, + "confidence": 0.4, + "legend": { + "0": "low", + "1": "high" + }, + "probabilities": { + "0": 0.2, + "1": 0.8 + }, + }, + }) + assert noul_answer(response, "refund").noul == 0.25 + assert choice_answer(response, "route").choice == "billing" + assert score_answer(response, "urgency").score == 1.0 + + +def test_missing_answer_names_the_id(): + response = _response({"refund": {"type": "noul", "noul": 0}}) + with pytest.raises(SystemOneError, match='answer "missing" not found'): + choice_answer(response, "missing") + + +def test_wrong_type_names_the_id(): + response = _response({"refund": {"type": "noul", "noul": 0}}) + with pytest.raises(SystemOneError, match='answer "refund": expected ChoiceAnswer, got NoulAnswer'): + choice_answer(response, "refund") diff --git a/tests/models/systemone/typesafe/test_client_laya.py b/tests/models/systemone/typesafe/test_client_laya.py new file mode 100644 index 000000000..4c69130da --- /dev/null +++ b/tests/models/systemone/typesafe/test_client_laya.py @@ -0,0 +1,283 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. + +import asyncio +import json +from pathlib import Path + +import httpx2 +import pytest +from typesafe_sdk import TypeSafeAPIConnectionError +from typesafe_sdk import TypeSafeAPIError +from typesafe_sdk import TypeSafeAPITimeoutError +from typesafe_sdk import TypeSafeInternalServerError +from typesafe_sdk import TypeSafeRateLimitError + +from trpc_agent_sdk.models.systemone import Choice +from trpc_agent_sdk.models.systemone import Noul +from trpc_agent_sdk.models.systemone import Score +from trpc_agent_sdk.models.systemone import SystemOneClient +from trpc_agent_sdk.models.systemone import SystemOneError +from trpc_agent_sdk.models.systemone import noul_answer +from trpc_agent_sdk.models.systemone._errors import MAX_RESPONSE_BYTES + +_FIXTURES = Path(__file__).parent / "testdata" +_URL = "https://systemone.test" +_FIXTURE = (_FIXTURES / "laya.json").read_bytes() + + +def _questions(): + return { + "route": Choice(instructions="Which team?", criteria={ + "z_billing": "Payments", + "a_other": None + }), + "severity": Score(instructions="Rate urgency", criteria=["low", "medium", "high"]), + "refund": Noul(instructions="Is a refund requested?"), + } + + +def _noul(): + return {"q": Noul(instructions="Is this a greeting?")} + + +def _noul_body(noul=0): + return json.dumps({ + "model": "english", + "answers": { + "q": { + "type": "noul", + "noul": noul + } + }, + "usage": { + "input_tokens": 1, + "output_tokens": 0 + }, + "routing": { + "model": "english", + "repo": "example/english" + }, + }).encode() + + +def _ok(status=200, content=None, headers=None): + return httpx2.Response(status, content=_noul_body() if content is None else content, headers=headers) + + +async def test_fixture_round_trip(): + seen = {} + + def handler(request): + seen["method"] = request.method + seen["path"] = request.url.path + seen["headers"] = request.headers + seen["body"] = json.loads(request.content) + return httpx2.Response(200, content=_FIXTURE, headers={"X-Request-ID": "req-123"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL + "/gateway/", api_key="test-key", default_model="english", http_client=http) + response = await client.system_one( + {"message": "我被重复扣款了"}, + _questions(), + model="explicit-model", + ) + assert seen["method"] == "POST" + assert seen["path"] == "/gateway/v1/systemone" + assert seen["headers"]["Authorization"] == "Bearer test-key" + assert seen["headers"]["Content-Type"] == "application/json" + assert seen["headers"].get("Content-Encoding") is None + assert seen["body"]["model"] == "explicit-model" + assert list(seen["body"]["questions"]["route"]["criteria"]) == ["z_billing", "a_other"] + assert response.request_id == "req-123" + assert response.raw_http_response.content == _FIXTURE + assert response.routing.model == "multilingual" + assert response.choices["route"].choice == "z_billing" + assert response.scores["severity"].score == 1.7 + assert response.nouls["refund"].noul == 0 + + +async def test_omits_key_and_model(): + seen = {} + + def handler(request): + seen["authorization"] = request.headers.get("Authorization") + seen["encoding"] = request.headers.get("Content-Encoding") + seen["body"] = json.loads(request.content) + return _ok(headers={"X-Request-ID": "req"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + response = await client.system_one("hello", _noul()) + assert seen["authorization"] is None + assert seen["encoding"] is None + assert "model" not in seen["body"] + assert noul_answer(response, "q").noul == 0 + + +async def test_errors_are_not_retried_and_hide_the_body(): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + if calls == 1: + return _ok(429, content=b'{"detail":"sensitive error data"}', headers={"X-Request-ID": "failed"}) + return _ok(503, content=b'{"detail":"sensitive server data"}', headers={"X-Request-ID": "down"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(TypeSafeRateLimitError) as limited: + await client.system_one("hello", _noul()) + with pytest.raises(TypeSafeInternalServerError) as failed: + await client.system_one("hello", _noul()) + assert calls == 2 + assert limited.value.status == 429 + assert failed.value.status == 503 + assert "sensitive" not in str(limited.value) + assert "sensitive" not in str(failed.value) + + +async def test_redirect_is_not_followed(): + calls = [] + + def handler(request): + calls.append(request.url.path) + if request.url.path == "/v1/systemone": + return httpx2.Response(307, headers={"location": _URL + "/secret"}) + return _ok() + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler), follow_redirects=True) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(TypeSafeAPIError) as raised: + await client.system_one("hello", _noul()) + assert raised.value.status == 307 + assert calls == ["/v1/systemone"] + + +async def test_response_size_limit(): + + def oversized(request): + return _ok(content=b" " * (MAX_RESPONSE_BYTES + 1)) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(oversized)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(SystemOneError, match="response exceeds") as raised: + await client.system_one("hello", _noul()) + assert not isinstance(raised.value, TypeSafeAPIError) + + +async def test_error_body_is_capped(): + + def handler(request): + return _ok(503, content=b"y" * (MAX_RESPONSE_BYTES + 1), headers={"X-Request-ID": "too-big"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(TypeSafeInternalServerError) as raised: + await client.system_one("hello", _noul()) + body = raised.value.body + assert isinstance(body, str) + assert len(body) == MAX_RESPONSE_BYTES + assert "y" * 8 not in str(raised.value) + + +async def test_connection_error_is_not_retried(): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + raise httpx2.ConnectError("connection failed") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(TypeSafeAPIConnectionError) as raised: + await client.system_one("hello", _noul()) + assert calls == 1 + assert isinstance(raised.value.__cause__, httpx2.ConnectError) + + +async def test_timeout_and_cancellation(): + + async def handler(request): + await asyncio.sleep(5) + return _ok(headers={"X-Request-ID": "req"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL, timeout=0.05, http_client=http) + with pytest.raises(TypeSafeAPITimeoutError): + await client.system_one("hello", _noul()) + + client = SystemOneClient.laya(_URL, timeout=30, http_client=http) + task = asyncio.create_task(client.system_one("hello", _noul())) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_external_client_stays_open(): + http = httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda request: _ok(headers={"X-Request-ID": "req"})), + timeout=None, + ) + client = SystemOneClient.laya(_URL, http_client=http) + await client.aclose() + assert http.is_closed is False + response = await client.system_one("hello", _noul()) + assert noul_answer(response, "q").noul == 0 + await http.aclose() + + +async def test_owned_client_closes(): + async with SystemOneClient.laya(_URL) as client: + http = client._backend._http + assert http.is_closed is False + assert http.is_closed is True + + +async def test_batch_round_trip_and_local_limits(): + seen = {} + + def handler(request): + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + item = json.loads(_noul_body(0.4)) + return httpx2.Response( + 200, + json={ + "results": [item, item], + "total_usage": { + "input_tokens": 2, + "output_tokens": 0 + } + }, + headers={"X-Request-ID": "batch-1"}, + ) + + calls = 0 + + def counting(request): + nonlocal calls + calls += 1 + return handler(request) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(counting)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(SystemOneError, match="between 1 and 64"): + await client.system_one_batch([], _noul()) + with pytest.raises(SystemOneError, match="between 1 and 64"): + await client.system_one_batch(["x"] * 65, _noul()) + assert calls == 0 + responses = await client.system_one_batch(["one", "two"], _noul()) + assert seen["path"] == "/v1/systemone/batch" + assert seen["body"]["states"] == ["one", "two"] + assert "model" not in seen["body"] + assert len(responses) == 2 + assert [item.routing.model for item in responses] == ["english", "english"] + assert [noul_answer(item, "q").noul for item in responses] == [0.4, 0.4] + assert responses[0].request_id == "batch-1" diff --git a/tests/models/systemone/typesafe/test_client_typesafe.py b/tests/models/systemone/typesafe/test_client_typesafe.py new file mode 100644 index 000000000..5c632c4fa --- /dev/null +++ b/tests/models/systemone/typesafe/test_client_typesafe.py @@ -0,0 +1,235 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. + +import asyncio +import json +from pathlib import Path + +import httpx2 +import pytest +from typesafe_sdk import TypeSafeAPIConnectionError +from typesafe_sdk import TypeSafeAPITimeoutError +from typesafe_sdk import TypeSafeRateLimitError + +from trpc_agent_sdk.models.systemone import Choice +from trpc_agent_sdk.models.systemone import Noul +from trpc_agent_sdk.models.systemone import RetryPolicy +from trpc_agent_sdk.models.systemone import Score +from trpc_agent_sdk.models.systemone import SystemOneClient +from trpc_agent_sdk.models.systemone import SystemOneError +from trpc_agent_sdk.models.systemone import TypeSafeAPIError + +_FIXTURES = Path(__file__).parent / "testdata" +_URL = "https://systemone.test" +_FIXTURE = (_FIXTURES / "jev.json").read_bytes() + + +def _questions(): + return { + "route": Choice(instructions="Which team?", criteria={ + "z_billing": "Payments", + "a_other": None + }), + "severity": Score(instructions="Rate urgency", criteria=["low", "medium", "high"]), + "refund": Noul(instructions="Is a refund requested?"), + } + + +def _noul(): + return {"q": Noul(instructions="Is this a greeting?")} + + +def _ok(status=200, content=None, headers=None): + return httpx2.Response(status, content=_FIXTURE if content is None else content, headers=headers) + + +def _noul_body(): + return json.dumps({ + "model": "jev-latest", + "answers": { + "q": { + "type": "noul", + "noul": 0 + } + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0 + }, + }).encode() + + +async def test_fixture_round_trip(): + seen = {} + + def handler(request): + seen["method"] = request.method + seen["path"] = request.url.path + seen["headers"] = request.headers + seen["body"] = json.loads(request.content) + return _ok(headers={"x-typesafe-request-id": "req-123"}) + + transport = httpx2.MockTransport(handler) + async with httpx2.AsyncClient(transport=transport, follow_redirects=False) as http: + client = SystemOneClient.typesafe( + api_key="test-key", + base_url=_URL + "/gateway/", + default_model="default-model", + http_client=http, + ) + response = await client.system_one( + { + "message": "我被重复扣款了", + "retries": 2 + }, + _questions(), + model="explicit-model", + ) + assert seen["method"] == "POST" + assert seen["path"] == "/gateway/v1/systemone" + assert seen["headers"]["Authorization"] == "Bearer test-key" + assert seen["headers"]["Content-Type"].startswith("application/json") + assert seen["headers"]["Accept"] == "application/json" + assert seen["body"]["model"] == "explicit-model" + assert seen["body"]["state"]["message"] == "我被重复扣款了" + assert list(seen["body"]["questions"]["route"]["criteria"]) == ["z_billing", "a_other"] + assert response.request_id == "req-123" + assert response.raw_http_response.content == _FIXTURE + assert response.choices["route"].choice == "z_billing" + assert response.scores["severity"].score == 1.7 + assert response.nouls["refund"].noul == 0 + + +async def test_default_model_is_sent_when_the_call_omits_one(): + seen = {} + + def handler(request): + seen["body"] = json.loads(request.content) + return _ok(content=_noul_body(), headers={"x-typesafe-request-id": "req"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.typesafe(api_key="test-key", + base_url=_URL, + default_model="jev-latest", + http_client=http) + await client.system_one("hello", _noul()) + assert seen["body"]["model"] == "jev-latest" + + +async def test_http_error_is_not_retried_unless_asked(): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + return _ok(429, content=b'{"detail":"nope"}', headers={"x-typesafe-request-id": "failed"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, http_client=http) + with pytest.raises(TypeSafeRateLimitError) as raised: + await client.system_one("hello", _noul()) + assert calls == 1 + assert raised.value.status == 429 + assert raised.value.request_id == "failed" + + calls = 0 + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.typesafe( + api_key="test-key", + base_url=_URL, + retry=RetryPolicy(max_retries=1, backoff_initial=0, backoff_max=0), + http_client=http, + ) + with pytest.raises(TypeSafeRateLimitError): + await client.system_one("hello", _noul()) + assert calls == 2 + + +async def test_redirect_is_not_followed(): + calls = [] + + def handler(request): + calls.append(request.url.path) + return httpx2.Response(307, headers={"location": _URL + "/secret"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler), follow_redirects=False) as http: + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, http_client=http) + with pytest.raises(TypeSafeAPIError) as raised: + await client.system_one("hello", _noul()) + assert raised.value.status == 307 + assert calls == ["/v1/systemone"] + + +async def test_connection_error_is_not_retried(): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + raise httpx2.ConnectError("connection failed") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, http_client=http) + with pytest.raises(TypeSafeAPIConnectionError) as raised: + await client.system_one("hello", _noul()) + assert calls == 1 + assert isinstance(raised.value.__cause__, httpx2.ConnectError) + + +async def test_timeout_and_cancellation(): + + async def handler(request): + await asyncio.sleep(5) + return _ok(content=_noul_body(), headers={"x-typesafe-request-id": "req"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, timeout=0.05, http_client=http) + with pytest.raises(TypeSafeAPITimeoutError): + await client.system_one("hello", _noul()) + + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, timeout=30, http_client=http) + task = asyncio.create_task(client.system_one("hello", _noul())) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_external_client_stays_open(): + http = httpx2.AsyncClient( + transport=httpx2.MockTransport( + lambda request: _ok(content=_noul_body(), headers={"x-typesafe-request-id": "req"})), + timeout=None, + ) + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, http_client=http) + await client.aclose() + assert http.is_closed is False + response = await client.system_one("hello", _noul()) + assert response.nouls["q"].noul == 0 + await http.aclose() + + +async def test_owned_client_closes_and_does_not_follow_redirects(): + async with SystemOneClient.typesafe(api_key="test-key", base_url=_URL) as client: + http = client._backend._typesafe_client._http_client + assert http.follow_redirects is False + assert http.is_closed is False + assert http.is_closed is True + + +async def test_batch_is_rejected(): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + return _ok(content=_noul_body()) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.typesafe(api_key="test-key", base_url=_URL, http_client=http) + with pytest.raises(SystemOneError, match="batch is only supported by laya"): + await client.system_one_batch(["hello"], _noul()) + assert calls == 0 diff --git a/tests/models/systemone/typesafe/test_validate.py b/tests/models/systemone/typesafe/test_validate.py new file mode 100644 index 000000000..f29309884 --- /dev/null +++ b/tests/models/systemone/typesafe/test_validate.py @@ -0,0 +1,156 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. + +import json + +import httpx2 +import pytest + +from trpc_agent_sdk.models.systemone import Choice +from trpc_agent_sdk.models.systemone import Noul +from trpc_agent_sdk.models.systemone import Score +from trpc_agent_sdk.models.systemone import SystemOneClient +from trpc_agent_sdk.models.systemone import SystemOneError +from trpc_agent_sdk.models.systemone import SystemOneResponse +from trpc_agent_sdk.models.systemone._validate import check_answers + +_URL = "https://systemone.test" +_BASE_URLS = [ + "", + "localhost:8000", + "ftp://host", + "https://", + "https://host/%zz", + "https://user:secret@host", + "https://host?q=x", + "https://host?", + "https://host#x", + "https://host#", +] + + +def _response(answers: dict) -> SystemOneResponse: + raw = json.dumps({ + "model": "test", + "answers": answers, + "usage": { + "input_tokens": 0, + "output_tokens": 0 + }, + }).encode() + http = httpx2.Response( + 200, + content=raw, + headers={"x-typesafe-request-id": "req"}, + request=httpx2.Request("POST", _URL + "/v1/systemone"), + ) + return SystemOneResponse.from_http_response(http) + + +def _noul_body() -> bytes: + return json.dumps({ + "model": "test", + "answers": { + "q": { + "type": "noul", + "noul": 0 + } + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0 + }, + }).encode() + + +@pytest.mark.parametrize("base_url", _BASE_URLS) +def test_invalid_base_url(base_url): + with pytest.raises(SystemOneError, match="invalid base url"): + SystemOneClient.laya(base_url) + with pytest.raises(SystemOneError, match="invalid base url"): + SystemOneClient.typesafe(api_key="test-key", base_url=base_url) + + +def test_invalid_timeout(): + with pytest.raises(SystemOneError, match="timeout"): + SystemOneClient.laya(_URL, timeout=-1) + with pytest.raises(SystemOneError, match="timeout"): + SystemOneClient.laya(_URL, timeout=True) + + +async def test_invalid_questions_do_not_reach_the_server(): + calls = 0 + + def handler(request): + nonlocal calls + calls += 1 + return httpx2.Response(200, content=_noul_body()) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + client = SystemOneClient.laya(_URL, http_client=http) + with pytest.raises(SystemOneError, match="state is required"): + await client.system_one(None, {"q": Noul(instructions="judge")}) + with pytest.raises(SystemOneError, match="at least one question"): + await client.system_one("hello", {}) + with pytest.raises(SystemOneError, match="instructions must not be blank"): + await client.system_one("hello", {"q": {"type": "noul", "instructions": " "}}) + with pytest.raises(SystemOneError, match="question id must not be blank"): + await client.system_one("hello", {" ": Noul(instructions="judge")}) + with pytest.raises(SystemOneError, match="criteria must not be empty"): + await client.system_one("hello", {"q": {"type": "choice", "instructions": "choose", "criteria": {}}}) + assert calls == 0 + + +def test_answer_mismatches(): + choice = {"q": Choice(instructions="choose", criteria={"a": None, "b": None})} + with pytest.raises(SystemOneError, match="not found"): + check_answers(choice, _response({})) + with pytest.raises(SystemOneError, match="expected choice, got noul"): + check_answers(choice, _response({"q": {"type": "noul", "noul": 1}})) + with pytest.raises(SystemOneError, match="choice 'z' is not in criteria"): + check_answers( + choice, + _response({"q": { + "type": "choice", + "choice": "z", + "confidence": 1, + "probabilities": { + "a": 1 + }, + }}), + ) + with pytest.raises(SystemOneError, match="probability 'z' is not in criteria"): + check_answers( + choice, + _response( + {"q": { + "type": "choice", + "choice": "a", + "confidence": 1, + "probabilities": { + "a": 0.5, + "z": 0.5 + }, + }}), + ) + score = {"q": Score(instructions="rate", criteria=["low", "high"])} + with pytest.raises(SystemOneError, match="expected 2 score levels, got 1"): + check_answers( + score, + _response({ + "q": { + "type": "score", + "score": 0, + "confidence": 1, + "legend": { + "0": "low" + }, + "probabilities": { + "0": 1 + }, + } + }), + ) diff --git a/tests/models/systemone/typesafe/testdata/jev.json b/tests/models/systemone/typesafe/testdata/jev.json new file mode 100644 index 000000000..24ec6b7fd --- /dev/null +++ b/tests/models/systemone/typesafe/testdata/jev.json @@ -0,0 +1,37 @@ +{ + "model": "jev-1.13.0", + "answers": { + "route": { + "type": "choice", + "choice": "z_billing", + "confidence": 0.6, + "probabilities": { + "z_billing": 0.8, + "a_other": 0.2 + } + }, + "severity": { + "type": "score", + "score": 1.7, + "confidence": 0.55, + "probabilities": { + "0": 0, + "1": 0.3, + "2": 0.7 + }, + "legend": { + "2": "high", + "0": "low", + "1": "medium" + } + }, + "refund": { + "type": "noul", + "noul": 0 + } + }, + "usage": { + "input_tokens": 100, + "output_tokens": 0 + } +} diff --git a/tests/models/systemone/typesafe/testdata/laya.json b/tests/models/systemone/typesafe/testdata/laya.json new file mode 100644 index 000000000..e70bf6df2 --- /dev/null +++ b/tests/models/systemone/typesafe/testdata/laya.json @@ -0,0 +1,62 @@ +{ + "model": "laya-rl-agent", + "answers": { + "route": { + "type": "choice", + "choice": "z_billing", + "confidence": 0.2781, + "answer_confidence": 0.8, + "probabilities": { + "z_billing": 0.8, + "a_other": 0.2 + }, + "action": { + "act_probability": 0.92 + }, + "abstention": "passed", + "abstention_threshold": 0.7 + }, + "severity": { + "type": "score", + "score": 1.7, + "confidence": 0.4439, + "probabilities": { + "0": 0, + "1": 0.3, + "2": 0.7 + }, + "legend": { + "2": "high", + "0": "low", + "1": "medium" + }, + "action": { + "act_probability": 0.91 + } + }, + "refund": { + "type": "noul", + "noul": 0, + "confidence": 1, + "action": { + "act_probability": 0.99 + }, + "future_extension": { + "value": true + } + } + }, + "usage": { + "input_tokens": 300, + "output_tokens": 0, + "truncated": true, + "state_tokens_dropped": 5, + "truncated_questions": [ + "severity" + ] + }, + "routing": { + "model": "multilingual", + "repo": "convaiinnovations/laya-multilingual" + } +} diff --git a/trpc_agent_sdk/models/systemone/__init__.py b/trpc_agent_sdk/models/systemone/__init__.py new file mode 100644 index 000000000..0a10f2bb9 --- /dev/null +++ b/trpc_agent_sdk/models/systemone/__init__.py @@ -0,0 +1,86 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""System One client for the official TypeSafe API and for Laya HTTP. + +``SystemOneClient.typesafe`` calls the official API. It requires an API key +and always sends a model. ``SystemOneClient.laya`` calls a ``laya-serve`` +process. It can omit both, which lets Laya choose the checkpoint and accept +anonymous local requests. + +Questions and answers use the official SDK types. Read one answer with +``noul_answer``, ``choice_answer``, or ``score_answer``. A Laya response also +carries ``routing``. ``system_one_batch`` is only available on the Laya +backend. Import the raw SDK from ``typesafe_sdk``. + +Example:: + + from trpc_agent_sdk.models.systemone import Choice + from trpc_agent_sdk.models.systemone import Noul + from trpc_agent_sdk.models.systemone import Score + from trpc_agent_sdk.models.systemone import SystemOneClient + + async with SystemOneClient.laya("http://127.0.0.1:8000") as client: + response = await client.system_one( + {"body": "We were billed twice. Please refund the duplicate."}, + { + "department": Choice( + instructions="Which team should handle this?", + criteria={ + "billing": "invoices, payments, refunds", + "technical": "bugs, outages", + "other": "everything else", + }, + ), + "urgency": Score( + instructions="How urgent is this?", + criteria=["not urgent", "soon", "blocking"], + ), + "refund": Noul(instructions="Does the user request a refund?"), + }, + ) +""" + +from typesafe_sdk import Choice +from typesafe_sdk import ChoiceAnswer +from typesafe_sdk import Noul +from typesafe_sdk import NoulAnswer +from typesafe_sdk import NoulCriteria +from typesafe_sdk import RetryPolicy +from typesafe_sdk import Score +from typesafe_sdk import ScoreAnswer +from typesafe_sdk import SystemOneResponse +from typesafe_sdk import TypeSafeAPIError +from typesafe_sdk import TypeSafeError +from typesafe_sdk import Usage +from ._answers import choice_answer +from ._answers import noul_answer +from ._answers import score_answer +from ._client import SystemOneClient +from ._errors import SystemOneError +from ._laya import LayaRouting +from ._laya import LayaSystemOneResponse + +__all__ = [ + "Choice", + "ChoiceAnswer", + "LayaRouting", + "LayaSystemOneResponse", + "Noul", + "NoulAnswer", + "NoulCriteria", + "RetryPolicy", + "Score", + "ScoreAnswer", + "SystemOneClient", + "SystemOneError", + "SystemOneResponse", + "TypeSafeAPIError", + "TypeSafeError", + "Usage", + "choice_answer", + "noul_answer", + "score_answer", +] diff --git a/trpc_agent_sdk/models/systemone/_answers.py b/trpc_agent_sdk/models/systemone/_answers.py new file mode 100644 index 000000000..319f193d2 --- /dev/null +++ b/trpc_agent_sdk/models/systemone/_answers.py @@ -0,0 +1,48 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Look up one typed answer by the question name sent in the request.""" + +from typing import TypeVar + +from typesafe_sdk import ChoiceAnswer +from typesafe_sdk import NoulAnswer +from typesafe_sdk import ScoreAnswer +from typesafe_sdk import SystemOneResponse + +from ._errors import SystemOneError + +_AnswerT = TypeVar("_AnswerT", NoulAnswer, ChoiceAnswer, ScoreAnswer) + + +def noul_answer(response: SystemOneResponse, name: str) -> NoulAnswer: + """Return the yes/no answer stored for ``name``.""" + return _typed_answer(response, name, NoulAnswer) + + +def choice_answer(response: SystemOneResponse, name: str) -> ChoiceAnswer: + """Return the choice answer stored for ``name``.""" + return _typed_answer(response, name, ChoiceAnswer) + + +def score_answer(response: SystemOneResponse, name: str) -> ScoreAnswer: + """Return the score answer stored for ``name``.""" + return _typed_answer(response, name, ScoreAnswer) + + +def _typed_answer(response: SystemOneResponse, name: str, expected: type[_AnswerT]) -> _AnswerT: + """Return the answer stored for ``name`` when it has type ``expected``. + + A missing answer, ``None``, or a value of another type raises ``SystemOneError``. + """ + answers = response.answers + if not isinstance(answers, dict) or name not in answers: + raise SystemOneError(f'systemone: answer "{name}" not found') + value = answers[name] + if value is None: + raise SystemOneError(f'systemone: answer "{name}" is nil') + if not isinstance(value, expected): + raise SystemOneError(f'systemone: answer "{name}": expected {expected.__name__}, got {type(value).__name__}') + return value diff --git a/trpc_agent_sdk/models/systemone/_backends.py b/trpc_agent_sdk/models/systemone/_backends.py new file mode 100644 index 000000000..0c5743984 --- /dev/null +++ b/trpc_agent_sdk/models/systemone/_backends.py @@ -0,0 +1,270 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""HTTP backends for the official TypeSafe API and for Laya.""" + +from collections.abc import Mapping +from collections.abc import Sequence +from typing import Optional + +import httpx2 +from typesafe_sdk import AsyncTypeSafeClient +from typesafe_sdk import Question +from typesafe_sdk import RetryPolicy +from typesafe_sdk import SystemOneResponse +from typesafe_sdk import TypeSafeAPIConnectionError +from typesafe_sdk import TypeSafeAPITimeoutError +from typesafe_sdk import TypeSafeError + +from ._errors import MAX_RESPONSE_BYTES +from ._errors import SystemOneError +from ._errors import raise_for_status +from ._laya import batch_body +from ._laya import batch_endpoint +from ._laya import decode_batch +from ._laya import decode_response +from ._laya import endpoint +from ._laya import request_body +from ._validate import json_ready_state + +_JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json"} + + +class TypeSafeBackend: + """Call the official API through ``AsyncTypeSafeClient``. + + The official client always sends an API key and a model, and it chooses + zstd when that encoding is installed. Those choices are what this backend + keeps. An HTTP client supplied by the caller is not closed here, because + closing the SDK client closes that client too. + """ + + # The official API has no batch route. ``SystemOneClient.system_one_batch`` + # checks this flag and rejects the call. + supports_batch = False + + def __init__( + self, + *, + api_key: Optional[str], + base_url: Optional[str], + default_model: Optional[str], + timeout: float, + retry: Optional[RetryPolicy], + http_client: Optional[httpx2.AsyncClient], + ) -> None: + """Build the official client. + + A missing ``http_client`` is created here and closed by ``aclose``. A + caller-supplied client stays open. A missing ``retry`` becomes + ``RetryPolicy(max_retries=0)`` because the SDK retries unless told not to. + """ + self._owns_http = http_client is None + if http_client is None: + http_client = httpx2.AsyncClient(follow_redirects=False, timeout=_http_timeout(timeout)) + self._typesafe_client = AsyncTypeSafeClient( + api_key=api_key, + model=default_model, + base_url=base_url, + timeout=_sdk_timeout(timeout), + retry=retry if retry is not None else RetryPolicy(max_retries=0), + http_client=http_client, + ) + + async def system_one(self, state: object, questions: Mapping[str, Question], + model: Optional[str]) -> SystemOneResponse: + """Evaluate ``questions`` with the official client.""" + return await self._typesafe_client.system_one(state, questions, model=model) + + async def aclose(self) -> None: + """Close the HTTP client this backend created.""" + if self._owns_http: + await self._typesafe_client.aclose() + + +class LayaHttpBackend: + """Send one plain JSON request to a Laya server. + + An empty API key omits ``Authorization``. An empty model omits ``model``, + which lets Laya choose the checkpoint. Redirects are not followed and the + request is not retried. The response is read only up to + ``MAX_RESPONSE_BYTES`` plus one byte. + """ + + # Laya exposes ``POST /v1/systemone/batch``. + supports_batch = True + + def __init__( + self, + *, + base_url: str, + api_key: str, + default_model: Optional[str], + timeout: float, + http_client: Optional[httpx2.AsyncClient], + ) -> None: + """Store the Laya connection. + + A missing ``http_client`` is created here and closed by ``aclose``. A + caller-supplied client stays open. + """ + self._base_url = base_url + self._api_key = api_key + self._default_model = default_model + self._timeout = timeout + self._owns_http = http_client is None + self._http = http_client if http_client is not None else httpx2.AsyncClient( + follow_redirects=False, + timeout=_http_timeout(timeout), + ) + + async def system_one(self, state: object, questions: Mapping[str, Question], + model: Optional[str]) -> SystemOneResponse: + """Evaluate ``questions`` against one state.""" + url = endpoint(self._base_url) + body = request_body(json_ready_state(state), questions, _model(model, self._default_model)) + status, received, headers = await self._exchange(url, body) + return _decode_single(url, status, received, headers) + + async def system_one_batch( + self, + states: Sequence[object], + questions: Mapping[str, Question], + model: Optional[str], + ) -> list[SystemOneResponse]: + """Evaluate the same questions against each state.""" + url = batch_endpoint(self._base_url) + ready = [json_ready_state(state) for state in states] + body = batch_body(ready, questions, _model(model, self._default_model)) + status, received, headers = await self._exchange(url, body) + if status < 200 or status >= 300: + raise_for_status(status, received, headers, f"POST {url}") + return decode_batch(url, received, headers, len(states)) + + async def aclose(self) -> None: + """Close the HTTP client this backend created.""" + if self._owns_http: + await self._http.aclose() + + async def _exchange(self, url: str, body: bytes) -> tuple[int, bytes, httpx2.Headers]: + """POST ``body`` and return the status, bytes, and headers. + + ``Authorization`` is sent only when an API key is set. Redirects are + not followed. A timeout or a transport failure becomes the official + timeout or connection error. ``SystemOneError`` and ``TypeSafeError`` + pass through. + """ + headers = dict(_JSON_HEADERS) + if self._api_key: + headers["Authorization"] = "Bearer " + self._api_key + try: + async with self._http.stream( + "POST", + url, + content=body, + headers=headers, + follow_redirects=False, + timeout=_http_timeout(self._timeout), + ) as response: + received, read_error = await _read_limited(response) + return _complete(response, received, read_error, url) + except (SystemOneError, TypeSafeError): + raise + except httpx2.TimeoutException as exc: + raise TypeSafeAPITimeoutError(self._timeout) from exc + except Exception as exc: + raise TypeSafeAPIConnectionError("systemone: send request") from exc + + +def _decode_single(url: str, status: int, body: bytes, headers: httpx2.Headers) -> SystemOneResponse: + """Turn one HTTP result into a Laya response. + + A non-2xx status becomes the official API error for that status. + """ + if status < 200 or status >= 300: + raise_for_status(status, body, headers, f"POST {url}") + return decode_response(url, body, headers) + + +def _complete(response: httpx2.Response, body: bytes, read_error: Optional[Exception], + url: str) -> tuple[int, bytes, httpx2.Headers]: + """Finish a streamed response. + + A failed status is reported even when the read also failed, and the read + error is the cause. A successful body longer than ``MAX_RESPONSE_BYTES`` + is rejected. Headers are copied so they stay usable after the stream closes. + """ + failed = response.status_code < 200 or response.status_code >= 300 + if failed and read_error is not None: + try: + raise_for_status(response.status_code, body, response.headers, f"POST {url}") + except TypeSafeError as exc: + raise exc from read_error + if read_error is not None: + raise SystemOneError("systemone: read response") from read_error + if not failed and len(body) > MAX_RESPONSE_BYTES: + raise SystemOneError(f"systemone: response exceeds {MAX_RESPONSE_BYTES} bytes") + return response.status_code, body, httpx2.Headers(response.headers) + + +async def _read_limited(response: httpx2.Response) -> tuple[bytes, Optional[Exception]]: + """Read at most ``MAX_RESPONSE_BYTES + 1`` bytes. + + The extra byte distinguishes a full accepted body from one that was cut + off. A read error is returned with the bytes already received, so a + non-2xx body can still become an API error. + """ + limit = MAX_RESPONSE_BYTES + 1 + parts: list[bytes] = [] + size = 0 + try: + async for chunk in response.aiter_bytes(): + room = limit - size + if len(chunk) > room: + parts.append(chunk[:room]) + size = limit + break + parts.append(chunk) + size += len(chunk) + if size >= limit: + break + except Exception as exc: + return b"".join(parts), exc + return b"".join(parts), None + + +def _model(model: Optional[str], default_model: Optional[str]) -> Optional[str]: + """Choose the model to send. + + A per-call ``model`` replaces ``default_model``. ``None`` and ``""`` both + become ``None``, so Laya omits ``model`` and picks the checkpoint. A + non-string raises ``SystemOneError``. + """ + chosen = default_model if model is None else model + if chosen is None: + return None + if not isinstance(chosen, str): + raise SystemOneError("systemone: model must be a string") + return chosen or None + + +def _http_timeout(timeout: float) -> httpx2.Timeout | float: + """Return the HTTP timeout for ``timeout`` seconds. + + ``0`` and any negative value become ``httpx2.Timeout(None)``, which means + the HTTP layer has no deadline. A positive value is passed through. + """ + if timeout <= 0: + return httpx2.Timeout(None) + return timeout + + +def _sdk_timeout(timeout: float) -> httpx2.Timeout | float: + """Return a timeout the official client accepts. + + The SDK rejects ``0``. No client deadline is expressed as an unlimited + HTTP timeout, and ``SystemOneClient`` does not add its own wait. + """ + return _http_timeout(timeout) diff --git a/trpc_agent_sdk/models/systemone/_client.py b/trpc_agent_sdk/models/systemone/_client.py new file mode 100644 index 000000000..058083764 --- /dev/null +++ b/trpc_agent_sdk/models/systemone/_client.py @@ -0,0 +1,198 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""System One client for the official TypeSafe API and for Laya HTTP.""" + +import asyncio +from collections.abc import Awaitable +from collections.abc import Mapping +from collections.abc import Sequence +from typing import Optional + +import httpx2 +from typesafe_sdk import Question +from typesafe_sdk import RetryPolicy +from typesafe_sdk import SystemOneResponse +from typesafe_sdk import TypeSafeAPITimeoutError + +from ._backends import LayaHttpBackend +from ._backends import TypeSafeBackend +from ._errors import SystemOneError +from ._validate import check_answers +from ._validate import validate_api_key +from ._validate import validate_base_url +from ._validate import validate_questions +from ._validate import validate_state +from ._validate import validate_timeout + +# Seconds used when the caller omits timeout. ``0`` means the call has no deadline. +_DEFAULT_TIMEOUT = 30.0 +# Laya accepts 1 to 64 states per batch. The error text uses this same limit. +_MAX_BATCH_STATES = 64 + + +class SystemOneClient: + """Evaluate System One questions through TypeSafe or Laya. + + ``typesafe`` talks to the official API. It requires an API key and always + sends a model, because the official SDK requires both. ``laya`` talks to + ``laya-serve``. It omits ``Authorization`` when no key is set and omits + ``model`` when none is set, so Laya can choose the checkpoint. + + Both backends return the official response type. Laya responses are a + subclass that also carries ``routing``. ``system_one_batch`` is only + available on the Laya backend. + + A timeout of ``0`` disables the deadline. A negative timeout is rejected. + The client does not follow redirects. The official backend does not retry + unless a ``RetryPolicy`` is supplied. The Laya backend never retries. + """ + + def __init__(self, backend: TypeSafeBackend | LayaHttpBackend, timeout: float) -> None: + self._backend = backend + self._timeout = timeout + + @classmethod + def typesafe( + cls, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + default_model: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + retry: Optional[RetryPolicy] = None, + http_client: Optional[httpx2.AsyncClient] = None, + ) -> "SystemOneClient": + """Create a client for the official TypeSafe API. + + ``api_key``, ``base_url``, and ``default_model`` fall back to + ``TYPESAFE_API_KEY``, ``TYPESAFE_BASE_URL``, and + ``TYPESAFE_DEFAULT_MODEL`` when omitted. The official default model is + ``jev-latest``. + """ + timeout = validate_timeout(timeout) + if base_url is not None: + base_url = validate_base_url(base_url) + _require_async_client(http_client) + backend = TypeSafeBackend( + api_key=api_key, + base_url=base_url, + default_model=default_model, + timeout=timeout, + retry=retry, + http_client=http_client, + ) + return cls(backend, timeout) + + @classmethod + def laya( + cls, + base_url: str, + *, + api_key: Optional[str] = None, + default_model: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + http_client: Optional[httpx2.AsyncClient] = None, + ) -> "SystemOneClient": + """Create a client for a Laya HTTP server. + + ``base_url`` is the server root, for example ``http://127.0.0.1:8000``. + A path prefix on that root is preserved. + """ + timeout = validate_timeout(timeout) + _require_async_client(http_client) + backend = LayaHttpBackend( + base_url=validate_base_url(base_url), + api_key=validate_api_key(api_key), + default_model=default_model, + timeout=timeout, + http_client=http_client, + ) + return cls(backend, timeout) + + async def system_one( + self, + state: object, + questions: Mapping[str, Question], + *, + model: Optional[str] = None, + ) -> SystemOneResponse: + """Evaluate ``questions`` against ``state``. + + The questions are checked before the request is sent. The response is + rejected when an answer is missing, has the wrong type, selects an + unknown choice, or reports a different number of score levels. + """ + validate_state(state) + validate_questions(questions) + + async def run() -> SystemOneResponse: + response = await self._backend.system_one(state, questions, model) + check_answers(questions, response) + return response + + return await self._wait(run()) + + async def system_one_batch( + self, + states: Sequence[object], + questions: Mapping[str, Question], + *, + model: Optional[str] = None, + ) -> list[SystemOneResponse]: + """Evaluate ``questions`` against each state on a Laya server. + + The batch contains 1 to 64 states. The official backend raises + ``SystemOneError``. + """ + validate_questions(questions) + _validate_states(states) + if not self._backend.supports_batch: + raise SystemOneError("systemone: batch is only supported by laya") + + async def run() -> list[SystemOneResponse]: + responses = await self._backend.system_one_batch(states, questions, model) + for response in responses: + check_answers(questions, response) + return responses + + return await self._wait(run()) + + async def aclose(self) -> None: + """Close the HTTP client created by this object. + + A client passed as ``http_client`` stays open. + """ + await self._backend.aclose() + + async def __aenter__(self) -> "SystemOneClient": + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.aclose() + + async def _wait(self, awaitable: Awaitable[object]) -> object: + if self._timeout <= 0: + return await awaitable + try: + return await asyncio.wait_for(awaitable, self._timeout) + except TypeSafeAPITimeoutError: + raise + except TimeoutError as exc: + raise TypeSafeAPITimeoutError(self._timeout) from exc + + +def _require_async_client(http_client: Optional[httpx2.AsyncClient]) -> None: + if http_client is not None and not isinstance(http_client, httpx2.AsyncClient): + raise SystemOneError("systemone: http client must be an httpx2.AsyncClient") + + +def _validate_states(states: Sequence[object]) -> None: + if isinstance(states, (str, bytes)) or not isinstance(states, Sequence): + raise SystemOneError("systemone: batch accepts between 1 and 64 states") + if not 1 <= len(states) <= _MAX_BATCH_STATES: + raise SystemOneError("systemone: batch accepts between 1 and 64 states") + for state in states: + validate_state(state) diff --git a/trpc_agent_sdk/models/systemone/_errors.py b/trpc_agent_sdk/models/systemone/_errors.py new file mode 100644 index 000000000..bd0317450 --- /dev/null +++ b/trpc_agent_sdk/models/systemone/_errors.py @@ -0,0 +1,63 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Errors raised by the System One client.""" + +import json +from typing import NoReturn + +import httpx2 +from typesafe_sdk import TypeSafeAPIError +from typesafe_sdk import TypeSafeAuthenticationError +from typesafe_sdk import TypeSafeBadRequestError +from typesafe_sdk import TypeSafeError +from typesafe_sdk import TypeSafeInternalServerError +from typesafe_sdk import TypeSafeNotFoundError +from typesafe_sdk import TypeSafePermissionDeniedError +from typesafe_sdk import TypeSafeRateLimitError +from typesafe_sdk import TypeSafeUnprocessableEntityError + +# One byte past the limit distinguishes an accepted body from a rejected one. +MAX_RESPONSE_BYTES = 4 << 20 + +_STATUS_ERRORS = { + 400: TypeSafeBadRequestError, + 401: TypeSafeAuthenticationError, + 403: TypeSafePermissionDeniedError, + 404: TypeSafeNotFoundError, + 422: TypeSafeUnprocessableEntityError, + 429: TypeSafeRateLimitError, +} + + +class SystemOneError(TypeSafeError): + """A System One call failed before a usable response was available. + + Invalid arguments, response limits, and answer mismatches use this type. + HTTP failures use the official ``TypeSafeAPIError`` subclasses instead, so + ``except TypeSafeError`` catches every failure from either backend. + """ + + +def raise_for_status(status: int, body: bytes, headers: httpx2.Headers, endpoint: str) -> NoReturn: + """Raise the official API error for a non-2xx response. + + ``str(error)`` contains the status and endpoint only. The response body + stays on ``error.body`` and is capped at ``MAX_RESPONSE_BYTES``. + """ + if len(body) > MAX_RESPONSE_BYTES: + body = body[:MAX_RESPONSE_BYTES] + if not body: + parsed: object = None + else: + try: + parsed = json.loads(body) + except ValueError: + parsed = body.decode("utf-8", errors="replace") + if status >= 500: + error_type = TypeSafeInternalServerError + else: + error_type = _STATUS_ERRORS.get(status, TypeSafeAPIError) + raise error_type(status, parsed, headers, message=f"http status {status}", endpoint=endpoint) diff --git a/trpc_agent_sdk/models/systemone/_laya.py b/trpc_agent_sdk/models/systemone/_laya.py new file mode 100644 index 000000000..ef3ef7e4e --- /dev/null +++ b/trpc_agent_sdk/models/systemone/_laya.py @@ -0,0 +1,149 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Laya response fields and batch decoding. + +Laya's ``POST /v1/systemone`` body matches the official System One response +and adds a ``routing`` object. ``POST /v1/systemone/batch`` returns those +responses under ``results``, plus ``total_usage`` for the whole call. +""" + +import json +from collections.abc import Mapping +from collections.abc import Sequence +from typing import Optional + +import httpx2 +from pydantic import BaseModel +from typesafe_sdk import Question +from typesafe_sdk import SystemOneResponse +from typesafe_sdk._core.schemas.base import Schema + +from ._errors import SystemOneError + +_BATCH_ROUTE = "/v1/systemone/batch" +_SYSTEM_ONE_ROUTE = "/v1/systemone" + + +class LayaRouting(Schema): + """The checkpoint Laya selected for a request.""" + + model: str + """Checkpoint name, such as ``english`` or ``multilingual``.""" + repo: Optional[str] = None + """Hugging Face repository that supplied the checkpoint, when reported.""" + + +class LayaSystemOneResponse(SystemOneResponse): + """A System One response plus Laya's routing report. + + Fields the official model does not declare, including per-answer ``action`` + and ``usage.truncated``, stay in ``raw_http_response``. + """ + + routing: Optional[LayaRouting] = None + """Checkpoint selection. ``None`` when the server omitted it.""" + + +def encode_questions(questions: Mapping[str, Question]) -> dict[str, object]: + """Serialize question objects to the JSON object Laya expects.""" + encoded: dict[str, object] = {} + for name, question in questions.items(): + if isinstance(question, BaseModel): + encoded[name] = question.model_dump(mode="json", exclude_none=True) + elif isinstance(question, dict): + encoded[name] = question + else: + raise SystemOneError(f"systemone: question {name!r}: expected a question object or a typed dictionary") + return encoded + + +def request_body(state: object, questions: Mapping[str, Question], model: Optional[str]) -> bytes: + """Encode one System One request, omitting ``model`` when it is empty.""" + return _dumps(_payload(questions, model, state=state)) + + +def batch_body(states: Sequence[object], questions: Mapping[str, Question], model: Optional[str]) -> bytes: + """Encode a Laya batch request.""" + return _dumps(_payload(questions, model, states=list(states))) + + +def endpoint(base_url: str, route: str = _SYSTEM_ONE_ROUTE) -> str: + """Join a normalized API root with a System One route.""" + return base_url.rstrip("/") + route + + +def batch_endpoint(base_url: str) -> str: + """Return the Laya batch route for ``base_url``.""" + return endpoint(base_url, _BATCH_ROUTE) + + +def decode_response(url: str, body: bytes, headers: httpx2.Headers) -> LayaSystemOneResponse: + """Parse one successful Laya response and keep its ``X-Request-ID``.""" + parsed = _parse_item(url, body, headers) + request_id = headers.get("X-Request-ID") + if request_id is not None: + parsed.__dict__["_request_id"] = request_id + return parsed + + +def decode_batch(url: str, body: bytes, headers: httpx2.Headers, expected: int) -> list[LayaSystemOneResponse]: + """Split a batch response into one checked response per submitted state.""" + try: + payload = json.loads(body) + except ValueError as exc: + raise SystemOneError("systemone: batch response is not JSON") from exc + results = payload.get("results") if isinstance(payload, dict) else None + if not isinstance(results, list): + raise SystemOneError("systemone: batch response is missing results") + if len(results) != expected: + raise SystemOneError("systemone: batch response size does not match states") + decoded = [] + for item in results: + if not isinstance(item, dict): + raise SystemOneError("systemone: batch response item is not an object") + decoded.append(decode_response(url, json.dumps(item, ensure_ascii=False).encode("utf-8"), headers)) + return decoded + + +def _payload(questions: Mapping[str, Question], model: Optional[str], **fields: object) -> dict[str, object]: + """Build the JSON object for one call or a batch. + + ``model`` is omitted when it is empty, so Laya can choose the checkpoint. + ``fields`` is either ``state`` or ``states``. + """ + body: dict[str, object] = dict(fields) + if model: + body["model"] = model + body["questions"] = encode_questions(questions) + return body + + +def _dumps(body: dict[str, object]) -> bytes: + """Encode ``body`` as UTF-8 JSON. + + NaN and Infinity are rejected. A value ``json.dumps`` cannot encode becomes + ``SystemOneError``. + """ + try: + return json.dumps(body, ensure_ascii=False, allow_nan=False).encode("utf-8") + except (TypeError, ValueError) as exc: + raise SystemOneError("systemone: state must be JSON") from exc + + +def _parse_item(url: str, body: bytes, headers: httpx2.Headers) -> LayaSystemOneResponse: + """Parse ``body`` with the official response model. + + The bytes are wrapped in an ``httpx2.Response`` because + ``LayaSystemOneResponse.from_http_response`` reads the body and headers + from that object. ``url`` is only the synthetic request URL. + """ + response = httpx2.Response( + 200, + content=body, + headers=headers, + request=httpx2.Request("POST", url), + ) + return LayaSystemOneResponse.from_http_response(response) diff --git a/trpc_agent_sdk/models/systemone/_validate.py b/trpc_agent_sdk/models/systemone/_validate.py new file mode 100644 index 000000000..4a60cf76d --- /dev/null +++ b/trpc_agent_sdk/models/systemone/_validate.py @@ -0,0 +1,196 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Validate System One arguments and check answers against the questions that were asked.""" + +import re +from collections.abc import Mapping +from collections.abc import Sequence +from typing import Optional +from urllib.parse import urlsplit + +from pydantic import BaseModel +from typesafe_sdk import Box +from typesafe_sdk import BoxAnswer +from typesafe_sdk import Choice +from typesafe_sdk import ChoiceAnswer +from typesafe_sdk import Noul +from typesafe_sdk import NoulAnswer +from typesafe_sdk import Question +from typesafe_sdk import Score +from typesafe_sdk import ScoreAnswer +from typesafe_sdk import SystemOneResponse + +from ._errors import SystemOneError + +_INVALID_PERCENT = re.compile(r"%(?![0-9A-Fa-f]{2})") +_ANSWER_TYPES = { + "noul": NoulAnswer, + "choice": ChoiceAnswer, + "score": ScoreAnswer, + "box": BoxAnswer, +} +_QUESTION_TYPES = (Noul, Choice, Score, Box) + + +def validate_timeout(timeout: float) -> float: + """Reject a negative timeout or a non-numeric value. + + ``0`` is accepted and means the call has no deadline. ``bool`` is rejected + because it is an ``int`` subclass. + """ + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout < 0: + raise SystemOneError("systemone: timeout must not be negative") + return float(timeout) + + +def validate_base_url(base_url: str) -> str: + """Return ``base_url`` without a trailing slash, or raise ``SystemOneError``. + + The URL must be http or https, must name a host, and must not carry user + info, a query, or a fragment. A path prefix is preserved. + """ + if not isinstance(base_url, str) or not base_url or any(char in base_url for char in "?#"): + raise SystemOneError("systemone: invalid base url") + if _INVALID_PERCENT.search(base_url): + raise SystemOneError("systemone: invalid base url") + try: + parts = urlsplit(base_url) + except ValueError as exc: + raise SystemOneError("systemone: invalid base url") from exc + if parts.scheme not in {"http", "https"} or not parts.hostname: + raise SystemOneError("systemone: invalid base url") + if parts.username is not None or parts.password is not None: + raise SystemOneError("systemone: invalid base url") + return parts.geturl().rstrip("/") + + +def validate_api_key(api_key: Optional[str]) -> str: + """Return the key to send, or ``""`` when the request is anonymous. + + A Laya server without ``LAYA_API_KEY`` accepts the empty result. A key that + contains a line break is rejected so it cannot inject a header. + """ + if api_key is None or api_key == "": + return "" + if not isinstance(api_key, str) or "\r" in api_key or "\n" in api_key: + raise SystemOneError("systemone: invalid api key") + return api_key + + +def validate_state(state: object) -> None: + """Reject a missing state before any request is sent.""" + if state is None: + raise SystemOneError("systemone: state is required") + + +def validate_questions(questions: Mapping[str, Question]) -> None: + """Reject an empty question map, a blank id, or a question with no criteria.""" + if not isinstance(questions, Mapping) or not questions: + raise SystemOneError("systemone: at least one question is required") + for name, question in questions.items(): + if not isinstance(name, str) or name.strip() == "": + raise SystemOneError("systemone: question id must not be blank") + _validate_question(name, question) + + +def check_answers(questions: Mapping[str, Question], response: SystemOneResponse) -> None: + """Raise ``SystemOneError`` when ``response`` does not answer ``questions``. + + Every question needs an answer of the same type. A choice answer must pick + a requested label, and its probability keys must be requested labels. A + score answer must report one probability per requested level. + """ + for name, question in questions.items(): + kind = _question_kind(name, question) + expected = _ANSWER_TYPES.get(kind) + if expected is None: + raise SystemOneError(f"systemone: question {name!r}: unknown type {kind!r}") + answer = response.answers.get(name) if isinstance(response.answers, dict) else None + if answer is None: + raise SystemOneError(f'systemone: answer "{name}" not found') + if not isinstance(answer, expected): + raise SystemOneError( + f'systemone: answer "{name}": expected {kind}, got {getattr(answer, "type", type(answer).__name__)}') + if isinstance(answer, ChoiceAnswer): + _check_choice(name, _choice_labels(name, question), answer) + elif isinstance(answer, ScoreAnswer): + _check_score(name, _score_levels(name, question), answer) + + +def _validate_question(name: str, question: Question) -> None: + if isinstance(question, _QUESTION_TYPES): + _reject_blank_text(name, getattr(question, "instructions", None)) + if isinstance(question, Choice) and not question.criteria: + raise SystemOneError(f"systemone: question {name!r}: criteria must not be empty") + if isinstance(question, Score) and not question.criteria: + raise SystemOneError(f"systemone: question {name!r}: criteria must not be empty") + return + if not isinstance(question, dict) or not isinstance(question.get("type"), str) or not question["type"]: + raise SystemOneError(f"systemone: question {name!r}: expected a question object or a typed dictionary") + _reject_blank_text(name, question.get("instructions")) + kind = question["type"] + if kind == "choice" and not _mapping(question.get("criteria")): + raise SystemOneError(f"systemone: question {name!r}: criteria must not be empty") + if kind == "score" and not _sequence(question.get("criteria")): + raise SystemOneError(f"systemone: question {name!r}: criteria must not be empty") + + +def _reject_blank_text(name: str, instructions: object) -> None: + if isinstance(instructions, str) and instructions.strip() == "": + raise SystemOneError(f"systemone: question {name!r}: instructions must not be blank") + + +def _question_kind(name: str, question: Question) -> str: + if isinstance(question, _QUESTION_TYPES): + return question.type + if isinstance(question, dict) and isinstance(question.get("type"), str): + return question["type"] + raise SystemOneError(f"systemone: question {name!r}: expected a question object or a typed dictionary") + + +def _choice_labels(name: str, question: Question) -> set[str]: + criteria = question.criteria if isinstance( + question, Choice) else question.get("criteria") if isinstance(question, dict) else None + if not isinstance(criteria, Mapping): + raise SystemOneError(f"systemone: question {name!r}: criteria must not be empty") + return {label for label in criteria if isinstance(label, str)} + + +def _score_levels(name: str, question: Question) -> int: + criteria = question.criteria if isinstance( + question, Score) else question.get("criteria") if isinstance(question, dict) else None + if not _sequence(criteria): + raise SystemOneError(f"systemone: question {name!r}: criteria must not be empty") + return len(criteria) + + +def _check_choice(name: str, labels: set[str], answer: ChoiceAnswer) -> None: + if answer.choice not in labels: + raise SystemOneError(f'systemone: answer "{name}": choice {answer.choice!r} is not in criteria') + for label in answer.probabilities: + if label not in labels: + raise SystemOneError(f'systemone: answer "{name}": probability {label!r} is not in criteria') + + +def _check_score(name: str, levels: int, answer: ScoreAnswer) -> None: + if len(answer.probabilities) != levels: + raise SystemOneError( + f'systemone: answer "{name}": expected {levels} score levels, got {len(answer.probabilities)}') + + +def _mapping(value: object) -> bool: + return isinstance(value, Mapping) and len(value) > 0 + + +def _sequence(value: object) -> bool: + return isinstance(value, Sequence) and not isinstance(value, (str, bytes)) and len(value) > 0 + + +def json_ready_state(state: object) -> object: + """Return ``state`` in a form ``json.dumps`` can encode.""" + if isinstance(state, BaseModel): + return state.model_dump(mode="json") + return state