solwyn-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.
@@ -0,0 +1,110 @@
1
+ """Commands for inspecting and updating local non-secret configuration."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from pydantic import ValidationError
7
+
8
+ from solwyn_cli.config import (
9
+ CONFIG_KEYS,
10
+ Config,
11
+ ConfigLoadError,
12
+ ConfigWriteError,
13
+ load_config,
14
+ update_config_value,
15
+ )
16
+ from solwyn_cli.help import help_contract
17
+
18
+
19
+ app = typer.Typer(
20
+ help="Inspect and update local configuration.",
21
+ epilog=help_contract(
22
+ endpoint="local configuration router",
23
+ auth="local (none)",
24
+ exits="0 success; 2 usage",
25
+ schema="none (subcommands emit plain text)",
26
+ ),
27
+ )
28
+
29
+
30
+ def _validate_key(key: str) -> str:
31
+ if key not in CONFIG_KEYS:
32
+ raise typer.BadParameter(f"Invalid configuration key: {key}", param_hint="key")
33
+ return key
34
+
35
+
36
+ def _load_config_for_command() -> Config:
37
+ try:
38
+ return load_config()
39
+ except ConfigLoadError:
40
+ raise typer.BadParameter(
41
+ "Could not load configuration.", param_hint="config"
42
+ ) from None
43
+
44
+
45
+ @app.command(
46
+ "get",
47
+ epilog=help_contract(
48
+ endpoint="local configuration read",
49
+ auth="local (none)",
50
+ exits="0 success; 2 usage",
51
+ schema="none (plain text value)",
52
+ ),
53
+ )
54
+ def get_config_value(
55
+ key: Annotated[str, typer.Argument(help="Configuration key to read.")],
56
+ ) -> None:
57
+ """Print one configuration value."""
58
+ validated_key = _validate_key(key)
59
+ config = _load_config_for_command()
60
+ value = getattr(config, validated_key)
61
+ typer.echo("" if value is None else value)
62
+
63
+
64
+ @app.command(
65
+ "set",
66
+ epilog=help_contract(
67
+ endpoint="local configuration write",
68
+ auth="local (none)",
69
+ exits="0 success; 2 usage",
70
+ schema="none (human confirmation on stderr)",
71
+ ),
72
+ )
73
+ def set_config_value(
74
+ key: Annotated[str, typer.Argument(help="Configuration key to update.")],
75
+ value: Annotated[str, typer.Argument(help="New configuration value.")],
76
+ ) -> None:
77
+ """Validate and persist one configuration value."""
78
+ validated_key = _validate_key(key)
79
+ try:
80
+ update_config_value(validated_key, value)
81
+ except ValidationError:
82
+ raise typer.BadParameter(
83
+ f"Invalid value for {validated_key}.", param_hint="value"
84
+ ) from None
85
+ except ConfigLoadError:
86
+ raise typer.BadParameter(
87
+ "Could not load configuration.", param_hint="config"
88
+ ) from None
89
+ except ConfigWriteError:
90
+ raise typer.BadParameter(
91
+ "Could not write configuration.", param_hint="config"
92
+ ) from None
93
+ typer.echo(f"Set {validated_key}.", err=True)
94
+
95
+
96
+ @app.command(
97
+ "list",
98
+ epilog=help_contract(
99
+ endpoint="local configuration read",
100
+ auth="local (none)",
101
+ exits="0 success; 2 usage",
102
+ schema="none (plain text key/value list)",
103
+ ),
104
+ )
105
+ def list_config_values() -> None:
106
+ """Print every allowlisted configuration value in deterministic order."""
107
+ config = _load_config_for_command()
108
+ for key in sorted(CONFIG_KEYS):
109
+ value = getattr(config, key)
110
+ typer.echo(f"{key}={'' if value is None else value}")
@@ -0,0 +1,186 @@
1
+ """Aggregated project cost reporting command."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated, Literal
5
+ from urllib.parse import quote
6
+
7
+ import typer
8
+ from pydantic import BaseModel, ConfigDict, ValidationError
9
+
10
+ from solwyn_cli.auth import AuthError, resolve_credential
11
+ from solwyn_cli.client import SolwynClient
12
+ from solwyn_cli.commands._shared import exit_with_error, required_credential
13
+ from solwyn_cli.errors import ApiError
14
+ from solwyn_cli.exit_codes import ErrorSemantic
15
+ from solwyn_cli.help import help_contract
16
+ from solwyn_cli.output import OutputError, TableData
17
+ from solwyn_cli.runtime import command_context, output_engine
18
+
19
+
20
+ class CostRange(StrEnum):
21
+ """Supported aggregate windows frozen for the v1 command."""
22
+
23
+ SEVEN_DAYS = "7d"
24
+ THIRTY_DAYS = "30d"
25
+ NINETY_DAYS = "90d"
26
+ YEAR = "365d"
27
+
28
+
29
+ class CostGrouping(StrEnum):
30
+ """Supported aggregate dimensions frozen for the v1 command."""
31
+
32
+ DAY = "day"
33
+ MODEL = "model"
34
+ PROVIDER = "provider"
35
+
36
+
37
+ COSTS_HELP = help_contract(
38
+ endpoint="GET /api/v1/projects/{project}/costs",
39
+ auth="any",
40
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
41
+ schema="solwyn.costs.v1",
42
+ )
43
+
44
+
45
+ class _CostGroup(BaseModel):
46
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
47
+
48
+ group_key: str
49
+ provider: str | None = None
50
+ total_cost: float
51
+ total_input_tokens: int
52
+ total_output_tokens: int
53
+ call_count: int
54
+ unpriced_call_count: int = 0
55
+ local_call_count: int = 0
56
+
57
+
58
+ class _CostsData(BaseModel):
59
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
60
+
61
+ project_id: str
62
+ range: Literal["7d", "30d", "90d", "365d"]
63
+ group_by: Literal["day", "model", "provider"]
64
+ groups: list[_CostGroup]
65
+
66
+
67
+ CostsData = _CostsData
68
+
69
+
70
+ def fetch_costs(
71
+ *,
72
+ client: SolwynClient,
73
+ project_id: str,
74
+ time_range: CostRange,
75
+ group_by: CostGrouping,
76
+ ) -> CostsData:
77
+ """Read and validate one aggregate cost query through a shared client."""
78
+ project = quote(project_id, safe="")
79
+ response = client.request(
80
+ "GET",
81
+ f"/api/v1/projects/{project}/costs",
82
+ params={"range": time_range.value, "group_by": group_by.value},
83
+ )
84
+ data = _CostsData.model_validate(response.json())
85
+ if data.project_id != project_id:
86
+ raise ValueError("Costs response project did not match the request.")
87
+ return data
88
+
89
+
90
+ def _table(data: _CostsData) -> TableData:
91
+ return TableData(
92
+ columns=(
93
+ "Group",
94
+ "Provider",
95
+ "Cost",
96
+ "Input",
97
+ "Output",
98
+ "Calls",
99
+ "Unpriced",
100
+ "Local",
101
+ ),
102
+ rows=tuple(
103
+ (
104
+ group.group_key,
105
+ group.provider or "-",
106
+ f"{group.total_cost:.6f}",
107
+ str(group.total_input_tokens),
108
+ str(group.total_output_tokens),
109
+ str(group.call_count),
110
+ str(group.unpriced_call_count),
111
+ str(group.local_call_count),
112
+ )
113
+ for group in data.groups
114
+ ),
115
+ right_align=(
116
+ "Cost",
117
+ "Input",
118
+ "Output",
119
+ "Calls",
120
+ "Unpriced",
121
+ "Local",
122
+ ),
123
+ )
124
+
125
+
126
+ def costs(
127
+ context: typer.Context,
128
+ time_range: Annotated[
129
+ CostRange,
130
+ typer.Option("--range", help="Aggregation range."),
131
+ ] = CostRange.SEVEN_DAYS,
132
+ group_by: Annotated[
133
+ CostGrouping,
134
+ typer.Option("--group-by", help="Aggregation dimension."),
135
+ ] = CostGrouping.MODEL,
136
+ ) -> None:
137
+ """Show aggregate project costs."""
138
+ runtime = command_context(context)
139
+ if runtime.project is None:
140
+ exit_with_error(
141
+ context,
142
+ OutputError(
143
+ code="project_required",
144
+ message="A project is required.",
145
+ hint=(
146
+ "Choose a project with --project, SOLWYN_PROJECT_ID, "
147
+ "or config default_project."
148
+ ),
149
+ ),
150
+ semantic=ErrorSemantic.USAGE,
151
+ )
152
+
153
+ credential = required_credential(context, resolve=resolve_credential)
154
+ try:
155
+ with SolwynClient(
156
+ api_url=runtime.api_url,
157
+ credential=credential,
158
+ ) as client:
159
+ data = fetch_costs(
160
+ client=client,
161
+ project_id=runtime.project,
162
+ time_range=time_range,
163
+ group_by=group_by,
164
+ )
165
+ except ApiError as error:
166
+ exit_with_error(context, error)
167
+ except AuthError as error:
168
+ exit_with_error(
169
+ context,
170
+ OutputError(code="authentication_failed", message=str(error)),
171
+ semantic=ErrorSemantic.AUTH,
172
+ )
173
+ except (ValidationError, ValueError, TypeError):
174
+ exit_with_error(
175
+ context,
176
+ OutputError(
177
+ code="invalid_costs_response",
178
+ message="The costs response was invalid.",
179
+ ),
180
+ )
181
+
182
+ output_engine(context).write_success(
183
+ schema="solwyn.costs.v1",
184
+ data=data,
185
+ table=_table(data),
186
+ )