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.
- solwyn_cli/__init__.py +3 -0
- solwyn_cli/_vendor/__init__.py +1 -0
- solwyn_cli/_vendor/validation.py +21 -0
- solwyn_cli/_vendor/wire.py +185 -0
- solwyn_cli/artifacts/__init__.py +727 -0
- solwyn_cli/artifacts/_atomic.py +79 -0
- solwyn_cli/auth.py +252 -0
- solwyn_cli/client.py +279 -0
- solwyn_cli/commands/__init__.py +1 -0
- solwyn_cli/commands/_shared.py +49 -0
- solwyn_cli/commands/agent_context.py +19 -0
- solwyn_cli/commands/budget.py +976 -0
- solwyn_cli/commands/config.py +110 -0
- solwyn_cli/commands/costs.py +186 -0
- solwyn_cli/commands/init.py +659 -0
- solwyn_cli/commands/keys.py +386 -0
- solwyn_cli/commands/login.py +205 -0
- solwyn_cli/commands/mcp.py +28 -0
- solwyn_cli/commands/projects.py +449 -0
- solwyn_cli/commands/status.py +173 -0
- solwyn_cli/commands/whoami.py +170 -0
- solwyn_cli/config.py +209 -0
- solwyn_cli/errors.py +114 -0
- solwyn_cli/exit_codes.py +56 -0
- solwyn_cli/help.py +19 -0
- solwyn_cli/main.py +135 -0
- solwyn_cli/mcp/__init__.py +5 -0
- solwyn_cli/mcp/server.py +728 -0
- solwyn_cli/output.py +263 -0
- solwyn_cli/runtime.py +115 -0
- solwyn_cli/spec/__init__.py +1 -0
- solwyn_cli/spec/openapi_snapshot.json +12357 -0
- solwyn_cli-0.1.0.dist-info/METADATA +60 -0
- solwyn_cli-0.1.0.dist-info/RECORD +37 -0
- solwyn_cli-0.1.0.dist-info/WHEEL +4 -0
- solwyn_cli-0.1.0.dist-info/entry_points.txt +3 -0
- solwyn_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Project creation and metadata inspection commands."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import (
|
|
9
|
+
AwareDatetime,
|
|
10
|
+
BaseModel,
|
|
11
|
+
ConfigDict,
|
|
12
|
+
Field,
|
|
13
|
+
SecretStr,
|
|
14
|
+
TypeAdapter,
|
|
15
|
+
ValidationError,
|
|
16
|
+
field_serializer,
|
|
17
|
+
field_validator,
|
|
18
|
+
model_validator,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from solwyn_cli._vendor.validation import validate_project_key_format
|
|
22
|
+
from solwyn_cli._vendor.wire import BudgetMode, BudgetPeriod, ProjectCreateRequest
|
|
23
|
+
from solwyn_cli.auth import (
|
|
24
|
+
AuthError,
|
|
25
|
+
CredentialStorageError,
|
|
26
|
+
JwtCredential,
|
|
27
|
+
ProjectKeyCredential,
|
|
28
|
+
resolve_credential,
|
|
29
|
+
store_project_key,
|
|
30
|
+
)
|
|
31
|
+
from solwyn_cli.client import SolwynClient
|
|
32
|
+
from solwyn_cli.commands._shared import exit_with_error, required_credential
|
|
33
|
+
from solwyn_cli.errors import ApiError
|
|
34
|
+
from solwyn_cli.exit_codes import ErrorSemantic
|
|
35
|
+
from solwyn_cli.help import help_contract
|
|
36
|
+
from solwyn_cli.output import OneTimeSecretPanel, OutputError, TableData
|
|
37
|
+
from solwyn_cli.runtime import command_context, output_engine
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
help="Inspect Solwyn projects.",
|
|
42
|
+
epilog=help_contract(
|
|
43
|
+
endpoint="composite router for project subcommands",
|
|
44
|
+
auth="varies by subcommand",
|
|
45
|
+
exits="0 success; 2 usage",
|
|
46
|
+
schema="varies by subcommand",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Project(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
53
|
+
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
budget_limit: float
|
|
57
|
+
budget_period: str
|
|
58
|
+
budget_mode: str
|
|
59
|
+
created_at: AwareDatetime
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _ProjectsListData(BaseModel):
|
|
63
|
+
model_config = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
projects: list[_Project]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_PROJECTS = TypeAdapter(list[_Project])
|
|
69
|
+
Project = _Project
|
|
70
|
+
ProjectsListData = _ProjectsListData
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _CreatedProject(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
75
|
+
|
|
76
|
+
id: str = Field(min_length=1)
|
|
77
|
+
name: str = Field(min_length=1)
|
|
78
|
+
budget_limit: float = Field(gt=0, allow_inf_nan=False)
|
|
79
|
+
budget_period: BudgetPeriod
|
|
80
|
+
budget_mode: BudgetMode
|
|
81
|
+
created_at: AwareDatetime
|
|
82
|
+
key_prefix: str = Field(pattern=r"^sk_proj_[a-f0-9]{16}$")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _ProjectCreateResponse(_CreatedProject):
|
|
86
|
+
key: SecretStr
|
|
87
|
+
|
|
88
|
+
@field_validator("key")
|
|
89
|
+
@classmethod
|
|
90
|
+
def validate_key(cls, value: SecretStr) -> SecretStr:
|
|
91
|
+
validate_project_key_format(value.get_secret_value())
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def validate_key_prefix(self) -> "_ProjectCreateResponse":
|
|
96
|
+
raw_key = self.key.get_secret_value()
|
|
97
|
+
if self.key_prefix != raw_key[:24]:
|
|
98
|
+
raise ValueError("Invalid project key prefix")
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ProjectCreateResponse = _ProjectCreateResponse
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fetch_projects(*, client: SolwynClient) -> list[Project]:
|
|
106
|
+
"""Fetch and validate the projects visible to a JWT credential."""
|
|
107
|
+
response = client.request("GET", "/api/v1/projects")
|
|
108
|
+
return _PROJECTS.validate_python(response.json())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def request_project_creation(
|
|
112
|
+
*,
|
|
113
|
+
client: SolwynClient,
|
|
114
|
+
request: ProjectCreateRequest,
|
|
115
|
+
) -> ProjectCreateResponse:
|
|
116
|
+
"""Create and validate a project through the no-replay wire boundary."""
|
|
117
|
+
response = client.request(
|
|
118
|
+
"POST",
|
|
119
|
+
"/api/v1/projects",
|
|
120
|
+
json=request.model_dump(mode="json"),
|
|
121
|
+
retry_rate_limit=False,
|
|
122
|
+
retry_auth=False,
|
|
123
|
+
)
|
|
124
|
+
if response.status_code != 201:
|
|
125
|
+
raise ValueError("Project creation did not return 201")
|
|
126
|
+
return _ProjectCreateResponse.model_validate(response.json())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _ProjectCreateDisplayData(_CreatedProject):
|
|
130
|
+
key: SecretStr
|
|
131
|
+
|
|
132
|
+
@field_serializer("key")
|
|
133
|
+
def serialize_key(self, value: SecretStr) -> str:
|
|
134
|
+
return value.get_secret_value()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _ProjectCreateStoredData(_CreatedProject):
|
|
138
|
+
stored: bool = True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _date_time(value: AwareDatetime) -> str:
|
|
142
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _list_table(data: _ProjectsListData) -> TableData:
|
|
146
|
+
return TableData(
|
|
147
|
+
columns=("ID", "Name", "Budget", "Period", "Mode", "Created"),
|
|
148
|
+
rows=tuple(
|
|
149
|
+
(
|
|
150
|
+
project.id,
|
|
151
|
+
project.name,
|
|
152
|
+
f"{project.budget_limit:.2f}",
|
|
153
|
+
project.budget_period,
|
|
154
|
+
project.budget_mode,
|
|
155
|
+
_date_time(project.created_at),
|
|
156
|
+
)
|
|
157
|
+
for project in data.projects
|
|
158
|
+
),
|
|
159
|
+
right_align=("Budget", "Created"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _show_table(project: _Project) -> TableData:
|
|
164
|
+
return TableData(
|
|
165
|
+
columns=("Field", "Value"),
|
|
166
|
+
rows=(
|
|
167
|
+
("ID", project.id),
|
|
168
|
+
("Name", project.name),
|
|
169
|
+
("Budget limit", f"{project.budget_limit:.2f}"),
|
|
170
|
+
("Budget period", project.budget_period),
|
|
171
|
+
("Budget mode", project.budget_mode),
|
|
172
|
+
("Created", _date_time(project.created_at)),
|
|
173
|
+
),
|
|
174
|
+
right_align=("Value",),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _create_table(project: _CreatedProject, *, stored: bool = False) -> TableData:
|
|
179
|
+
rows = (
|
|
180
|
+
("ID", project.id),
|
|
181
|
+
("Name", project.name),
|
|
182
|
+
("Budget limit", f"{project.budget_limit:.2f}"),
|
|
183
|
+
("Budget period", project.budget_period),
|
|
184
|
+
("Budget mode", project.budget_mode),
|
|
185
|
+
("Created", _date_time(project.created_at)),
|
|
186
|
+
("Key prefix", project.key_prefix),
|
|
187
|
+
)
|
|
188
|
+
if stored:
|
|
189
|
+
rows = (*rows, ("Stored", "true"))
|
|
190
|
+
return TableData(
|
|
191
|
+
columns=("Field", "Value"),
|
|
192
|
+
rows=rows,
|
|
193
|
+
right_align=("Value",),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _jwt_credential_for_create(context: typer.Context) -> JwtCredential:
|
|
198
|
+
credential = required_credential(context, resolve=resolve_credential)
|
|
199
|
+
if isinstance(credential, ProjectKeyCredential):
|
|
200
|
+
hint = (
|
|
201
|
+
"Unset SOLWYN_API_KEY, run solwyn login, then retry projects create."
|
|
202
|
+
if credential.source == "environment"
|
|
203
|
+
else "Run solwyn logout, then solwyn login, and retry projects create."
|
|
204
|
+
)
|
|
205
|
+
exit_with_error(
|
|
206
|
+
context,
|
|
207
|
+
OutputError(
|
|
208
|
+
code="authentication_required",
|
|
209
|
+
message="User login is required to create projects.",
|
|
210
|
+
hint=hint,
|
|
211
|
+
),
|
|
212
|
+
semantic=ErrorSemantic.AUTH,
|
|
213
|
+
)
|
|
214
|
+
return credential
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command(
|
|
218
|
+
"create",
|
|
219
|
+
epilog=help_contract(
|
|
220
|
+
endpoint="POST /api/v1/projects",
|
|
221
|
+
auth="JWT",
|
|
222
|
+
exits="0 success; 1 generic/API; 2 usage; 3 auth",
|
|
223
|
+
schema="solwyn.projects-create.v1",
|
|
224
|
+
guidance=(
|
|
225
|
+
"The new project key is shown exactly once or stored directly in the OS keychain.",
|
|
226
|
+
),
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
def create_project(
|
|
230
|
+
context: typer.Context,
|
|
231
|
+
name: Annotated[str, typer.Option("--name", help="Name for the new project.")],
|
|
232
|
+
budget: Annotated[
|
|
233
|
+
float,
|
|
234
|
+
typer.Option("--budget", help="Initial project budget in USD."),
|
|
235
|
+
] = 100.0,
|
|
236
|
+
period: Annotated[
|
|
237
|
+
BudgetPeriod,
|
|
238
|
+
typer.Option("--period", help="Initial budget period."),
|
|
239
|
+
] = BudgetPeriod.MONTHLY,
|
|
240
|
+
mode: Annotated[
|
|
241
|
+
BudgetMode,
|
|
242
|
+
typer.Option("--mode", help="Initial budget enforcement mode."),
|
|
243
|
+
] = BudgetMode.ALERT_ONLY,
|
|
244
|
+
store_key: Annotated[
|
|
245
|
+
bool,
|
|
246
|
+
typer.Option("--store-key", help="Store the new key in the OS keychain."),
|
|
247
|
+
] = False,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Create a project and capture its first API key exactly once."""
|
|
250
|
+
if not name.strip() or not math.isfinite(budget) or budget <= 0:
|
|
251
|
+
exit_with_error(
|
|
252
|
+
context,
|
|
253
|
+
OutputError(
|
|
254
|
+
code="invalid_project_create_request",
|
|
255
|
+
message="Project name and budget are invalid.",
|
|
256
|
+
hint="Provide a nonblank name and a finite positive budget.",
|
|
257
|
+
),
|
|
258
|
+
semantic=ErrorSemantic.USAGE,
|
|
259
|
+
)
|
|
260
|
+
try:
|
|
261
|
+
request = ProjectCreateRequest(
|
|
262
|
+
name=name.strip(),
|
|
263
|
+
budget_limit=budget,
|
|
264
|
+
budget_period=period.value,
|
|
265
|
+
budget_mode=mode.value,
|
|
266
|
+
)
|
|
267
|
+
except ValidationError:
|
|
268
|
+
exit_with_error(
|
|
269
|
+
context,
|
|
270
|
+
OutputError(
|
|
271
|
+
code="invalid_project_create_request",
|
|
272
|
+
message="The project creation request is invalid.",
|
|
273
|
+
hint="Check the project name and initial budget settings.",
|
|
274
|
+
),
|
|
275
|
+
semantic=ErrorSemantic.USAGE,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
credential = _jwt_credential_for_create(context)
|
|
279
|
+
runtime = command_context(context)
|
|
280
|
+
try:
|
|
281
|
+
with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
|
|
282
|
+
created = request_project_creation(client=client, request=request)
|
|
283
|
+
except ApiError as error:
|
|
284
|
+
exit_with_error(context, error)
|
|
285
|
+
except AuthError as error:
|
|
286
|
+
exit_with_error(
|
|
287
|
+
context,
|
|
288
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
289
|
+
semantic=ErrorSemantic.AUTH,
|
|
290
|
+
)
|
|
291
|
+
except (ValidationError, ValueError, TypeError):
|
|
292
|
+
exit_with_error(
|
|
293
|
+
context,
|
|
294
|
+
OutputError(
|
|
295
|
+
code="invalid_projects_response",
|
|
296
|
+
message="The project creation response was invalid.",
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
raw_key = created.key.get_secret_value()
|
|
301
|
+
if store_key:
|
|
302
|
+
try:
|
|
303
|
+
store_project_key(raw_key)
|
|
304
|
+
except CredentialStorageError as error:
|
|
305
|
+
exit_with_error(
|
|
306
|
+
context,
|
|
307
|
+
OutputError(
|
|
308
|
+
code="credential_storage_failed",
|
|
309
|
+
message=str(error),
|
|
310
|
+
hint="Retry after configuring an operating-system keychain.",
|
|
311
|
+
),
|
|
312
|
+
semantic=ErrorSemantic.AUTH,
|
|
313
|
+
)
|
|
314
|
+
stored_data = _ProjectCreateStoredData.model_validate(created.model_dump())
|
|
315
|
+
output_engine(context).write_success(
|
|
316
|
+
schema="solwyn.projects-create.v1",
|
|
317
|
+
data=stored_data,
|
|
318
|
+
table=_create_table(stored_data, stored=True),
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
display_data = _ProjectCreateDisplayData.model_validate(created.model_dump())
|
|
323
|
+
output_engine(context).write_success(
|
|
324
|
+
schema="solwyn.projects-create.v1",
|
|
325
|
+
data=display_data,
|
|
326
|
+
table=_create_table(display_data),
|
|
327
|
+
one_time_secret=OneTimeSecretPanel(
|
|
328
|
+
title="One-time project key",
|
|
329
|
+
value=created.key,
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.command(
|
|
335
|
+
"list",
|
|
336
|
+
epilog=help_contract(
|
|
337
|
+
endpoint="GET /api/v1/projects",
|
|
338
|
+
auth="JWT",
|
|
339
|
+
exits="0 success; 1 generic/API; 2 usage; 3 auth",
|
|
340
|
+
schema="solwyn.projects-list.v1",
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
def list_projects(context: typer.Context) -> None:
|
|
344
|
+
"""List projects available to the logged-in user."""
|
|
345
|
+
credential = required_credential(context, resolve=resolve_credential)
|
|
346
|
+
if isinstance(credential, ProjectKeyCredential):
|
|
347
|
+
hint = (
|
|
348
|
+
"Unset SOLWYN_API_KEY, run solwyn login, then retry projects list."
|
|
349
|
+
if credential.source == "environment"
|
|
350
|
+
else "Run solwyn logout, then solwyn login, and retry projects list."
|
|
351
|
+
)
|
|
352
|
+
exit_with_error(
|
|
353
|
+
context,
|
|
354
|
+
OutputError(
|
|
355
|
+
code="authentication_required",
|
|
356
|
+
message="User login is required to list projects.",
|
|
357
|
+
hint=hint,
|
|
358
|
+
),
|
|
359
|
+
semantic=ErrorSemantic.AUTH,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
runtime = command_context(context)
|
|
363
|
+
try:
|
|
364
|
+
with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
|
|
365
|
+
data = _ProjectsListData(projects=fetch_projects(client=client))
|
|
366
|
+
except ApiError as error:
|
|
367
|
+
exit_with_error(context, error)
|
|
368
|
+
except AuthError as error:
|
|
369
|
+
exit_with_error(
|
|
370
|
+
context,
|
|
371
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
372
|
+
semantic=ErrorSemantic.AUTH,
|
|
373
|
+
)
|
|
374
|
+
except (ValidationError, ValueError, TypeError):
|
|
375
|
+
exit_with_error(
|
|
376
|
+
context,
|
|
377
|
+
OutputError(
|
|
378
|
+
code="invalid_projects_response",
|
|
379
|
+
message="The projects response was invalid.",
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
output_engine(context).write_success(
|
|
384
|
+
schema="solwyn.projects-list.v1",
|
|
385
|
+
data=data,
|
|
386
|
+
table=_list_table(data),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@app.command(
|
|
391
|
+
"show",
|
|
392
|
+
epilog=help_contract(
|
|
393
|
+
endpoint="GET /api/v1/projects/{project}",
|
|
394
|
+
auth="any",
|
|
395
|
+
exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
|
|
396
|
+
schema="solwyn.projects-show.v1",
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
def show_project(context: typer.Context) -> None:
|
|
400
|
+
"""Show the project selected by the global project context."""
|
|
401
|
+
runtime = command_context(context)
|
|
402
|
+
if runtime.project is None:
|
|
403
|
+
exit_with_error(
|
|
404
|
+
context,
|
|
405
|
+
OutputError(
|
|
406
|
+
code="project_required",
|
|
407
|
+
message="A project is required.",
|
|
408
|
+
hint=(
|
|
409
|
+
"Choose a project with --project, SOLWYN_PROJECT_ID, "
|
|
410
|
+
"or config default_project."
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
semantic=ErrorSemantic.USAGE,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
credential = required_credential(context, resolve=resolve_credential)
|
|
417
|
+
project_id = runtime.project
|
|
418
|
+
project_path = quote(project_id, safe="")
|
|
419
|
+
try:
|
|
420
|
+
with SolwynClient(
|
|
421
|
+
api_url=runtime.api_url,
|
|
422
|
+
credential=credential,
|
|
423
|
+
) as client:
|
|
424
|
+
response = client.request("GET", f"/api/v1/projects/{project_path}")
|
|
425
|
+
project = _Project.model_validate(response.json())
|
|
426
|
+
if isinstance(credential, ProjectKeyCredential) and project.id != project_id:
|
|
427
|
+
raise ValueError("Key-scoped project response did not match the request.")
|
|
428
|
+
except ApiError as error:
|
|
429
|
+
exit_with_error(context, error)
|
|
430
|
+
except AuthError as error:
|
|
431
|
+
exit_with_error(
|
|
432
|
+
context,
|
|
433
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
434
|
+
semantic=ErrorSemantic.AUTH,
|
|
435
|
+
)
|
|
436
|
+
except (ValidationError, ValueError, TypeError):
|
|
437
|
+
exit_with_error(
|
|
438
|
+
context,
|
|
439
|
+
OutputError(
|
|
440
|
+
code="invalid_projects_response",
|
|
441
|
+
message="The projects response was invalid.",
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
output_engine(context).write_success(
|
|
446
|
+
schema="solwyn.projects-show.v1",
|
|
447
|
+
data=project,
|
|
448
|
+
table=_show_table(project),
|
|
449
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""At-a-glance project status command."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
|
8
|
+
|
|
9
|
+
from solwyn_cli.auth import AuthError, resolve_credential
|
|
10
|
+
from solwyn_cli.client import SolwynClient
|
|
11
|
+
from solwyn_cli.commands._shared import exit_with_error, required_credential
|
|
12
|
+
from solwyn_cli.errors import ApiError
|
|
13
|
+
from solwyn_cli.exit_codes import ErrorSemantic
|
|
14
|
+
from solwyn_cli.help import help_contract
|
|
15
|
+
from solwyn_cli.output import OutputError, TableData
|
|
16
|
+
from solwyn_cli.runtime import command_context, output_engine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _BudgetSummary(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
21
|
+
|
|
22
|
+
budget_limit: float
|
|
23
|
+
current_usage: float
|
|
24
|
+
remaining_budget: float
|
|
25
|
+
budget_period: Literal["daily", "weekly", "monthly"]
|
|
26
|
+
budget_mode: Literal["alert_only", "hard_deny"]
|
|
27
|
+
period_date: str
|
|
28
|
+
utilization_pct: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
STATUS_HELP = help_contract(
|
|
32
|
+
endpoint=(
|
|
33
|
+
"GET /api/v1/projects/{project}/budget and "
|
|
34
|
+
"GET /api/v1/projects/{project}/providers"
|
|
35
|
+
),
|
|
36
|
+
auth="any",
|
|
37
|
+
exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
|
|
38
|
+
schema="solwyn.status.v1",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _BudgetResponse(_BudgetSummary):
|
|
43
|
+
project_id: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ProviderHealth(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
48
|
+
|
|
49
|
+
provider: str
|
|
50
|
+
total_calls: int
|
|
51
|
+
success_count: int
|
|
52
|
+
error_count: int
|
|
53
|
+
failover_count: int
|
|
54
|
+
success_rate: float
|
|
55
|
+
avg_latency_ms: float
|
|
56
|
+
circuit_state: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _StatusData(BaseModel):
|
|
60
|
+
model_config = ConfigDict(extra="forbid")
|
|
61
|
+
|
|
62
|
+
project_id: str
|
|
63
|
+
budget: _BudgetSummary
|
|
64
|
+
providers: list[_ProviderHealth]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_PROVIDERS = TypeAdapter(list[_ProviderHealth])
|
|
68
|
+
ProjectStatusData = _StatusData
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fetch_project_status(*, client: SolwynClient, project_id: str) -> ProjectStatusData:
|
|
72
|
+
"""Read and validate budget plus provider health through one shared client."""
|
|
73
|
+
project = quote(project_id, safe="")
|
|
74
|
+
budget_response = client.request("GET", f"/api/v1/projects/{project}/budget")
|
|
75
|
+
providers_response = client.request("GET", f"/api/v1/projects/{project}/providers")
|
|
76
|
+
budget = _BudgetResponse.model_validate(budget_response.json())
|
|
77
|
+
providers = _PROVIDERS.validate_python(providers_response.json())
|
|
78
|
+
data = _StatusData(
|
|
79
|
+
project_id=budget.project_id,
|
|
80
|
+
budget=_BudgetSummary.model_validate(budget.model_dump()),
|
|
81
|
+
providers=providers,
|
|
82
|
+
)
|
|
83
|
+
if data.project_id != project_id:
|
|
84
|
+
raise ValueError("Status response project did not match the request.")
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _table(data: _StatusData) -> TableData:
|
|
89
|
+
budget = data.budget
|
|
90
|
+
rows: list[tuple[str, str]] = [
|
|
91
|
+
("Project", data.project_id),
|
|
92
|
+
("Budget limit", f"{budget.budget_limit:.2f}"),
|
|
93
|
+
("Current usage", f"{budget.current_usage:.2f}"),
|
|
94
|
+
("Remaining budget", f"{budget.remaining_budget:.2f}"),
|
|
95
|
+
(
|
|
96
|
+
"Budget period",
|
|
97
|
+
f"{budget.budget_period} ({budget.period_date})",
|
|
98
|
+
),
|
|
99
|
+
("Budget mode", budget.budget_mode),
|
|
100
|
+
("Utilization", f"{budget.utilization_pct:.2f}%"),
|
|
101
|
+
]
|
|
102
|
+
for provider in data.providers:
|
|
103
|
+
rows.extend(
|
|
104
|
+
[
|
|
105
|
+
(f"{provider.provider} circuit", provider.circuit_state),
|
|
106
|
+
(
|
|
107
|
+
f"{provider.provider} success",
|
|
108
|
+
f"{provider.success_count} / {provider.total_calls} "
|
|
109
|
+
f"({provider.success_rate:.2f}%)",
|
|
110
|
+
),
|
|
111
|
+
(
|
|
112
|
+
f"{provider.provider} latency",
|
|
113
|
+
f"{provider.avg_latency_ms:.2f} ms",
|
|
114
|
+
),
|
|
115
|
+
(
|
|
116
|
+
f"{provider.provider} errors / failovers",
|
|
117
|
+
f"{provider.error_count} / {provider.failover_count}",
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
return TableData(
|
|
122
|
+
columns=("Field", "Value"),
|
|
123
|
+
rows=tuple(rows),
|
|
124
|
+
right_align=("Value",),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def status(context: typer.Context) -> None:
|
|
129
|
+
"""Show project budget and provider health."""
|
|
130
|
+
runtime = command_context(context)
|
|
131
|
+
if runtime.project is None:
|
|
132
|
+
exit_with_error(
|
|
133
|
+
context,
|
|
134
|
+
OutputError(
|
|
135
|
+
code="project_required",
|
|
136
|
+
message="A project is required.",
|
|
137
|
+
hint=(
|
|
138
|
+
"Choose a project with --project, SOLWYN_PROJECT_ID, "
|
|
139
|
+
"or config default_project."
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
semantic=ErrorSemantic.USAGE,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
credential = required_credential(context, resolve=resolve_credential)
|
|
146
|
+
try:
|
|
147
|
+
with SolwynClient(
|
|
148
|
+
api_url=runtime.api_url,
|
|
149
|
+
credential=credential,
|
|
150
|
+
) as client:
|
|
151
|
+
data = fetch_project_status(client=client, project_id=runtime.project)
|
|
152
|
+
except ApiError as error:
|
|
153
|
+
exit_with_error(context, error)
|
|
154
|
+
except AuthError as error:
|
|
155
|
+
exit_with_error(
|
|
156
|
+
context,
|
|
157
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
158
|
+
semantic=ErrorSemantic.AUTH,
|
|
159
|
+
)
|
|
160
|
+
except (ValidationError, ValueError, TypeError):
|
|
161
|
+
exit_with_error(
|
|
162
|
+
context,
|
|
163
|
+
OutputError(
|
|
164
|
+
code="invalid_status_response",
|
|
165
|
+
message="The status response was invalid.",
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
output_engine(context).write_success(
|
|
170
|
+
schema="solwyn.status.v1",
|
|
171
|
+
data=data,
|
|
172
|
+
table=_table(data),
|
|
173
|
+
)
|