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 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
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m otari_cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from otari_cli.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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,6 @@
1
+ """CLI command groups.
2
+
3
+ Each module exposes either a command function (``completion``, ``models``,
4
+ ``health``) or a Typer sub-app (``keys``, ``usage``) that
5
+ :mod:`otari_cli.cli` wires onto the root application.
6
+ """
@@ -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}.")