agent-studio-client 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_studio/__init__.py +60 -0
- agent_studio/_api.py +188 -0
- agent_studio/_meta.py +41 -0
- agent_studio/_sse.py +71 -0
- agent_studio/_transport.py +132 -0
- agent_studio/_version.py +4 -0
- agent_studio/async_client.py +313 -0
- agent_studio/client.py +350 -0
- agent_studio/errors.py +58 -0
- agent_studio/models.py +142 -0
- agent_studio/py.typed +0 -0
- agent_studio_client-0.0.1.dist-info/METADATA +353 -0
- agent_studio_client-0.0.1.dist-info/RECORD +15 -0
- agent_studio_client-0.0.1.dist-info/WHEEL +4 -0
- agent_studio_client-0.0.1.dist-info/licenses/LICENSE +21 -0
agent_studio/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Agent Studio 运行时 API 的 Python 客户端。"""
|
|
2
|
+
|
|
3
|
+
from ._meta import StudioMeta
|
|
4
|
+
from ._version import EVENT_SCHEMA_MAJOR, __version__
|
|
5
|
+
from .async_client import AsyncAgentHandle, AsyncRunHandle, AsyncStudioClient
|
|
6
|
+
from .client import AgentHandle, RunHandle, StudioClient
|
|
7
|
+
from .errors import (
|
|
8
|
+
StudioApiError,
|
|
9
|
+
StudioCompatibilityError,
|
|
10
|
+
StudioConnectionError,
|
|
11
|
+
StudioError,
|
|
12
|
+
StudioFeatureUnavailableError,
|
|
13
|
+
StudioProtocolError,
|
|
14
|
+
StudioStreamGapError,
|
|
15
|
+
)
|
|
16
|
+
from .models import (
|
|
17
|
+
ActionAccepted,
|
|
18
|
+
CapabilityList,
|
|
19
|
+
ClientCapabilities,
|
|
20
|
+
Event,
|
|
21
|
+
PendingAction,
|
|
22
|
+
Run,
|
|
23
|
+
RunStatus,
|
|
24
|
+
RunUsage,
|
|
25
|
+
Thread,
|
|
26
|
+
ThreadList,
|
|
27
|
+
ThreadMessage,
|
|
28
|
+
ThreadSummary,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"EVENT_SCHEMA_MAJOR",
|
|
33
|
+
"ActionAccepted",
|
|
34
|
+
"AgentHandle",
|
|
35
|
+
"AsyncAgentHandle",
|
|
36
|
+
"AsyncRunHandle",
|
|
37
|
+
"AsyncStudioClient",
|
|
38
|
+
"CapabilityList",
|
|
39
|
+
"ClientCapabilities",
|
|
40
|
+
"Event",
|
|
41
|
+
"PendingAction",
|
|
42
|
+
"Run",
|
|
43
|
+
"RunHandle",
|
|
44
|
+
"RunStatus",
|
|
45
|
+
"RunUsage",
|
|
46
|
+
"StudioApiError",
|
|
47
|
+
"StudioClient",
|
|
48
|
+
"StudioCompatibilityError",
|
|
49
|
+
"StudioConnectionError",
|
|
50
|
+
"StudioError",
|
|
51
|
+
"StudioFeatureUnavailableError",
|
|
52
|
+
"StudioMeta",
|
|
53
|
+
"StudioProtocolError",
|
|
54
|
+
"StudioStreamGapError",
|
|
55
|
+
"Thread",
|
|
56
|
+
"ThreadList",
|
|
57
|
+
"ThreadMessage",
|
|
58
|
+
"ThreadSummary",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
agent_studio/_api.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""运行时 API 端点定义。每个函数只描述请求与响应解析,由同步或异步客户端负责发送,两者共享同一份端点逻辑。
|
|
2
|
+
|
|
3
|
+
路径均相对于 /v1/projects/{projectId}。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
|
|
15
|
+
from ._transport import check_idempotency_key, drop_none, segment
|
|
16
|
+
from .errors import StudioProtocolError
|
|
17
|
+
from .models import (
|
|
18
|
+
ActionAccepted,
|
|
19
|
+
CapabilityList,
|
|
20
|
+
ClientCapabilities,
|
|
21
|
+
Event,
|
|
22
|
+
PendingAction,
|
|
23
|
+
Run,
|
|
24
|
+
Thread,
|
|
25
|
+
ThreadList,
|
|
26
|
+
ThreadMessage,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
M = TypeVar("M", bound=BaseModel)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Request(Generic[T]):
|
|
35
|
+
method: str
|
|
36
|
+
path: str
|
|
37
|
+
parse: Callable[[Any], T]
|
|
38
|
+
json: dict[str, Any] | None = None
|
|
39
|
+
params: dict[str, Any] | None = None
|
|
40
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode(response: httpx.Response) -> Any:
|
|
44
|
+
if response.status_code == 204 or not response.content:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return response.json()
|
|
48
|
+
except ValueError as error:
|
|
49
|
+
raise StudioProtocolError(f"响应不是 JSON(HTTP {response.status_code})") from error
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _model(model: type[M], key: str | None = None) -> Callable[[Any], M]:
|
|
53
|
+
def parse(body: Any) -> M:
|
|
54
|
+
try:
|
|
55
|
+
return model.model_validate(body[key] if key else body)
|
|
56
|
+
except (KeyError, TypeError, ValidationError) as error:
|
|
57
|
+
raise StudioProtocolError(f"响应不符合接口契约:{model.__name__}") from error
|
|
58
|
+
return parse
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _list(model: type[M], key: str) -> Callable[[Any], list[M]]:
|
|
62
|
+
def parse(body: Any) -> list[M]:
|
|
63
|
+
try:
|
|
64
|
+
return [model.model_validate(item) for item in body[key]]
|
|
65
|
+
except (KeyError, TypeError, ValidationError) as error:
|
|
66
|
+
raise StudioProtocolError(f"响应不符合接口契约:{model.__name__} 列表") from error
|
|
67
|
+
return parse
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _none(_: Any) -> None:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _raw(body: Any) -> Any:
|
|
75
|
+
return body
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- Thread ----
|
|
79
|
+
|
|
80
|
+
def create_thread(agent_id: str | None = None, agent_slug: str | None = None) -> Request[Thread]:
|
|
81
|
+
if (agent_id is None) == (agent_slug is None):
|
|
82
|
+
raise ValueError("agent_id 与 agent_slug 需且只能提供一个")
|
|
83
|
+
body = {"agentId": agent_id} if agent_id else {"agentSlug": agent_slug}
|
|
84
|
+
return Request("POST", "/threads", _model(Thread, "thread"), json=body)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_thread(thread_id: str) -> Request[Thread]:
|
|
88
|
+
return Request("GET", f"/threads/{segment(thread_id)}", _model(Thread, "thread"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def delete_thread(thread_id: str) -> Request[None]:
|
|
92
|
+
return Request("DELETE", f"/threads/{segment(thread_id)}", _none)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def list_threads(agent_id: str | None = None, before: str | None = None) -> Request[ThreadList]:
|
|
96
|
+
return Request("GET", "/threads", _model(ThreadList), params=drop_none({"agentId": agent_id, "before": before}))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_thread_messages(thread_id: str) -> Request[list[ThreadMessage]]:
|
|
100
|
+
return Request("GET", f"/threads/{segment(thread_id)}/messages", _list(ThreadMessage, "messages"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---- Run ----
|
|
104
|
+
|
|
105
|
+
def create_run(
|
|
106
|
+
thread_id: str,
|
|
107
|
+
input: str,
|
|
108
|
+
idempotency_key: str,
|
|
109
|
+
*,
|
|
110
|
+
context: dict[str, str | int | float | bool | None] | None = None,
|
|
111
|
+
attachments: list[str] | None = None,
|
|
112
|
+
capabilities: ClientCapabilities | None = None,
|
|
113
|
+
agent_version: int | None = None,
|
|
114
|
+
) -> Request[Run]:
|
|
115
|
+
if not input.strip():
|
|
116
|
+
raise ValueError("input 不能为空")
|
|
117
|
+
if len(input) > 20000:
|
|
118
|
+
raise ValueError("input 不能超过 20000 个字符")
|
|
119
|
+
body = drop_none({
|
|
120
|
+
"threadId": thread_id,
|
|
121
|
+
"input": input,
|
|
122
|
+
"context": context,
|
|
123
|
+
"attachments": [{"fileId": file_id} for file_id in attachments] if attachments else None,
|
|
124
|
+
"capabilities": capabilities.model_dump(by_alias=True) if capabilities else None,
|
|
125
|
+
"agentVersion": agent_version,
|
|
126
|
+
})
|
|
127
|
+
return Request("POST", "/runs", _model(Run, "run"), json=body,
|
|
128
|
+
headers={"Idempotency-Key": check_idempotency_key(idempotency_key)})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_run(run_id: str) -> Request[Run]:
|
|
132
|
+
return Request("GET", f"/runs/{segment(run_id)}", _model(Run, "run"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def list_runs() -> Request[list[Run]]:
|
|
136
|
+
return Request("GET", "/runs", _list(Run, "runs"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cancel_run(run_id: str) -> Request[Run]:
|
|
140
|
+
return Request("POST", f"/runs/{segment(run_id)}/cancel", _model(Run, "run"))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def list_events(run_id: str, after: int = 0, limit: int = 100) -> Request[list[Event]]:
|
|
144
|
+
if after < 0 or not 1 <= limit <= 200:
|
|
145
|
+
raise ValueError("after 不能为负数,limit 范围为 1~200")
|
|
146
|
+
return Request("GET", f"/runs/{segment(run_id)}/events", _list(Event, "events"),
|
|
147
|
+
params={"after": after, "limit": limit})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- 审批与客户端工具 ----
|
|
151
|
+
|
|
152
|
+
def get_pending(run_id: str) -> Request[list[PendingAction]]:
|
|
153
|
+
return Request("GET", f"/runs/{segment(run_id)}/pending", _list(PendingAction, "pending"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def submit_approval(run_id: str, action: PendingAction, decision: str,
|
|
157
|
+
args: dict[str, Any] | None = None) -> Request[ActionAccepted]:
|
|
158
|
+
if action.kind != "approval":
|
|
159
|
+
raise ValueError("客户端工具请求需用 submit_client_tool_result 回传")
|
|
160
|
+
if decision not in action.allowed_decisions:
|
|
161
|
+
raise ValueError(f"该请求不允许决定 {decision}")
|
|
162
|
+
if (decision == "edit") != (args is not None):
|
|
163
|
+
raise ValueError("只有 edit 决定需要且必须提供 args")
|
|
164
|
+
body = drop_none({"argsHash": action.args_hash, "decision": decision, "args": args})
|
|
165
|
+
return Request("POST", f"/runs/{segment(run_id)}/requests/{segment(action.id)}/decision",
|
|
166
|
+
_model(ActionAccepted), json=body)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def submit_client_tool_result(run_id: str, action: PendingAction, device_id: str, *, approved: bool,
|
|
170
|
+
result: dict[str, Any] | None = None) -> Request[ActionAccepted]:
|
|
171
|
+
if action.kind != "client_tool":
|
|
172
|
+
raise ValueError("审批请求需用 submit_approval 提交")
|
|
173
|
+
if approved and result is None:
|
|
174
|
+
raise ValueError("批准执行时必须回传 result")
|
|
175
|
+
body = drop_none({"deviceId": device_id, "argsHash": action.args_hash,
|
|
176
|
+
"decision": "approved" if approved else "rejected", "result": result if approved else None})
|
|
177
|
+
return Request("POST", f"/runs/{segment(run_id)}/requests/{segment(action.id)}/result",
|
|
178
|
+
_model(ActionAccepted), json=body)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---- 其他 ----
|
|
182
|
+
|
|
183
|
+
def delete_actor_data(actor_ref: str) -> Request[dict[str, Any]]:
|
|
184
|
+
return Request("POST", "/privacy/deletions", _raw, json={"scope": "actor", "targetRef": actor_ref})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_capabilities() -> Request[CapabilityList]:
|
|
188
|
+
return Request("GET", "/capabilities", _model(CapabilityList))
|
agent_studio/_meta.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""能力发现:GET /v1/meta。旧版服务端没有该端点,视为 1.0.0 且所有新能力不可用。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from ._sse import check_event_schema
|
|
10
|
+
from .errors import StudioFeatureUnavailableError, StudioProtocolError
|
|
11
|
+
from .models import _Model
|
|
12
|
+
|
|
13
|
+
LEGACY_API_VERSION = "1.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StudioMeta(_Model):
|
|
17
|
+
api_version: str
|
|
18
|
+
event_schema_version: str
|
|
19
|
+
features: dict[str, bool] = {}
|
|
20
|
+
|
|
21
|
+
def supports(self, feature: str) -> bool:
|
|
22
|
+
# 缺失的能力键视为不支持,服务端新增的未知键被忽略
|
|
23
|
+
return self.features.get(feature, False)
|
|
24
|
+
|
|
25
|
+
def require(self, feature: str) -> None:
|
|
26
|
+
if not self.supports(feature):
|
|
27
|
+
raise StudioFeatureUnavailableError(feature, self.api_version)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
LEGACY_META = StudioMeta(api_version=LEGACY_API_VERSION, event_schema_version="1.0", features={})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_meta(status: int, body: Any) -> StudioMeta:
|
|
34
|
+
if status == 404:
|
|
35
|
+
return LEGACY_META
|
|
36
|
+
try:
|
|
37
|
+
meta = StudioMeta.model_validate(body)
|
|
38
|
+
except ValidationError as error:
|
|
39
|
+
raise StudioProtocolError("能力发现响应不符合接口契约") from error
|
|
40
|
+
check_event_schema(meta.event_schema_version)
|
|
41
|
+
return meta
|
agent_studio/_sse.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Server-Sent Events 解析与事件序号检查,纯函数实现,同步与异步流共用。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from ._version import EVENT_SCHEMA_MAJOR
|
|
11
|
+
from .errors import StudioCompatibilityError, StudioProtocolError, StudioStreamGapError
|
|
12
|
+
from .models import Event
|
|
13
|
+
|
|
14
|
+
# 与服务端单帧上限对齐,防止异常服务端让客户端无限累积内存。
|
|
15
|
+
MAX_FRAME_BYTES = 1_048_576
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SseParser:
|
|
19
|
+
"""逐行喂入 SSE 文本,产出每一帧的 data 字段。注释行(心跳)与 id/event 字段被忽略,序号以信封内 seq 为准。"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._data: list[str] = []
|
|
23
|
+
self._size = 0
|
|
24
|
+
|
|
25
|
+
def feed(self, line: str) -> Iterator[str]:
|
|
26
|
+
line = line.rstrip("\r")
|
|
27
|
+
if line == "":
|
|
28
|
+
if self._data:
|
|
29
|
+
data = "\n".join(self._data)
|
|
30
|
+
self._data, self._size = [], 0
|
|
31
|
+
yield data
|
|
32
|
+
return
|
|
33
|
+
if line.startswith(":"):
|
|
34
|
+
return
|
|
35
|
+
field, _, value = line.partition(":")
|
|
36
|
+
if field == "data":
|
|
37
|
+
value = value[1:] if value.startswith(" ") else value
|
|
38
|
+
self._size += len(value)
|
|
39
|
+
if self._size > MAX_FRAME_BYTES:
|
|
40
|
+
raise StudioProtocolError("事件帧超过 1 MiB 上限")
|
|
41
|
+
self._data.append(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EventSequencer:
|
|
45
|
+
"""解析事件信封、校验主版本与 seq 连续性,并维护续传游标。"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, run_id: str, after: int):
|
|
48
|
+
if after < 0:
|
|
49
|
+
raise ValueError("事件游标不能为负数")
|
|
50
|
+
self.run_id = run_id
|
|
51
|
+
self.cursor = after
|
|
52
|
+
|
|
53
|
+
def accept(self, data: str) -> Event | None:
|
|
54
|
+
"""返回新事件;重复或属于其他 Run 的事件返回 None。"""
|
|
55
|
+
try:
|
|
56
|
+
event = Event.model_validate(json.loads(data))
|
|
57
|
+
except (ValueError, ValidationError) as error:
|
|
58
|
+
raise StudioProtocolError("事件不符合接口契约") from error
|
|
59
|
+
check_event_schema(event.event_schema_version)
|
|
60
|
+
if event.run_id != self.run_id or event.seq <= self.cursor:
|
|
61
|
+
return None
|
|
62
|
+
if event.seq != self.cursor + 1:
|
|
63
|
+
raise StudioStreamGapError(self.cursor + 1, event.seq)
|
|
64
|
+
self.cursor = event.seq
|
|
65
|
+
return event
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_event_schema(version: str) -> None:
|
|
69
|
+
major = version.split(".", 1)[0]
|
|
70
|
+
if major != str(EVENT_SCHEMA_MAJOR):
|
|
71
|
+
raise StudioCompatibilityError(f"{EVENT_SCHEMA_MAJOR}.x", version)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""同步与异步客户端共用的请求构造、错误映射与重试策略,不做任何 I/O。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from email.utils import parsedate_to_datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import StudioApiError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("agent_studio")
|
|
21
|
+
|
|
22
|
+
_ACTOR = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$")
|
|
23
|
+
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
|
24
|
+
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
|
|
25
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ClientConfig:
|
|
30
|
+
base_url: str
|
|
31
|
+
project_id: str
|
|
32
|
+
api_key: str
|
|
33
|
+
actor_ref: str
|
|
34
|
+
timeout: float = 30.0
|
|
35
|
+
max_retries: int = 3
|
|
36
|
+
max_reconnects: int = 5
|
|
37
|
+
# 服务端每 15 秒发一次心跳;超过 3 倍心跳仍无数据视为连接失效。
|
|
38
|
+
heartbeat_timeout: float = 45.0
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if not self.base_url:
|
|
42
|
+
raise ValueError("base_url 必填,SDK 不内置默认实例地址")
|
|
43
|
+
if not _UUID.match(self.project_id):
|
|
44
|
+
raise ValueError("project_id 必须是 UUID")
|
|
45
|
+
if not self.api_key.startswith("sk_studio_"):
|
|
46
|
+
raise ValueError("api_key 格式无效,应以 sk_studio_ 开头")
|
|
47
|
+
if not _ACTOR.match(self.actor_ref):
|
|
48
|
+
raise ValueError("actor_ref 只能包含字母、数字和 :._-,且不超过 128 个字符")
|
|
49
|
+
if self.max_retries < 0 or self.max_reconnects < 0:
|
|
50
|
+
raise ValueError("重试次数不能为负数")
|
|
51
|
+
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
|
|
52
|
+
url = httpx.URL(self.base_url)
|
|
53
|
+
if url.scheme != "https" and url.host not in _LOCAL_HOSTS:
|
|
54
|
+
logger.warning("Studio 地址未使用 HTTPS,API Key 将以明文传输")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def project_url(self) -> str:
|
|
58
|
+
return f"{self.base_url}/v1/projects/{self.project_id}"
|
|
59
|
+
|
|
60
|
+
def headers(self) -> dict[str, str]:
|
|
61
|
+
return {
|
|
62
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
63
|
+
"X-Studio-Actor": self.actor_ref,
|
|
64
|
+
"Studio-Sdk": f"python/{__version__}",
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def segment(value: str) -> str:
|
|
70
|
+
"""URL 路径片段转义,防止标识中的 / 或 .. 改变请求路径。"""
|
|
71
|
+
return quote(value, safe="")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_uuid(value: str) -> bool:
|
|
75
|
+
return bool(_UUID.match(value))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def new_idempotency_key() -> str:
|
|
79
|
+
return str(uuid.uuid4())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_idempotency_key(key: str) -> str:
|
|
83
|
+
if not _IDEMPOTENCY_KEY.match(key):
|
|
84
|
+
raise ValueError("idempotency_key 需为 8~128 个字母、数字或 ._:- 字符")
|
|
85
|
+
return key
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
89
|
+
if not value:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
seconds = float(value)
|
|
93
|
+
except ValueError:
|
|
94
|
+
try:
|
|
95
|
+
seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
return min(max(seconds, 0.0), 30.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def error_from_response(response: httpx.Response) -> StudioApiError:
|
|
102
|
+
"""按服务端统一错误格式 {"error": {code, message, retryable}} 映射;非该格式(如代理 HTML)也给出稳定的错误码。"""
|
|
103
|
+
status = response.status_code
|
|
104
|
+
fallback_retryable = status == 429 or status >= 500
|
|
105
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
106
|
+
try:
|
|
107
|
+
body = response.json()
|
|
108
|
+
error = body["error"]
|
|
109
|
+
return StudioApiError(
|
|
110
|
+
str(error["code"]), str(error.get("message", "")), status,
|
|
111
|
+
bool(error.get("retryable", fallback_retryable)), retry_after,
|
|
112
|
+
)
|
|
113
|
+
except (ValueError, KeyError, TypeError):
|
|
114
|
+
return StudioApiError("unexpected_response", f"服务端返回了非预期响应(HTTP {status})", status,
|
|
115
|
+
fallback_retryable, retry_after)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_retryable(error: BaseException) -> bool:
|
|
119
|
+
if isinstance(error, StudioApiError):
|
|
120
|
+
return error.retryable
|
|
121
|
+
return isinstance(error, httpx.TransportError)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def backoff(attempt: int, error: BaseException | None = None) -> float:
|
|
125
|
+
"""第 attempt 次重试(从 0 开始)前的等待秒数;服务端给出 Retry-After 时以其为准。"""
|
|
126
|
+
if isinstance(error, StudioApiError) and error.retry_after is not None:
|
|
127
|
+
return error.retry_after
|
|
128
|
+
return float(min(8.0, 0.5 * 2**attempt) * random.uniform(0.5, 1.0))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def drop_none(body: dict[str, Any]) -> dict[str, Any]:
|
|
132
|
+
return {key: value for key, value in body.items() if value is not None}
|
agent_studio/_version.py
ADDED