otari-cli 0.1.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.
- otari_cli/__init__.py +18 -0
- otari_cli/__main__.py +8 -0
- otari_cli/_client.py +43 -0
- otari_cli/_context.py +17 -0
- otari_cli/_errors.py +67 -0
- otari_cli/_output.py +91 -0
- otari_cli/_params.py +46 -0
- otari_cli/cli.py +103 -0
- otari_cli/commands/__init__.py +6 -0
- otari_cli/commands/batches.py +153 -0
- otari_cli/commands/budgets.py +96 -0
- otari_cli/commands/completion.py +83 -0
- otari_cli/commands/embedding.py +53 -0
- otari_cli/commands/health.py +50 -0
- otari_cli/commands/keys.py +120 -0
- otari_cli/commands/message.py +82 -0
- otari_cli/commands/models.py +28 -0
- otari_cli/commands/moderation.py +32 -0
- otari_cli/commands/pricing.py +108 -0
- otari_cli/commands/rerank.py +33 -0
- otari_cli/commands/response.py +74 -0
- otari_cli/commands/usage.py +53 -0
- otari_cli/commands/users.py +131 -0
- otari_cli/config.py +62 -0
- otari_cli/py.typed +0 -0
- otari_cli-0.1.0.dist-info/METADATA +167 -0
- otari_cli-0.1.0.dist-info/RECORD +30 -0
- otari_cli-0.1.0.dist-info/WHEEL +4 -0
- otari_cli-0.1.0.dist-info/entry_points.txt +2 -0
- otari_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
otari_cli/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""otari-cli - command-line interface for the otari LLM gateway and platform.
|
|
2
|
+
|
|
3
|
+
The CLI is a thin shell over the :mod:`otari` Python client SDK. It resolves
|
|
4
|
+
connection settings from flags or the same environment variables the SDK reads,
|
|
5
|
+
then maps subcommands onto the SDK's :class:`otari.OtariClient` and its
|
|
6
|
+
control-plane resources.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("otari-cli")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
__version__ = "0.0.0-dev"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__version__"]
|
otari_cli/__main__.py
ADDED
otari_cli/_client.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Construction of the :class:`otari.OtariClient` from resolved CLI config.
|
|
2
|
+
|
|
3
|
+
Command modules call :func:`build_client` indirectly (via the module, e.g.
|
|
4
|
+
``_client.build_client(...)``) so tests can substitute a fake client by
|
|
5
|
+
monkeypatching this single seam.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from otari import OtariClient
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
from otari_cli.config import OtariConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_client(config: OtariConfig) -> OtariClient:
|
|
22
|
+
"""Build an :class:`otari.OtariClient` from resolved connection settings."""
|
|
23
|
+
return OtariClient(
|
|
24
|
+
api_base=config.api_base,
|
|
25
|
+
api_key=config.api_key,
|
|
26
|
+
platform_token=config.platform_token,
|
|
27
|
+
admin_key=config.admin_key,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextlib.contextmanager
|
|
32
|
+
def session(config: OtariConfig) -> Iterator[OtariClient]:
|
|
33
|
+
"""Yield a client built from ``config``, closing it on exit.
|
|
34
|
+
|
|
35
|
+
Construct the client lazily inside the ``with`` body so that a
|
|
36
|
+
configuration error (the SDK raises ``ValueError`` for a missing api_base)
|
|
37
|
+
is raised within any surrounding :func:`otari_cli._errors.handle_errors`.
|
|
38
|
+
"""
|
|
39
|
+
client = build_client(config)
|
|
40
|
+
try:
|
|
41
|
+
yield client
|
|
42
|
+
finally:
|
|
43
|
+
client.close()
|
otari_cli/_context.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Per-invocation application state stored on the Typer context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from otari_cli.config import OtariConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AppContext:
|
|
14
|
+
"""State shared across the root callback and every subcommand."""
|
|
15
|
+
|
|
16
|
+
config: OtariConfig
|
|
17
|
+
output_json: bool = False
|
otari_cli/_errors.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Mapping of SDK errors to clean CLI messages and process exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from otari.errors import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
GatewayTimeoutError,
|
|
12
|
+
InsufficientFundsError,
|
|
13
|
+
ModelNotFoundError,
|
|
14
|
+
OtariError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
UpstreamProviderError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from otari_cli._output import error_console
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
|
|
24
|
+
# Exit codes. 1 is the catch-all; the rest let scripts branch on failure mode.
|
|
25
|
+
EXIT_ERROR = 1
|
|
26
|
+
EXIT_AUTH = 2
|
|
27
|
+
EXIT_NOT_FOUND = 3
|
|
28
|
+
EXIT_RATE_LIMIT = 4
|
|
29
|
+
EXIT_FUNDS = 5
|
|
30
|
+
EXIT_UPSTREAM = 6
|
|
31
|
+
|
|
32
|
+
# Ordered most-specific first; every entry is a distinct OtariError subclass.
|
|
33
|
+
_EXIT_CODES: list[tuple[type[OtariError], int]] = [
|
|
34
|
+
(AuthenticationError, EXIT_AUTH),
|
|
35
|
+
(ModelNotFoundError, EXIT_NOT_FOUND),
|
|
36
|
+
(RateLimitError, EXIT_RATE_LIMIT),
|
|
37
|
+
(InsufficientFundsError, EXIT_FUNDS),
|
|
38
|
+
(UpstreamProviderError, EXIT_UPSTREAM),
|
|
39
|
+
(GatewayTimeoutError, EXIT_UPSTREAM),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def exit_code_for(exc: OtariError) -> int:
|
|
44
|
+
"""Return the process exit code for a given otari error."""
|
|
45
|
+
for exc_type, code in _EXIT_CODES:
|
|
46
|
+
if isinstance(exc, exc_type):
|
|
47
|
+
return code
|
|
48
|
+
return EXIT_ERROR
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextlib.contextmanager
|
|
52
|
+
def handle_errors() -> Iterator[None]:
|
|
53
|
+
"""Convert any :class:`otari.errors.OtariError` into a clean CLI failure.
|
|
54
|
+
|
|
55
|
+
The error message is printed to stderr and the process exits with the code
|
|
56
|
+
from :func:`exit_code_for`.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
yield
|
|
60
|
+
except OtariError as exc:
|
|
61
|
+
error_console().print(f"[bold red]Error:[/] {exc}")
|
|
62
|
+
raise typer.Exit(exit_code_for(exc)) from exc
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
# The SDK raises ValueError for missing or invalid configuration, e.g.
|
|
65
|
+
# a missing api_base in self-hosted mode. Surface it without a traceback.
|
|
66
|
+
error_console().print(f"[bold red]Error:[/] {exc}")
|
|
67
|
+
raise typer.Exit(EXIT_ERROR) from exc
|
otari_cli/_output.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Rendering helpers: Rich tables and text for humans, JSON for machines.
|
|
2
|
+
|
|
3
|
+
Every command honors the global ``--json`` flag. Human output uses Rich; JSON
|
|
4
|
+
output is a best-effort conversion of SDK models (pydantic v2 models and
|
|
5
|
+
dataclasses) to plain JSON-serializable data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
_STDOUT = Console()
|
|
18
|
+
_STDERR = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
# Cells longer than this are truncated in tables so rows stay readable.
|
|
21
|
+
_MAX_CELL = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def console() -> Console:
|
|
25
|
+
"""Return the shared stdout console."""
|
|
26
|
+
return _STDOUT
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def error_console() -> Console:
|
|
30
|
+
"""Return the shared stderr console (used for error messages)."""
|
|
31
|
+
return _STDERR
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_jsonable(value: Any) -> Any:
|
|
35
|
+
"""Best-effort conversion of SDK models to JSON-serializable data."""
|
|
36
|
+
if hasattr(value, "model_dump"):
|
|
37
|
+
return value.model_dump(mode="json")
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return [to_jsonable(item) for item in value]
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
return {key: to_jsonable(item) for key, item in value.items()}
|
|
42
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
43
|
+
return dataclasses.asdict(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_json(value: Any) -> None:
|
|
48
|
+
"""Print ``value`` as pretty JSON, converting SDK models as needed."""
|
|
49
|
+
console().print_json(json.dumps(to_jsonable(value), default=str))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _as_record(value: Any) -> dict[str, Any]:
|
|
53
|
+
"""Coerce a single converted item into a flat mapping for table rows."""
|
|
54
|
+
jsonable = to_jsonable(value)
|
|
55
|
+
if isinstance(jsonable, dict):
|
|
56
|
+
return jsonable
|
|
57
|
+
return {"value": jsonable}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_cell(value: Any) -> str:
|
|
61
|
+
if value is None:
|
|
62
|
+
return ""
|
|
63
|
+
text = json.dumps(value, default=str) if isinstance(value, (dict, list)) else str(value)
|
|
64
|
+
if len(text) > _MAX_CELL:
|
|
65
|
+
return text[: _MAX_CELL - 1] + "…"
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_records(
|
|
70
|
+
records: list[Any],
|
|
71
|
+
*,
|
|
72
|
+
output_json: bool,
|
|
73
|
+
title: str,
|
|
74
|
+
empty_message: str,
|
|
75
|
+
columns: list[str] | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Render a list of SDK models as a JSON array or a Rich table."""
|
|
78
|
+
if output_json:
|
|
79
|
+
print_json(records)
|
|
80
|
+
return
|
|
81
|
+
if not records:
|
|
82
|
+
console().print(empty_message)
|
|
83
|
+
return
|
|
84
|
+
rows = [_as_record(record) for record in records]
|
|
85
|
+
table_columns = columns or list(rows[0].keys())
|
|
86
|
+
table = Table(title=title)
|
|
87
|
+
for column in table_columns:
|
|
88
|
+
table.add_column(column, overflow="fold")
|
|
89
|
+
for row in rows:
|
|
90
|
+
table.add_row(*[_format_cell(row.get(column)) for column in table_columns])
|
|
91
|
+
console().print(table)
|
otari_cli/_params.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared parsing of CLI option strings into structured values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def drop_none(**fields: Any) -> dict[str, Any]:
|
|
13
|
+
"""Return only the keyword arguments whose value is not ``None``.
|
|
14
|
+
|
|
15
|
+
Used to build control-plane request models from just the options the user
|
|
16
|
+
provided. Fields the user omits stay out of the model's ``model_fields_set``
|
|
17
|
+
and are not serialized, so an ``update`` never clears a field the user did
|
|
18
|
+
not mention (the generated request models serialize explicit ``None``).
|
|
19
|
+
"""
|
|
20
|
+
return {key: value for key, value in fields.items() if value is not None}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_json_object(value: str | None, *, flag: str) -> dict[str, Any] | None:
|
|
24
|
+
"""Parse a JSON-object option (e.g. ``--metadata '{"team": "ml"}'``)."""
|
|
25
|
+
if value is None:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
parsed = json.loads(value)
|
|
29
|
+
except json.JSONDecodeError as exc:
|
|
30
|
+
msg = f"{flag} must be valid JSON"
|
|
31
|
+
raise typer.BadParameter(msg) from exc
|
|
32
|
+
if not isinstance(parsed, dict):
|
|
33
|
+
msg = f"{flag} must be a JSON object"
|
|
34
|
+
raise typer.BadParameter(msg)
|
|
35
|
+
return parsed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_datetime(value: str | None, *, flag: str) -> datetime | None:
|
|
39
|
+
"""Parse an ISO-8601 date/time option."""
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return datetime.fromisoformat(value)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
msg = f"{flag} must be an ISO-8601 date/time (e.g. 2026-01-31 or 2026-01-31T12:00:00)"
|
|
46
|
+
raise typer.BadParameter(msg) from exc
|
otari_cli/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Root Typer application: global options and command wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from otari_cli import __version__
|
|
8
|
+
from otari_cli._context import AppContext
|
|
9
|
+
from otari_cli.commands import (
|
|
10
|
+
batches,
|
|
11
|
+
budgets,
|
|
12
|
+
completion,
|
|
13
|
+
embedding,
|
|
14
|
+
health,
|
|
15
|
+
keys,
|
|
16
|
+
message,
|
|
17
|
+
models,
|
|
18
|
+
moderation,
|
|
19
|
+
pricing,
|
|
20
|
+
rerank,
|
|
21
|
+
response,
|
|
22
|
+
usage,
|
|
23
|
+
users,
|
|
24
|
+
)
|
|
25
|
+
from otari_cli.config import OtariConfig
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="otari",
|
|
29
|
+
help="Command-line interface for the otari LLM gateway and platform.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
add_completion=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Generation / inference commands.
|
|
35
|
+
app.command("completion")(completion.completion)
|
|
36
|
+
app.command("response")(response.response)
|
|
37
|
+
app.command("message")(message.message)
|
|
38
|
+
app.command("embedding")(embedding.embedding)
|
|
39
|
+
app.command("moderation")(moderation.moderation)
|
|
40
|
+
app.command("rerank")(rerank.rerank)
|
|
41
|
+
app.command("models")(models.models)
|
|
42
|
+
app.command("health")(health.health)
|
|
43
|
+
app.add_typer(batches.app, name="batches")
|
|
44
|
+
|
|
45
|
+
# Control-plane (management) command groups.
|
|
46
|
+
app.add_typer(keys.app, name="keys")
|
|
47
|
+
app.add_typer(users.app, name="users")
|
|
48
|
+
app.add_typer(budgets.app, name="budgets")
|
|
49
|
+
app.add_typer(pricing.app, name="pricing")
|
|
50
|
+
app.add_typer(usage.app, name="usage")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _version_callback(value: bool) -> None:
|
|
54
|
+
if value:
|
|
55
|
+
typer.echo(__version__)
|
|
56
|
+
raise typer.Exit
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback()
|
|
60
|
+
def main_callback(
|
|
61
|
+
ctx: typer.Context,
|
|
62
|
+
api_base: str | None = typer.Option(
|
|
63
|
+
None, "--api-base", help="Gateway base URL (env: GATEWAY_API_BASE)."
|
|
64
|
+
),
|
|
65
|
+
api_key: str | None = typer.Option(
|
|
66
|
+
None, "--api-key", help="Self-hosted API key (env: GATEWAY_API_KEY)."
|
|
67
|
+
),
|
|
68
|
+
token: str | None = typer.Option(
|
|
69
|
+
None, "--token", help="Platform token (env: OTARI_AI_TOKEN)."
|
|
70
|
+
),
|
|
71
|
+
admin_key: str | None = typer.Option(
|
|
72
|
+
None, "--admin-key", help="Admin key for control-plane commands (env: GATEWAY_ADMIN_KEY)."
|
|
73
|
+
),
|
|
74
|
+
output_json: bool = typer.Option(
|
|
75
|
+
False, "--json", help="Emit JSON instead of human-readable output."
|
|
76
|
+
),
|
|
77
|
+
version: bool = typer.Option( # noqa: ARG001 (consumed by the eager callback)
|
|
78
|
+
False,
|
|
79
|
+
"--version",
|
|
80
|
+
help="Show the otari-cli version and exit.",
|
|
81
|
+
is_eager=True,
|
|
82
|
+
callback=_version_callback,
|
|
83
|
+
),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Resolve connection settings and store shared state on the context."""
|
|
86
|
+
ctx.obj = AppContext(
|
|
87
|
+
config=OtariConfig.resolve(
|
|
88
|
+
api_base=api_base,
|
|
89
|
+
api_key=api_key,
|
|
90
|
+
token=token,
|
|
91
|
+
admin_key=admin_key,
|
|
92
|
+
),
|
|
93
|
+
output_json=output_json,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
"""Console-script entry point (``otari``)."""
|
|
99
|
+
app()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""``otari batches`` - manage batch jobs.
|
|
2
|
+
|
|
3
|
+
The provider (for example ``openai``) is required to retrieve, cancel, list, or
|
|
4
|
+
fetch results, mirroring the SDK. ``create`` reads a JSONL request file where
|
|
5
|
+
each line is a JSON object with ``custom_id`` and ``body`` keys.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path # noqa: TC003 (Typer resolves the Path annotation at runtime)
|
|
12
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from otari_cli import _client
|
|
17
|
+
from otari_cli._errors import handle_errors
|
|
18
|
+
from otari_cli._output import console, print_json, render_records
|
|
19
|
+
from otari_cli._params import parse_json_object
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from otari import CreateBatchParams, ListBatchesOptions
|
|
23
|
+
|
|
24
|
+
from otari_cli._context import AppContext
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="batches",
|
|
28
|
+
help="Create and manage batch jobs.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_requests(path: Path) -> list[dict[str, Any]]:
|
|
34
|
+
requests: list[dict[str, Any]] = []
|
|
35
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
36
|
+
stripped = line.strip()
|
|
37
|
+
if not stripped:
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
entry = json.loads(stripped)
|
|
41
|
+
except json.JSONDecodeError as exc:
|
|
42
|
+
msg = f"--input line {lineno} is not valid JSON"
|
|
43
|
+
raise typer.BadParameter(msg) from exc
|
|
44
|
+
if not isinstance(entry, dict) or "custom_id" not in entry or "body" not in entry:
|
|
45
|
+
msg = f"--input line {lineno} must be a JSON object with 'custom_id' and 'body'"
|
|
46
|
+
raise typer.BadParameter(msg)
|
|
47
|
+
requests.append({"custom_id": entry["custom_id"], "body": entry["body"]})
|
|
48
|
+
if not requests:
|
|
49
|
+
msg = "--input contained no request lines"
|
|
50
|
+
raise typer.BadParameter(msg)
|
|
51
|
+
return requests
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("create")
|
|
55
|
+
def create_batch(
|
|
56
|
+
ctx: typer.Context,
|
|
57
|
+
input_file: Path = typer.Option(
|
|
58
|
+
...,
|
|
59
|
+
"--input",
|
|
60
|
+
"-i",
|
|
61
|
+
exists=True,
|
|
62
|
+
readable=True,
|
|
63
|
+
dir_okay=False,
|
|
64
|
+
help="JSONL file of requests, one {custom_id, body} object per line.",
|
|
65
|
+
),
|
|
66
|
+
model: str = typer.Option(..., "--model", "-m", help="Model id, e.g. 'openai:gpt-4o-mini'."),
|
|
67
|
+
completion_window: str | None = typer.Option(None, "--completion-window", help="Completion window, e.g. '24h'."),
|
|
68
|
+
metadata: str | None = typer.Option(None, "--metadata", help="Metadata as a JSON object."),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Create a batch job from a JSONL request file."""
|
|
71
|
+
app_ctx: AppContext = ctx.obj
|
|
72
|
+
params: dict[str, Any] = {"model": model, "requests": _load_requests(input_file)}
|
|
73
|
+
if completion_window is not None:
|
|
74
|
+
params["completion_window"] = completion_window
|
|
75
|
+
parsed_metadata = parse_json_object(metadata, flag="--metadata")
|
|
76
|
+
if parsed_metadata is not None:
|
|
77
|
+
params["metadata"] = parsed_metadata
|
|
78
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
79
|
+
result = client.create_batch(cast("CreateBatchParams", params))
|
|
80
|
+
print_json(result)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("retrieve")
|
|
84
|
+
def retrieve_batch(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
batch_id: str = typer.Argument(..., help="Identifier of the batch."),
|
|
87
|
+
provider: str = typer.Option(..., "--provider", help="Provider that owns the batch, e.g. 'openai'."),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Retrieve the status of a batch job."""
|
|
90
|
+
app_ctx: AppContext = ctx.obj
|
|
91
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
92
|
+
result = client.retrieve_batch(batch_id, provider)
|
|
93
|
+
print_json(result)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command("list")
|
|
97
|
+
def list_batches(
|
|
98
|
+
ctx: typer.Context,
|
|
99
|
+
provider: str = typer.Option(..., "--provider", help="Provider to list batches for, e.g. 'openai'."),
|
|
100
|
+
after: str | None = typer.Option(None, "--after", help="Return batches after this id."),
|
|
101
|
+
limit: int | None = typer.Option(None, "--limit", help="Maximum number of batches to return."),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""List batch jobs for a provider."""
|
|
104
|
+
app_ctx: AppContext = ctx.obj
|
|
105
|
+
options: dict[str, Any] = {}
|
|
106
|
+
if after is not None:
|
|
107
|
+
options["after"] = after
|
|
108
|
+
if limit is not None:
|
|
109
|
+
options["limit"] = limit
|
|
110
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
111
|
+
result = client.list_batches(provider, cast("ListBatchesOptions", options))
|
|
112
|
+
render_records(
|
|
113
|
+
list(result),
|
|
114
|
+
output_json=app_ctx.output_json,
|
|
115
|
+
title="Batches",
|
|
116
|
+
empty_message="No batches found.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("cancel")
|
|
121
|
+
def cancel_batch(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
batch_id: str = typer.Argument(..., help="Identifier of the batch to cancel."),
|
|
124
|
+
provider: str = typer.Option(..., "--provider", help="Provider that owns the batch, e.g. 'openai'."),
|
|
125
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Cancel a batch job."""
|
|
128
|
+
app_ctx: AppContext = ctx.obj
|
|
129
|
+
if not yes:
|
|
130
|
+
typer.confirm(f"Cancel batch {batch_id!r}?", abort=True)
|
|
131
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
132
|
+
result = client.cancel_batch(batch_id, provider)
|
|
133
|
+
print_json(result)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("results")
|
|
137
|
+
def batch_results(
|
|
138
|
+
ctx: typer.Context,
|
|
139
|
+
batch_id: str = typer.Argument(..., help="Identifier of the completed batch."),
|
|
140
|
+
provider: str = typer.Option(..., "--provider", help="Provider that owns the batch, e.g. 'openai'."),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Retrieve the results of a completed batch job."""
|
|
143
|
+
app_ctx: AppContext = ctx.obj
|
|
144
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
145
|
+
result = client.retrieve_batch_results(batch_id, provider)
|
|
146
|
+
if app_ctx.output_json:
|
|
147
|
+
print_json(result)
|
|
148
|
+
return
|
|
149
|
+
items = getattr(result, "results", None) or []
|
|
150
|
+
if not items:
|
|
151
|
+
console().print("No results in batch.")
|
|
152
|
+
return
|
|
153
|
+
render_records(list(items), output_json=False, title="Batch results", empty_message="No results in batch.")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""``otari budgets`` - manage spending budgets (control plane).
|
|
2
|
+
|
|
3
|
+
Requires an admin credential and a self-hosted/standalone gateway.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from otari._client import CreateBudgetRequest, UpdateBudgetRequest
|
|
12
|
+
|
|
13
|
+
from otari_cli import _client
|
|
14
|
+
from otari_cli._errors import handle_errors
|
|
15
|
+
from otari_cli._output import console, print_json, render_records
|
|
16
|
+
from otari_cli._params import drop_none
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from otari_cli._context import AppContext
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="budgets",
|
|
23
|
+
help="Manage spending budgets (admin / self-hosted only).",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("list")
|
|
29
|
+
def list_budgets(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
skip: int | None = typer.Option(None, "--skip", help="Number of budgets to skip."),
|
|
32
|
+
limit: int | None = typer.Option(None, "--limit", help="Maximum number of budgets to return."),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""List budgets."""
|
|
35
|
+
app_ctx: AppContext = ctx.obj
|
|
36
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
37
|
+
result = client.control_plane.budgets.list(skip=skip, limit=limit)
|
|
38
|
+
render_records(list(result), output_json=app_ctx.output_json, title="Budgets", empty_message="No budgets found.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("get")
|
|
42
|
+
def get_budget(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
budget_id: str = typer.Argument(..., help="Identifier of the budget."),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Show details for a single budget."""
|
|
47
|
+
app_ctx: AppContext = ctx.obj
|
|
48
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
49
|
+
result = client.control_plane.budgets.get(budget_id)
|
|
50
|
+
print_json(result)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("create")
|
|
54
|
+
def create_budget(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
max_budget: float = typer.Option(..., "--max-budget", help="Maximum spending limit."),
|
|
57
|
+
duration_sec: int | None = typer.Option(
|
|
58
|
+
None, "--duration-sec", help="Budget window in seconds (e.g. 86400 for daily)."
|
|
59
|
+
),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Create a budget."""
|
|
62
|
+
app_ctx: AppContext = ctx.obj
|
|
63
|
+
request = CreateBudgetRequest(max_budget=max_budget, **drop_none(budget_duration_sec=duration_sec))
|
|
64
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
65
|
+
result = client.control_plane.budgets.create(request)
|
|
66
|
+
print_json(result)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("update")
|
|
70
|
+
def update_budget(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
budget_id: str = typer.Argument(..., help="Identifier of the budget to update."),
|
|
73
|
+
max_budget: float | None = typer.Option(None, "--max-budget", help="New maximum spending limit."),
|
|
74
|
+
duration_sec: int | None = typer.Option(None, "--duration-sec", help="New budget window in seconds."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Update a budget. Only the provided fields are changed."""
|
|
77
|
+
app_ctx: AppContext = ctx.obj
|
|
78
|
+
request = UpdateBudgetRequest(**drop_none(max_budget=max_budget, budget_duration_sec=duration_sec))
|
|
79
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
80
|
+
result = client.control_plane.budgets.update(budget_id, request)
|
|
81
|
+
print_json(result)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("delete")
|
|
85
|
+
def delete_budget(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
budget_id: str = typer.Argument(..., help="Identifier of the budget to delete."),
|
|
88
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Delete a budget."""
|
|
91
|
+
app_ctx: AppContext = ctx.obj
|
|
92
|
+
if not yes:
|
|
93
|
+
typer.confirm(f"Delete budget {budget_id!r}?", abort=True)
|
|
94
|
+
with handle_errors(), _client.session(app_ctx.config) as client:
|
|
95
|
+
client.control_plane.budgets.delete(budget_id)
|
|
96
|
+
console().print(f"[bold green]Deleted[/] budget {budget_id!r}.")
|