tai42-cli 1.0.0__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.
- tai42_cli/__init__.py +0 -0
- tai42_cli/app.py +281 -0
- tai42_cli/client.py +273 -0
- tai42_cli/commands/__init__.py +5 -0
- tai42_cli/commands/_common.py +351 -0
- tai42_cli/commands/agents.py +87 -0
- tai42_cli/commands/auth.py +70 -0
- tai42_cli/commands/backup.py +76 -0
- tai42_cli/commands/channels.py +31 -0
- tai42_cli/commands/checkpoints.py +29 -0
- tai42_cli/commands/config.py +375 -0
- tai42_cli/commands/connectors.py +152 -0
- tai42_cli/commands/conversations.py +320 -0
- tai42_cli/commands/extensions.py +30 -0
- tai42_cli/commands/fleet.py +123 -0
- tai42_cli/commands/hooks.py +258 -0
- tai42_cli/commands/interactions.py +67 -0
- tai42_cli/commands/keys.py +233 -0
- tai42_cli/commands/manifest.py +76 -0
- tai42_cli/commands/mcp.py +162 -0
- tai42_cli/commands/notifications.py +105 -0
- tai42_cli/commands/obs.py +48 -0
- tai42_cli/commands/plugins.py +267 -0
- tai42_cli/commands/presets.py +279 -0
- tai42_cli/commands/resources.py +60 -0
- tai42_cli/commands/roles.py +155 -0
- tai42_cli/commands/schedules.py +94 -0
- tai42_cli/commands/scopes.py +139 -0
- tai42_cli/commands/storage.py +128 -0
- tai42_cli/commands/sub_mcp.py +61 -0
- tai42_cli/commands/system.py +36 -0
- tai42_cli/commands/templates.py +145 -0
- tai42_cli/commands/tool_meta.py +194 -0
- tai42_cli/commands/tools.py +245 -0
- tai42_cli/commands/traces.py +95 -0
- tai42_cli/completion.py +45 -0
- tai42_cli/context.py +121 -0
- tai42_cli/py.typed +0 -0
- tai42_cli/render.py +94 -0
- tai42_cli/version.py +36 -0
- tai42_cli-1.0.0.dist-info/METADATA +123 -0
- tai42_cli-1.0.0.dist-info/RECORD +47 -0
- tai42_cli-1.0.0.dist-info/WHEEL +5 -0
- tai42_cli-1.0.0.dist-info/entry_points.txt +3 -0
- tai42_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- tai42_cli-1.0.0.dist-info/licenses/NOTICE +5 -0
- tai42_cli-1.0.0.dist-info/top_level.txt +1 -0
tai42_cli/__init__.py
ADDED
|
File without changes
|
tai42_cli/app.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""The unified ``tai`` command.
|
|
2
|
+
|
|
3
|
+
One Typer app exposes the remote command groups — thin clients over a tai42
|
|
4
|
+
server's ``/api/*`` routes — plus the CLI-native ``completion`` and ``version``
|
|
5
|
+
commands. The console entry point ``app`` is the compiled click group.
|
|
6
|
+
|
|
7
|
+
Server-side and local commands (``serve``, ``db``, ``doctor``, ``backend`` …)
|
|
8
|
+
are contributed by the server package through the ``tai.commands`` entry-point
|
|
9
|
+
group: each entry point loads to a ``register(group: click.Group) -> None``
|
|
10
|
+
callable that mounts its commands onto the compiled root group. When only this
|
|
11
|
+
client is installed those commands are simply absent; the remote surface stands
|
|
12
|
+
on its own.
|
|
13
|
+
|
|
14
|
+
Registration lives here so each remote command module only fills its own Typer
|
|
15
|
+
app without editing this file. An extension's ``register`` receives the compiled
|
|
16
|
+
click group and may use click's own API on it, plus the two exported helpers:
|
|
17
|
+
:func:`inject_json_flag` (give a Typer-backed leaf the trailing ``--json`` form)
|
|
18
|
+
and :func:`mount_launcher` (mount a click launcher command under a name). The
|
|
19
|
+
seams the command bodies build on are the
|
|
20
|
+
:class:`~tai42_cli.context.AppContext` on the Typer context (for a configured
|
|
21
|
+
client), :class:`~tai42_cli.client.ApiClient`, and the
|
|
22
|
+
:mod:`tai42_cli.render` helpers.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from importlib.metadata import entry_points
|
|
27
|
+
from typing import Any, cast
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
import typer
|
|
31
|
+
from dotenv import load_dotenv
|
|
32
|
+
from typer.core import TyperGroup, TyperOption
|
|
33
|
+
from typer.main import get_command
|
|
34
|
+
|
|
35
|
+
from tai42_cli import completion, version
|
|
36
|
+
from tai42_cli.client import ApiError
|
|
37
|
+
from tai42_cli.commands import (
|
|
38
|
+
agents,
|
|
39
|
+
auth,
|
|
40
|
+
backup,
|
|
41
|
+
channels,
|
|
42
|
+
checkpoints,
|
|
43
|
+
config,
|
|
44
|
+
connectors,
|
|
45
|
+
conversations,
|
|
46
|
+
extensions,
|
|
47
|
+
fleet,
|
|
48
|
+
hooks,
|
|
49
|
+
interactions,
|
|
50
|
+
keys,
|
|
51
|
+
manifest,
|
|
52
|
+
mcp,
|
|
53
|
+
notifications,
|
|
54
|
+
obs,
|
|
55
|
+
plugins,
|
|
56
|
+
presets,
|
|
57
|
+
resources,
|
|
58
|
+
roles,
|
|
59
|
+
schedules,
|
|
60
|
+
scopes,
|
|
61
|
+
storage,
|
|
62
|
+
sub_mcp,
|
|
63
|
+
system,
|
|
64
|
+
templates,
|
|
65
|
+
tool_meta,
|
|
66
|
+
tools,
|
|
67
|
+
traces,
|
|
68
|
+
)
|
|
69
|
+
from tai42_cli.context import AppContext
|
|
70
|
+
|
|
71
|
+
# The entry-point group a server package publishes to contribute its local and
|
|
72
|
+
# runtime commands (``serve``/``db``/``doctor``/``backend`` …) to this CLI.
|
|
73
|
+
_EXTENSION_GROUP = "tai.commands"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TaiCLIGroup(TyperGroup):
|
|
77
|
+
"""Root group that bridges standard-``click`` outcomes into Typer's runner.
|
|
78
|
+
|
|
79
|
+
Typer bundles its own vendored copy of click, so Typer's runner only
|
|
80
|
+
recognises its vendored exception types. Contributed launcher commands (and
|
|
81
|
+
the native command modules) raise standard-``click`` exceptions, and remote
|
|
82
|
+
commands raise :class:`ApiError`. This override translates all of those into
|
|
83
|
+
Typer's own control-flow exceptions so they render cleanly — a server/usage
|
|
84
|
+
message on stderr with a non-zero exit — instead of a traceback."""
|
|
85
|
+
|
|
86
|
+
def invoke(self, ctx: Any) -> Any:
|
|
87
|
+
try:
|
|
88
|
+
return super().invoke(ctx)
|
|
89
|
+
except ApiError as exc:
|
|
90
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
91
|
+
raise typer.Exit(1) from exc
|
|
92
|
+
except click.exceptions.Exit as exc:
|
|
93
|
+
raise typer.Exit(exc.exit_code) from exc
|
|
94
|
+
except click.exceptions.Abort as exc:
|
|
95
|
+
raise typer.Abort() from exc
|
|
96
|
+
except click.ClickException as exc:
|
|
97
|
+
typer.echo(f"Error: {exc.format_message()}", err=True)
|
|
98
|
+
raise typer.Exit(exc.exit_code) from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _apply_json_flag(ctx: click.Context, _param: click.Parameter, value: bool | None) -> bool | None:
|
|
102
|
+
"""Merge a per-subcommand ``--json/--no-json`` into the shared context.
|
|
103
|
+
|
|
104
|
+
The root callback owns the flag-first form (``tai --json <cmd>``); this lets the
|
|
105
|
+
same flag ride AFTER the subcommand (``tai <cmd> --json``). ``value`` is ``None``
|
|
106
|
+
unless the flag was actually passed, so the root callback's setting stands when
|
|
107
|
+
the trailing flag is absent."""
|
|
108
|
+
if value is not None and isinstance(ctx.obj, AppContext):
|
|
109
|
+
ctx.obj.json_output = value
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _json_flag_option() -> TyperOption:
|
|
114
|
+
"""A fresh trailing ``--json/--no-json`` flag that merges into the context.
|
|
115
|
+
|
|
116
|
+
Built as a :class:`TyperOption` so its parse handling matches the Typer context
|
|
117
|
+
the compiled commands run under (a raw ``click.Option`` reads a context attribute
|
|
118
|
+
Typer's vendored context does not carry)."""
|
|
119
|
+
return TyperOption(
|
|
120
|
+
param_decls=["--json/--no-json"],
|
|
121
|
+
is_flag=True,
|
|
122
|
+
default=None,
|
|
123
|
+
expose_value=False,
|
|
124
|
+
callback=_apply_json_flag,
|
|
125
|
+
help="Emit raw JSON instead of human tables.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def inject_json_flag(command: click.Command) -> None:
|
|
130
|
+
"""Give a compiled command the trailing ``--json/--no-json`` form.
|
|
131
|
+
|
|
132
|
+
A group recurses into every subcommand so a leaf reached as ``tai <group> <cmd>``
|
|
133
|
+
accepts the flag in its own right (``tai tools list --json``), matching the root
|
|
134
|
+
callback's flag-first form; a single leaf command gets the flag directly. The flag
|
|
135
|
+
is only meaningful once, so groups keep the root form and only leaf commands carry
|
|
136
|
+
the trailing one.
|
|
137
|
+
|
|
138
|
+
A group is detected by its ``commands`` mapping rather than by ``isinstance`` —
|
|
139
|
+
Typer's compiled sub-groups are its own vendored ``Group`` type, not a
|
|
140
|
+
:class:`click.Group` subclass. Extensions apply this to their own Typer-backed
|
|
141
|
+
leaves after mounting them."""
|
|
142
|
+
subcommands = getattr(command, "commands", None)
|
|
143
|
+
if subcommands is not None:
|
|
144
|
+
for sub in subcommands.values():
|
|
145
|
+
inject_json_flag(cast(click.Command, sub))
|
|
146
|
+
else:
|
|
147
|
+
command.params.append(cast(click.Parameter, _json_flag_option()))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def mount_launcher(group: click.Group, launcher: click.Command, name: str) -> None:
|
|
151
|
+
"""Mount a click launcher command under its ``tai`` subcommand name.
|
|
152
|
+
|
|
153
|
+
The command's own ``name`` is the canonical subcommand name now, so it is set
|
|
154
|
+
here (the rich help lists commands by that name, not by the registration key).
|
|
155
|
+
Extensions use this to mount their raw click launcher commands onto the root
|
|
156
|
+
group."""
|
|
157
|
+
launcher.name = name
|
|
158
|
+
group.add_command(launcher)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
cli_app = typer.Typer(
|
|
162
|
+
name="tai",
|
|
163
|
+
cls=TaiCLIGroup,
|
|
164
|
+
help="Operate a tai42 server from the terminal.",
|
|
165
|
+
no_args_is_help=True,
|
|
166
|
+
add_completion=False,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@cli_app.callback()
|
|
171
|
+
def main(
|
|
172
|
+
ctx: typer.Context,
|
|
173
|
+
json_output: bool = typer.Option(
|
|
174
|
+
False,
|
|
175
|
+
"--json/--no-json",
|
|
176
|
+
help="Emit raw JSON instead of human tables.",
|
|
177
|
+
),
|
|
178
|
+
server: str | None = typer.Option(
|
|
179
|
+
None,
|
|
180
|
+
"--server",
|
|
181
|
+
metavar="URL",
|
|
182
|
+
help="Server base URL. Resolved: this flag -> TAI_SERVER_URL -> config.toml -> local default.",
|
|
183
|
+
),
|
|
184
|
+
api_key_stdin: bool = typer.Option(
|
|
185
|
+
False,
|
|
186
|
+
"--api-key-stdin",
|
|
187
|
+
help=(
|
|
188
|
+
"Read the API key as one line from stdin. Resolved: this flag -> TAI_API_KEY -> "
|
|
189
|
+
"config.toml -> interactive prompt. There is no --api-key VALUE flag (a value leaks "
|
|
190
|
+
"via ps and shell history)."
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
) -> None:
|
|
194
|
+
# Bootstrap a local ``.env`` once for the whole CLI so every subcommand — the
|
|
195
|
+
# remote client and any contributed launcher alike — sees it. ``TAI_CONFIG_MODE``
|
|
196
|
+
# is read straight from the environment (default ``file``), normalized and
|
|
197
|
+
# validated here so this client needs no server settings module. Under ``k8s`` the
|
|
198
|
+
# platform injects env directly and a local ``.env`` must not shadow it, so the
|
|
199
|
+
# load is skipped.
|
|
200
|
+
raw_mode = os.environ.get("TAI_CONFIG_MODE", "file")
|
|
201
|
+
config_mode = raw_mode.strip().lower()
|
|
202
|
+
if config_mode not in ("file", "k8s"):
|
|
203
|
+
raise typer.BadParameter(
|
|
204
|
+
f"Invalid TAI_CONFIG_MODE={raw_mode!r}. Must be one of: file, k8s",
|
|
205
|
+
param_hint="TAI_CONFIG_MODE",
|
|
206
|
+
)
|
|
207
|
+
if config_mode != "k8s":
|
|
208
|
+
load_dotenv()
|
|
209
|
+
ctx.obj = AppContext(json_output=json_output, server_override=server, api_key_stdin=api_key_stdin)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Remote command groups — thin clients over the ``/api/*`` routes.
|
|
213
|
+
_REMOTE_GROUPS: list[tuple[typer.Typer, str]] = [
|
|
214
|
+
(tools.app, "tools"),
|
|
215
|
+
(presets.app, "presets"),
|
|
216
|
+
(agents.app, "agents"),
|
|
217
|
+
(extensions.app, "extensions"),
|
|
218
|
+
(connectors.app, "connectors"),
|
|
219
|
+
(conversations.app, "conversations"),
|
|
220
|
+
(hooks.app, "hooks"),
|
|
221
|
+
(channels.app, "channels"),
|
|
222
|
+
(checkpoints.app, "checkpoints"),
|
|
223
|
+
(notifications.app, "notifications"),
|
|
224
|
+
(storage.app, "storage"),
|
|
225
|
+
(resources.app, "resources"),
|
|
226
|
+
(fleet.app, "fleet"),
|
|
227
|
+
(manifest.app, "manifest"),
|
|
228
|
+
(mcp.app, "mcp"),
|
|
229
|
+
(sub_mcp.app, "sub-mcp"),
|
|
230
|
+
(templates.app, "templates"),
|
|
231
|
+
(config.app, "config"),
|
|
232
|
+
(keys.app, "keys"),
|
|
233
|
+
(scopes.app, "scopes"),
|
|
234
|
+
(roles.app, "roles"),
|
|
235
|
+
(auth.app, "auth"),
|
|
236
|
+
(backup.app, "backup"),
|
|
237
|
+
(schedules.app, "schedules"),
|
|
238
|
+
(obs.app, "obs"),
|
|
239
|
+
(plugins.app, "plugins"),
|
|
240
|
+
(traces.app, "traces"),
|
|
241
|
+
(interactions.app, "interactions"),
|
|
242
|
+
(system.app, "system"),
|
|
243
|
+
(tool_meta.app, "tool-meta"),
|
|
244
|
+
]
|
|
245
|
+
for group_app, group_name in _REMOTE_GROUPS:
|
|
246
|
+
cli_app.add_typer(group_app, name=group_name)
|
|
247
|
+
|
|
248
|
+
# CLI-native pieces the remote client owns outright.
|
|
249
|
+
cli_app.add_typer(completion.app, name="completion")
|
|
250
|
+
cli_app.command(name="version")(version.version)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _discover_extensions(root: click.Group) -> None:
|
|
254
|
+
"""Load every ``tai.commands`` entry point and let it register its commands.
|
|
255
|
+
|
|
256
|
+
Each entry point loads to a ``register(group: click.Group) -> None`` callable and
|
|
257
|
+
is invoked with the compiled root group. Entry points are processed in sorted
|
|
258
|
+
entry-point-name order so contributed help order is deterministic. A failing load
|
|
259
|
+
or a failing ``register`` propagates — a broken contribution is never swallowed."""
|
|
260
|
+
discovered = sorted(entry_points(group=_EXTENSION_GROUP), key=lambda ep: ep.name)
|
|
261
|
+
for ep in discovered:
|
|
262
|
+
register = ep.load()
|
|
263
|
+
register(root)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _build_app() -> click.Group:
|
|
267
|
+
"""Compile the Typer app to a click group, give every native leaf the trailing
|
|
268
|
+
``--json`` form, then let installed extensions contribute their commands."""
|
|
269
|
+
command = cast(click.Group, get_command(cli_app))
|
|
270
|
+
# Inject the trailing ``--json`` form onto the native tree BEFORE extensions run;
|
|
271
|
+
# each extension applies :func:`inject_json_flag` to its own Typer-backed leaves.
|
|
272
|
+
inject_json_flag(command)
|
|
273
|
+
_discover_extensions(command)
|
|
274
|
+
return command
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
app = _build_app()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
app()
|
tai42_cli/client.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Shared HTTP client for the ``tai`` CLI's remote commands.
|
|
2
|
+
|
|
3
|
+
Every remote command talks to the server's ``/api/*`` surface — the SAME
|
|
4
|
+
routes the Studio calls — through this one client, so the envelope contract and
|
|
5
|
+
authentication live in exactly one place.
|
|
6
|
+
|
|
7
|
+
The wire contract:
|
|
8
|
+
|
|
9
|
+
* the api key travels in the ``x-api-key`` header;
|
|
10
|
+
* a success body is ``{"data": ...}`` and is unwrapped to the inner value;
|
|
11
|
+
* a failure body is ``{"error": "<message>"}`` and, together with the HTTP
|
|
12
|
+
status, is raised as a typed :class:`ApiError` (401/404/409/400 each get their
|
|
13
|
+
own subclass so callers can distinguish them);
|
|
14
|
+
* streaming runs are consumed frame by frame off an SSE response — never
|
|
15
|
+
buffered whole.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Iterator, Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiError(Exception):
|
|
26
|
+
"""A non-2xx response from the server API.
|
|
27
|
+
|
|
28
|
+
``message`` is the server's ``{"error": ...}`` text where present, else the
|
|
29
|
+
raw response body. ``status_code`` is the HTTP status.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, *, status_code: int) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthError(ApiError):
|
|
39
|
+
"""A 401 — the request carried no API key the server would accept."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFoundError(ApiError):
|
|
43
|
+
"""A 404 — the addressed resource does not exist."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConflictError(ApiError):
|
|
47
|
+
"""A 409 — the request conflicts with the current server state."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BadRequestError(ApiError):
|
|
51
|
+
"""A 400 — the server rejected the request payload."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_STATUS_ERRORS: dict[int, type[ApiError]] = {
|
|
55
|
+
400: BadRequestError,
|
|
56
|
+
401: AuthError,
|
|
57
|
+
404: NotFoundError,
|
|
58
|
+
409: ConflictError,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _server_message(response: httpx.Response) -> str | None:
|
|
63
|
+
"""The ``{"error": ...}`` message from a response body, or ``None`` when the
|
|
64
|
+
body is not the JSON error envelope."""
|
|
65
|
+
try:
|
|
66
|
+
body = response.json()
|
|
67
|
+
except (json.JSONDecodeError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
if isinstance(body, Mapping):
|
|
70
|
+
error = body.get("error")
|
|
71
|
+
if isinstance(error, str):
|
|
72
|
+
return error
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _error_from_response(response: httpx.Response) -> ApiError:
|
|
77
|
+
status = response.status_code
|
|
78
|
+
message = _server_message(response) or response.text.strip()
|
|
79
|
+
if status == 401:
|
|
80
|
+
detail = message or "no valid API key was accepted by the server"
|
|
81
|
+
return AuthError(f"not authenticated: {detail}", status_code=401)
|
|
82
|
+
if not message:
|
|
83
|
+
message = f"HTTP {status}"
|
|
84
|
+
error_cls = _STATUS_ERRORS.get(status, ApiError)
|
|
85
|
+
return error_cls(message, status_code=status)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _unwrap(response: httpx.Response) -> Any:
|
|
89
|
+
"""Return the ``data`` payload of a success response, raising on any failure
|
|
90
|
+
status or a malformed success envelope."""
|
|
91
|
+
if response.status_code >= 400:
|
|
92
|
+
raise _error_from_response(response)
|
|
93
|
+
if response.status_code == 204 or not response.content:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
body = response.json()
|
|
97
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
98
|
+
# A 2xx whose body is not JSON (a proxy's HTML page, say) is as malformed as
|
|
99
|
+
# a missing ``data`` key — surface it as the same typed error, not a raw decode.
|
|
100
|
+
raise ApiError(
|
|
101
|
+
f"malformed success envelope (body is not JSON): {response.text!r}",
|
|
102
|
+
status_code=response.status_code,
|
|
103
|
+
) from exc
|
|
104
|
+
if not isinstance(body, Mapping) or "data" not in body:
|
|
105
|
+
raise ApiError(
|
|
106
|
+
f"malformed success envelope (expected a 'data' key): {body!r}",
|
|
107
|
+
status_code=response.status_code,
|
|
108
|
+
)
|
|
109
|
+
return body["data"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def iter_sse_data(lines: Iterator[str]) -> Iterator[tuple[str | None, str]]:
|
|
113
|
+
"""Yield ``(event, data)`` for each SSE frame from a line iterator.
|
|
114
|
+
|
|
115
|
+
The server emits one JSON object per frame as a single ``data:`` line
|
|
116
|
+
terminated by a blank line. A frame's type may ride INSIDE that JSON (the
|
|
117
|
+
agents/runs stream sends no ``event:`` line) OR arrive OUT OF BAND on an
|
|
118
|
+
``event:`` line (the interactions stream), so the parser surfaces the
|
|
119
|
+
``event:`` value when present and yields ``None`` for it otherwise.
|
|
120
|
+
Comment/keepalive lines (a leading ``:``) and any other SSE fields are
|
|
121
|
+
ignored; multi-line ``data:`` values are joined with newlines.
|
|
122
|
+
"""
|
|
123
|
+
event: str | None = None
|
|
124
|
+
data_parts: list[str] = []
|
|
125
|
+
for line in lines:
|
|
126
|
+
if line == "":
|
|
127
|
+
if data_parts:
|
|
128
|
+
yield event, "\n".join(data_parts)
|
|
129
|
+
event = None
|
|
130
|
+
data_parts = []
|
|
131
|
+
continue
|
|
132
|
+
if line.startswith(":"):
|
|
133
|
+
continue
|
|
134
|
+
field, _, value = line.partition(":")
|
|
135
|
+
value = value[1:] if value.startswith(" ") else value
|
|
136
|
+
if field == "data":
|
|
137
|
+
data_parts.append(value)
|
|
138
|
+
elif field == "event":
|
|
139
|
+
event = value
|
|
140
|
+
if data_parts:
|
|
141
|
+
yield event, "\n".join(data_parts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ApiClient:
|
|
145
|
+
"""A thin httpx wrapper that owns the api-key header and the envelope.
|
|
146
|
+
|
|
147
|
+
Construct with a ``base_url`` and an ``api_key``; inject ``transport`` in tests
|
|
148
|
+
to serve responses from a fake without any network. ``api_key=None`` builds an
|
|
149
|
+
ANONYMOUS client that sends no credential header — the one public door the CLI
|
|
150
|
+
calls (``tai auth claim`` exchanging a claim token, which the caller has no key for
|
|
151
|
+
yet); every other command passes a real key.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
base_url: str,
|
|
157
|
+
api_key: str | None,
|
|
158
|
+
*,
|
|
159
|
+
transport: httpx.BaseTransport | None = None,
|
|
160
|
+
timeout: float = 30.0,
|
|
161
|
+
) -> None:
|
|
162
|
+
# No key → no ``x-api-key`` header at all, so a public route is never handed a
|
|
163
|
+
# stale/wrong credential (which its always-public middleware would ignore, but a
|
|
164
|
+
# protected route would 401 on).
|
|
165
|
+
headers = {"x-api-key": api_key} if api_key is not None else {}
|
|
166
|
+
self._client = httpx.Client(
|
|
167
|
+
base_url=base_url,
|
|
168
|
+
timeout=timeout,
|
|
169
|
+
transport=transport,
|
|
170
|
+
headers=headers,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def __enter__(self) -> "ApiClient":
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
177
|
+
self.close()
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
self._client.close()
|
|
181
|
+
|
|
182
|
+
def request(
|
|
183
|
+
self,
|
|
184
|
+
method: str,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
json: Any | None = None,
|
|
188
|
+
params: Mapping[str, Any] | None = None,
|
|
189
|
+
) -> Any:
|
|
190
|
+
response = self._client.request(method, path, json=json, params=params)
|
|
191
|
+
return _unwrap(response)
|
|
192
|
+
|
|
193
|
+
def request_raw(
|
|
194
|
+
self,
|
|
195
|
+
method: str,
|
|
196
|
+
path: str,
|
|
197
|
+
*,
|
|
198
|
+
json: Any | None = None,
|
|
199
|
+
params: Mapping[str, Any] | None = None,
|
|
200
|
+
) -> httpx.Response:
|
|
201
|
+
"""Perform the auth'd request and return the RAW response, unwrapping no
|
|
202
|
+
envelope, but still raising the typed error on a failure status.
|
|
203
|
+
|
|
204
|
+
The download routes (a bare backup document, a CSV/JSON export) answer
|
|
205
|
+
outside the ``{"data": ...}`` envelope, so their callers read the body
|
|
206
|
+
directly while keeping this module's auth, base URL, and typed errors.
|
|
207
|
+
"""
|
|
208
|
+
response = self._client.request(method, path, json=json, params=params)
|
|
209
|
+
if response.status_code >= 400:
|
|
210
|
+
raise _error_from_response(response)
|
|
211
|
+
return response
|
|
212
|
+
|
|
213
|
+
def get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
|
|
214
|
+
return self.request("GET", path, params=params)
|
|
215
|
+
|
|
216
|
+
def post(
|
|
217
|
+
self,
|
|
218
|
+
path: str,
|
|
219
|
+
*,
|
|
220
|
+
json: Any | None = None,
|
|
221
|
+
params: Mapping[str, Any] | None = None,
|
|
222
|
+
) -> Any:
|
|
223
|
+
return self.request("POST", path, json=json, params=params)
|
|
224
|
+
|
|
225
|
+
def patch(
|
|
226
|
+
self,
|
|
227
|
+
path: str,
|
|
228
|
+
*,
|
|
229
|
+
json: Any | None = None,
|
|
230
|
+
params: Mapping[str, Any] | None = None,
|
|
231
|
+
) -> Any:
|
|
232
|
+
return self.request("PATCH", path, json=json, params=params)
|
|
233
|
+
|
|
234
|
+
def put(
|
|
235
|
+
self,
|
|
236
|
+
path: str,
|
|
237
|
+
*,
|
|
238
|
+
json: Any | None = None,
|
|
239
|
+
params: Mapping[str, Any] | None = None,
|
|
240
|
+
) -> Any:
|
|
241
|
+
return self.request("PUT", path, json=json, params=params)
|
|
242
|
+
|
|
243
|
+
def delete(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
|
|
244
|
+
return self.request("DELETE", path, params=params)
|
|
245
|
+
|
|
246
|
+
def stream(
|
|
247
|
+
self,
|
|
248
|
+
method: str,
|
|
249
|
+
path: str,
|
|
250
|
+
*,
|
|
251
|
+
json: Any | None = None,
|
|
252
|
+
params: Mapping[str, Any] | None = None,
|
|
253
|
+
) -> Iterator[tuple[str | None, str]]:
|
|
254
|
+
"""Yield each SSE frame as ``(event, data)`` from a streaming run, incrementally.
|
|
255
|
+
|
|
256
|
+
The response body is consumed frame by frame — a run's output reaches
|
|
257
|
+
the caller as it arrives, never after the whole run completes. ``event``
|
|
258
|
+
is the frame's out-of-band ``event:`` type when the server sends one (the
|
|
259
|
+
interactions stream) and ``None`` otherwise (the runs stream, whose type
|
|
260
|
+
rides inside the JSON ``data``). A failure status raises the typed error
|
|
261
|
+
before any frame is yielded.
|
|
262
|
+
"""
|
|
263
|
+
with self._client.stream(
|
|
264
|
+
method,
|
|
265
|
+
path,
|
|
266
|
+
json=json,
|
|
267
|
+
params=params,
|
|
268
|
+
headers={"accept": "text/event-stream"},
|
|
269
|
+
) as response:
|
|
270
|
+
if response.status_code >= 400:
|
|
271
|
+
response.read()
|
|
272
|
+
raise _error_from_response(response)
|
|
273
|
+
yield from iter_sse_data(response.iter_lines())
|