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,976 @@
1
+ """Project budget inspection and pre-flight guardrail commands."""
2
+
3
+ import math
4
+ from datetime import datetime
5
+ from typing import Annotated, Literal
6
+ from urllib.parse import quote
7
+
8
+ import typer
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ FiniteFloat,
14
+ ValidationError,
15
+ model_validator,
16
+ )
17
+
18
+ from solwyn_cli._vendor.wire import (
19
+ BudgetCheckRequest,
20
+ BudgetConfirmRequest,
21
+ BudgetMode,
22
+ BudgetPeriod,
23
+ BudgetUpdateRequest,
24
+ ProviderName,
25
+ ServiceTier,
26
+ TokenDetails,
27
+ )
28
+ from solwyn_cli.auth import (
29
+ AuthError,
30
+ JwtCredential,
31
+ ProjectKeyCredential,
32
+ resolve_credential,
33
+ )
34
+ from solwyn_cli.client import SolwynClient
35
+ from solwyn_cli.commands._shared import exit_with_error, required_credential
36
+ from solwyn_cli.errors import ApiError
37
+ from solwyn_cli.exit_codes import ErrorSemantic, ExitCode
38
+ from solwyn_cli.help import help_contract
39
+ from solwyn_cli.output import OutputError, TableData
40
+ from solwyn_cli.runtime import command_context, output_engine
41
+
42
+
43
+ app = typer.Typer(
44
+ help="Inspect budgets and run pre-flight guardrail checks.",
45
+ epilog=help_contract(
46
+ endpoint="composite router for budget subcommands",
47
+ auth="varies by subcommand",
48
+ exits="0 success; 2 usage",
49
+ schema="varies by subcommand",
50
+ guidance=(
51
+ "Use status to poll, check immediately before un-instrumented spend, "
52
+ "and confirm only a non-null reservation.",
53
+ ),
54
+ ),
55
+ )
56
+
57
+ _FAIL_OPEN_NOTICE = "fail-open, unreserved — skip confirm"
58
+ _CONFIRM_NOT_FOUND_HINT = (
59
+ "Expired or already consumed. Run a fresh check before spend; confirm once."
60
+ )
61
+
62
+
63
+ class _RoutingChannel(BaseModel):
64
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
65
+
66
+ id: str
67
+ name: str
68
+
69
+
70
+ class _RoutingMuted(BaseModel):
71
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
72
+
73
+ reason: Literal["severity_floor", "event_filter", "email_off"]
74
+ severity_floor: str | None = None
75
+
76
+
77
+ class _AlertRouting(BaseModel):
78
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
79
+
80
+ will_send: bool
81
+ email: str | None = None
82
+ channels: list[_RoutingChannel] = Field(default_factory=list)
83
+ source: Literal["override", "project_default", "account_default"]
84
+ muted: _RoutingMuted | None = None
85
+
86
+
87
+ class _ThresholdStateBase(BaseModel):
88
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
89
+
90
+ rule_key: str
91
+ channel_ids: list[str]
92
+ routing: _AlertRouting
93
+ snoozed_until: datetime | None = None
94
+ paused: bool = False
95
+
96
+
97
+ class _BudgetThresholdState(_ThresholdStateBase):
98
+ type: Literal["budget_threshold"]
99
+ percent: Annotated[int, Field(ge=1, le=100)]
100
+
101
+
102
+ class _ForecastOverrunState(_ThresholdStateBase):
103
+ type: Literal["forecast_overrun"]
104
+ spend_threshold_usd: Annotated[FiniteFloat, Field(gt=0)]
105
+ period: Literal["month"] = "month"
106
+
107
+
108
+ class _DailyAnomalyState(_ThresholdStateBase):
109
+ type: Literal["daily_anomaly"]
110
+ multiplier: Annotated[FiniteFloat, Field(gt=1, le=100)]
111
+ lookback_days: Annotated[int, Field(ge=3, le=30)] = 7
112
+
113
+
114
+ class _UnitCostAnomalyState(_ThresholdStateBase):
115
+ type: Literal["unit_cost_anomaly"]
116
+ multiplier: Annotated[FiniteFloat, Field(gt=1, le=100)]
117
+ lookback_days: Annotated[int, Field(ge=3, le=30)] = 7
118
+
119
+
120
+ _ThresholdState = Annotated[
121
+ _BudgetThresholdState
122
+ | _ForecastOverrunState
123
+ | _DailyAnomalyState
124
+ | _UnitCostAnomalyState,
125
+ Field(discriminator="type"),
126
+ ]
127
+
128
+
129
+ class _BudgetConstraints(BaseModel):
130
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
131
+
132
+ allowed_modes: list[BudgetMode]
133
+ allowed_periods: list[BudgetPeriod]
134
+ sub_period_enabled: bool
135
+ max_thresholds: int
136
+
137
+
138
+ class _BudgetStatusData(BaseModel):
139
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
140
+
141
+ project_id: str
142
+ budget_limit: FiniteFloat
143
+ current_usage: FiniteFloat
144
+ remaining_budget: FiniteFloat
145
+ utilization_pct: FiniteFloat
146
+ budget_mode: BudgetMode
147
+ budget_period: BudgetPeriod
148
+ period_date: str
149
+ thresholds: list[_ThresholdState]
150
+ sub_period: BudgetPeriod | None = None
151
+ sub_limit: FiniteFloat | None = None
152
+ constraints: _BudgetConstraints
153
+
154
+
155
+ BudgetStatusData = _BudgetStatusData
156
+
157
+
158
+ def fetch_budget_status(
159
+ *, api_url: str, credential: JwtCredential | ProjectKeyCredential, project_id: str
160
+ ) -> BudgetStatusData:
161
+ """Fetch and validate one project's pure-read budget status."""
162
+ with SolwynClient(api_url=api_url, credential=credential) as client:
163
+ return request_budget_status(client=client, project_id=project_id)
164
+
165
+
166
+ def request_budget_status(*, client: SolwynClient, project_id: str) -> BudgetStatusData:
167
+ """Read and validate budget status through an existing shared client."""
168
+ project_path = quote(project_id, safe="")
169
+ response = client.request("GET", f"/api/v1/projects/{project_path}/budget")
170
+ data = _BudgetStatusData.model_validate(response.json())
171
+ if data.project_id != project_id:
172
+ raise ValueError("Budget response project did not match the request.")
173
+ return data
174
+
175
+
176
+ class _BudgetStatusOutput(BaseModel):
177
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
178
+
179
+ project_id: str
180
+ budget_limit: FiniteFloat
181
+ current_usage: FiniteFloat
182
+ remaining_budget: FiniteFloat
183
+ utilization_pct: FiniteFloat
184
+ budget_mode: BudgetMode
185
+ budget_period: BudgetPeriod
186
+
187
+
188
+ class _BudgetSetData(BaseModel):
189
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
190
+
191
+ project_id: str
192
+ budget_limit: FiniteFloat
193
+ current_usage: FiniteFloat
194
+ remaining_budget: FiniteFloat
195
+ budget_period: BudgetPeriod
196
+ budget_mode: BudgetMode
197
+ period_date: str
198
+ utilization_pct: FiniteFloat
199
+ sub_period: BudgetPeriod | None
200
+ sub_limit: FiniteFloat | None
201
+ thresholds: list[dict[str, object]]
202
+
203
+
204
+ class _BudgetCheckData(BaseModel):
205
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
206
+
207
+ allowed: bool
208
+ remaining_budget: FiniteFloat
209
+ reservation_id: Annotated[str, Field(min_length=1)] | None = None
210
+ mode: BudgetMode
211
+ budget_limit: FiniteFloat
212
+ current_usage: FiniteFloat
213
+ denied_by_period: BudgetPeriod | None = None
214
+ project_id: str
215
+ price_hints: dict[ProviderName, FiniteFloat] | None = None
216
+
217
+ @model_validator(mode="after")
218
+ def validate_guardrail_outcome(self) -> "_BudgetCheckData":
219
+ """Reject contradictory reservation and denial states."""
220
+ if not self.allowed and self.reservation_id is not None:
221
+ raise ValueError("Denied checks cannot carry a reservation")
222
+ if not self.allowed and self.mode is not BudgetMode.HARD_DENY:
223
+ raise ValueError("Only hard-deny mode can deny a check")
224
+ if self.allowed and self.denied_by_period is not None:
225
+ raise ValueError("Allowed checks cannot carry a denial period")
226
+ return self
227
+
228
+
229
+ class _BudgetConfirmData(BaseModel):
230
+ model_config = ConfigDict(extra="forbid", frozen=True)
231
+
232
+ reservation_id: str
233
+ call_id: str
234
+
235
+
236
+ BudgetCheckData = _BudgetCheckData
237
+ BudgetConfirmData = _BudgetConfirmData
238
+
239
+
240
+ def request_budget_check(
241
+ *, client: SolwynClient, request: BudgetCheckRequest
242
+ ) -> BudgetCheckData:
243
+ """Reserve and validate one caller-made un-instrumented spend."""
244
+ response = client.request(
245
+ "POST",
246
+ "/api/v1/budgets/check",
247
+ json=request.model_dump(mode="json"),
248
+ )
249
+ return _BudgetCheckData.model_validate(response.json())
250
+
251
+
252
+ def request_budget_confirm(
253
+ *, client: SolwynClient, request: BudgetConfirmRequest
254
+ ) -> BudgetConfirmData:
255
+ """Settle one reservation with the exact no-replay 204 contract."""
256
+ response = client.request(
257
+ "POST",
258
+ "/api/v1/budgets/confirm",
259
+ json=request.model_dump(mode="json"),
260
+ retry_rate_limit=False,
261
+ )
262
+ if response.status_code != 204 or response.content:
263
+ raise ValueError("Budget confirmation did not return an empty 204 response")
264
+ return _BudgetConfirmData(
265
+ reservation_id=request.reservation_id,
266
+ call_id=request.call_id,
267
+ )
268
+
269
+
270
+ def _table(data: _BudgetStatusOutput) -> TableData:
271
+ return TableData(
272
+ columns=("Field", "Value"),
273
+ rows=(
274
+ ("Project", data.project_id),
275
+ ("Budget limit", f"{data.budget_limit:.2f}"),
276
+ ("Current usage", f"{data.current_usage:.2f}"),
277
+ ("Remaining budget", f"{data.remaining_budget:.2f}"),
278
+ ("Budget period", data.budget_period),
279
+ ("Budget mode", data.budget_mode),
280
+ ("Utilization", f"{data.utilization_pct:.2f}%"),
281
+ ),
282
+ right_align=("Value",),
283
+ )
284
+
285
+
286
+ def _check_table(data: _BudgetCheckData) -> TableData:
287
+ price_hints = (
288
+ ", ".join(
289
+ f"{provider.value}={price:.6g}"
290
+ for provider, price in sorted(
291
+ data.price_hints.items(), key=lambda item: item[0].value
292
+ )
293
+ )
294
+ if data.price_hints
295
+ else "-"
296
+ )
297
+ return TableData(
298
+ columns=("Field", "Value"),
299
+ rows=(
300
+ ("Allowed", "yes" if data.allowed else "no"),
301
+ ("Reservation ID", data.reservation_id or "-"),
302
+ ("Project", data.project_id),
303
+ ("Budget mode", data.mode.value),
304
+ ("Budget limit", f"{data.budget_limit:.2f}"),
305
+ ("Current usage", f"{data.current_usage:.2f}"),
306
+ ("Remaining budget", f"{data.remaining_budget:.2f}"),
307
+ (
308
+ "Denied by period",
309
+ data.denied_by_period.value if data.denied_by_period else "-",
310
+ ),
311
+ ("Price hints", price_hints),
312
+ ),
313
+ right_align=("Value",),
314
+ )
315
+
316
+
317
+ def _confirm_table(data: _BudgetConfirmData) -> TableData:
318
+ return TableData(
319
+ columns=("Field", "Value"),
320
+ rows=(
321
+ ("Reservation ID", data.reservation_id),
322
+ ("Call ID", data.call_id),
323
+ ),
324
+ right_align=("Value",),
325
+ )
326
+
327
+
328
+ def _threshold_configs(data: _BudgetStatusData) -> list[dict[str, object]]:
329
+ """Convert validated response state to request config without server-only data."""
330
+ return [
331
+ threshold.model_dump(
332
+ mode="json",
333
+ exclude={"rule_key", "routing"},
334
+ )
335
+ for threshold in data.thresholds
336
+ ]
337
+
338
+
339
+ def _set_data(data: _BudgetStatusData) -> _BudgetSetData:
340
+ return _BudgetSetData(
341
+ project_id=data.project_id,
342
+ budget_limit=data.budget_limit,
343
+ current_usage=data.current_usage,
344
+ remaining_budget=data.remaining_budget,
345
+ budget_period=data.budget_period,
346
+ budget_mode=data.budget_mode,
347
+ period_date=data.period_date,
348
+ utilization_pct=data.utilization_pct,
349
+ sub_period=data.sub_period,
350
+ sub_limit=data.sub_limit,
351
+ thresholds=_threshold_configs(data),
352
+ )
353
+
354
+
355
+ def _set_table(data: _BudgetSetData) -> TableData:
356
+ return TableData(
357
+ columns=("Field", "Value"),
358
+ rows=(
359
+ ("Project", data.project_id),
360
+ ("Budget limit", f"{data.budget_limit:.2f}"),
361
+ ("Current usage", f"{data.current_usage:.2f}"),
362
+ ("Remaining budget", f"{data.remaining_budget:.2f}"),
363
+ ("Budget period", data.budget_period.value),
364
+ ("Budget mode", data.budget_mode.value),
365
+ (
366
+ "Sub-period",
367
+ data.sub_period.value if data.sub_period is not None else "-",
368
+ ),
369
+ (
370
+ "Sub-limit",
371
+ f"{data.sub_limit:.2f}" if data.sub_limit is not None else "-",
372
+ ),
373
+ ("Threshold rules", len(data.thresholds)),
374
+ ("Utilization", f"{data.utilization_pct:.2f}%"),
375
+ ),
376
+ right_align=("Value",),
377
+ )
378
+
379
+
380
+ @app.command(
381
+ "check",
382
+ epilog=help_contract(
383
+ endpoint="POST /api/v1/budgets/check",
384
+ auth="key",
385
+ exits=(
386
+ "0 allowed or fail-open; 1 generic/API; 2 usage; 3 auth; 4 budget denial"
387
+ ),
388
+ schema="solwyn.budget-check.v1",
389
+ guidance=(
390
+ "Run immediately before caller-made un-instrumented provider spend.",
391
+ "Do not use with Solwyn SDK-instrumented calls. Do not poll with check.",
392
+ ),
393
+ ),
394
+ )
395
+ def check(
396
+ context: typer.Context,
397
+ estimated_input_tokens: Annotated[
398
+ int,
399
+ typer.Option(
400
+ "--estimated-input-tokens",
401
+ min=0,
402
+ help="Estimated input tokens for the pending provider call.",
403
+ ),
404
+ ],
405
+ model: Annotated[
406
+ str,
407
+ typer.Option("--model", help="Primary provider model name."),
408
+ ],
409
+ provider: Annotated[
410
+ ProviderName,
411
+ typer.Option("--provider", help="Primary provider."),
412
+ ],
413
+ fallback_providers: Annotated[
414
+ list[ProviderName] | None,
415
+ typer.Option(
416
+ "--fallback-provider",
417
+ help="Fallback provider in attempt order; repeat with fallback model.",
418
+ ),
419
+ ] = None,
420
+ fallback_models: Annotated[
421
+ list[str] | None,
422
+ typer.Option(
423
+ "--fallback-model",
424
+ help="Fallback model aligned with each fallback provider.",
425
+ ),
426
+ ] = None,
427
+ ) -> None:
428
+ """Reserve budget immediately before one un-instrumented provider call."""
429
+ providers = fallback_providers or []
430
+ models = fallback_models or []
431
+ if len(providers) != len(models):
432
+ exit_with_error(
433
+ context,
434
+ OutputError(
435
+ code="fallback_alignment_required",
436
+ message="Fallback providers and models must have aligned counts.",
437
+ hint=(
438
+ "Repeat --fallback-provider and --fallback-model together "
439
+ "in attempt order."
440
+ ),
441
+ ),
442
+ semantic=ErrorSemantic.USAGE,
443
+ )
444
+ try:
445
+ request = BudgetCheckRequest(
446
+ estimated_input_tokens=estimated_input_tokens,
447
+ model=model,
448
+ provider=provider,
449
+ fallback_providers=providers,
450
+ fallback_models=models,
451
+ )
452
+ except ValidationError:
453
+ exit_with_error(
454
+ context,
455
+ OutputError(
456
+ code="invalid_budget_check_request",
457
+ message="The budget check request is invalid.",
458
+ hint="Check model lengths and use no more than eight fallbacks.",
459
+ ),
460
+ semantic=ErrorSemantic.USAGE,
461
+ )
462
+
463
+ credential = required_credential(context, resolve=resolve_credential)
464
+ if isinstance(credential, JwtCredential):
465
+ exit_with_error(
466
+ context,
467
+ OutputError(
468
+ code="project_key_required",
469
+ message="Budget checks require a project API key.",
470
+ hint=(
471
+ "Set SOLWYN_API_KEY or store a project key with "
472
+ "solwyn login --with-key."
473
+ ),
474
+ ),
475
+ semantic=ErrorSemantic.AUTH,
476
+ )
477
+
478
+ runtime = command_context(context)
479
+ try:
480
+ with SolwynClient(
481
+ api_url=runtime.api_url,
482
+ credential=credential,
483
+ ) as client:
484
+ data = request_budget_check(client=client, request=request)
485
+ except ApiError as error:
486
+ exit_with_error(context, error)
487
+ except AuthError as error:
488
+ exit_with_error(
489
+ context,
490
+ OutputError(code="authentication_failed", message=str(error)),
491
+ semantic=ErrorSemantic.AUTH,
492
+ )
493
+ except (ValidationError, ValueError, TypeError):
494
+ exit_with_error(
495
+ context,
496
+ OutputError(
497
+ code="invalid_budget_check_response",
498
+ message="The budget check response was invalid.",
499
+ ),
500
+ )
501
+
502
+ output_engine(context).write_success(
503
+ schema="solwyn.budget-check.v1",
504
+ data=data,
505
+ table=_check_table(data),
506
+ )
507
+ if not data.allowed:
508
+ raise typer.Exit(code=ExitCode.BUDGET_DENIED)
509
+ if data.reservation_id is None:
510
+ typer.echo(_FAIL_OPEN_NOTICE, err=True)
511
+
512
+
513
+ @app.command(
514
+ "confirm",
515
+ epilog=help_contract(
516
+ endpoint="POST /api/v1/budgets/confirm",
517
+ auth="key",
518
+ exits=("0 success; 1 generic/API; 2 usage; 3 auth; 5 reservation not found"),
519
+ schema="solwyn.budget-confirm.v1",
520
+ guidance=(
521
+ "Settle only when check returned a non-null reservation after the call.",
522
+ "Skip confirm when the reservation is null.",
523
+ ),
524
+ ),
525
+ )
526
+ def confirm(
527
+ context: typer.Context,
528
+ call_id: Annotated[
529
+ str,
530
+ typer.Option("--call-id", help="Unique identifier for the provider call."),
531
+ ],
532
+ model: Annotated[
533
+ str,
534
+ typer.Option("--model", help="Model that actually served the call."),
535
+ ],
536
+ provider: Annotated[
537
+ ProviderName,
538
+ typer.Option("--provider", help="Provider that actually served the call."),
539
+ ],
540
+ reservation_id: Annotated[
541
+ str | None,
542
+ typer.Option(
543
+ "--reservation-id",
544
+ help="Reservation returned by the immediately preceding budget check.",
545
+ ),
546
+ ] = None,
547
+ is_provider_fallback: Annotated[
548
+ bool,
549
+ typer.Option(
550
+ "--is-provider-fallback",
551
+ help="Mark that a fallback provider served the call.",
552
+ ),
553
+ ] = False,
554
+ provider_region: Annotated[
555
+ str | None,
556
+ typer.Option("--provider-region", help="Serving cloud region, when relevant."),
557
+ ] = None,
558
+ service_tier: Annotated[
559
+ ServiceTier | None,
560
+ typer.Option("--service-tier", help="Provider service tier used for the call."),
561
+ ] = None,
562
+ input_tokens: Annotated[
563
+ int,
564
+ typer.Option("--input-tokens", min=0, max=100_000_000),
565
+ ] = 0,
566
+ output_tokens: Annotated[
567
+ int,
568
+ typer.Option("--output-tokens", min=0, max=100_000_000),
569
+ ] = 0,
570
+ cached_input_tokens: Annotated[
571
+ int,
572
+ typer.Option("--cached-input-tokens", min=0, max=100_000_000),
573
+ ] = 0,
574
+ cache_creation_5m_tokens: Annotated[
575
+ int,
576
+ typer.Option("--cache-creation-5m-tokens", min=0, max=100_000_000),
577
+ ] = 0,
578
+ cache_creation_1h_tokens: Annotated[
579
+ int,
580
+ typer.Option("--cache-creation-1h-tokens", min=0, max=100_000_000),
581
+ ] = 0,
582
+ reasoning_tokens: Annotated[
583
+ int,
584
+ typer.Option("--reasoning-tokens", min=0, max=100_000_000),
585
+ ] = 0,
586
+ audio_input_tokens: Annotated[
587
+ int,
588
+ typer.Option("--audio-input-tokens", min=0, max=100_000_000),
589
+ ] = 0,
590
+ audio_output_tokens: Annotated[
591
+ int,
592
+ typer.Option("--audio-output-tokens", min=0, max=100_000_000),
593
+ ] = 0,
594
+ accepted_prediction_tokens: Annotated[
595
+ int,
596
+ typer.Option("--accepted-prediction-tokens", min=0, max=100_000_000),
597
+ ] = 0,
598
+ rejected_prediction_tokens: Annotated[
599
+ int,
600
+ typer.Option("--rejected-prediction-tokens", min=0, max=100_000_000),
601
+ ] = 0,
602
+ tool_use_input_tokens: Annotated[
603
+ int,
604
+ typer.Option("--tool-use-input-tokens", min=0, max=100_000_000),
605
+ ] = 0,
606
+ is_estimated: Annotated[
607
+ bool,
608
+ typer.Option(
609
+ "--is-estimated",
610
+ help="Mark the token breakdown as estimated rather than provider-reported.",
611
+ ),
612
+ ] = False,
613
+ ) -> None:
614
+ """Settle one reservation after an un-instrumented provider call."""
615
+ if reservation_id is None:
616
+ exit_with_error(
617
+ context,
618
+ OutputError(
619
+ code="reservation_required",
620
+ message="No budget reservation was supplied.",
621
+ hint=("reservation_id=null means fail-open/unreserved; skip confirm."),
622
+ ),
623
+ semantic=ErrorSemantic.USAGE,
624
+ )
625
+ if not reservation_id.strip():
626
+ exit_with_error(
627
+ context,
628
+ OutputError(
629
+ code="invalid_budget_confirm_request",
630
+ message="The budget confirmation request is invalid.",
631
+ hint="Provide a nonempty reservation ID returned by budget check.",
632
+ ),
633
+ semantic=ErrorSemantic.USAGE,
634
+ )
635
+ if (
636
+ not call_id.strip()
637
+ or not model.strip()
638
+ or (provider_region is not None and not provider_region.strip())
639
+ ):
640
+ exit_with_error(
641
+ context,
642
+ OutputError(
643
+ code="invalid_budget_confirm_request",
644
+ message="The budget confirmation request is invalid.",
645
+ hint="Provide nonempty call, model, and optional region values.",
646
+ ),
647
+ semantic=ErrorSemantic.USAGE,
648
+ )
649
+ try:
650
+ token_details = TokenDetails(
651
+ input_tokens=input_tokens,
652
+ output_tokens=output_tokens,
653
+ cached_input_tokens=cached_input_tokens,
654
+ cache_creation_5m_tokens=cache_creation_5m_tokens,
655
+ cache_creation_1h_tokens=cache_creation_1h_tokens,
656
+ reasoning_tokens=reasoning_tokens,
657
+ audio_input_tokens=audio_input_tokens,
658
+ audio_output_tokens=audio_output_tokens,
659
+ accepted_prediction_tokens=accepted_prediction_tokens,
660
+ rejected_prediction_tokens=rejected_prediction_tokens,
661
+ tool_use_input_tokens=tool_use_input_tokens,
662
+ is_estimated=is_estimated,
663
+ )
664
+ request = BudgetConfirmRequest(
665
+ reservation_id=reservation_id,
666
+ call_id=call_id,
667
+ model=model,
668
+ provider=provider,
669
+ is_provider_fallback=is_provider_fallback,
670
+ provider_region=provider_region,
671
+ service_tier=service_tier.value if service_tier is not None else None,
672
+ token_details=token_details,
673
+ )
674
+ except ValidationError:
675
+ exit_with_error(
676
+ context,
677
+ OutputError(
678
+ code="invalid_budget_confirm_request",
679
+ message="The budget confirmation request is invalid.",
680
+ hint="Check identifier lengths, provider metadata, and token counts.",
681
+ ),
682
+ semantic=ErrorSemantic.USAGE,
683
+ )
684
+
685
+ try:
686
+ credential = resolve_credential()
687
+ except AuthError as error:
688
+ exit_with_error(
689
+ context,
690
+ OutputError(
691
+ code="authentication_failed",
692
+ message=str(error),
693
+ hint=(
694
+ "Set SOLWYN_API_KEY or store a project key with "
695
+ "solwyn login --with-key."
696
+ ),
697
+ ),
698
+ semantic=ErrorSemantic.AUTH,
699
+ )
700
+ if credential is None or isinstance(credential, JwtCredential):
701
+ exit_with_error(
702
+ context,
703
+ OutputError(
704
+ code="project_key_required",
705
+ message="Budget confirmation requires a project API key.",
706
+ hint=(
707
+ "Set SOLWYN_API_KEY or store a project key with "
708
+ "solwyn login --with-key."
709
+ ),
710
+ ),
711
+ semantic=ErrorSemantic.AUTH,
712
+ )
713
+
714
+ runtime = command_context(context)
715
+ try:
716
+ with SolwynClient(
717
+ api_url=runtime.api_url,
718
+ credential=credential,
719
+ ) as client:
720
+ data = request_budget_confirm(client=client, request=request)
721
+ except ApiError as error:
722
+ if error.status_code == 404:
723
+ error = ApiError(
724
+ status_code=error.status_code,
725
+ code=error.code,
726
+ message=error.message,
727
+ hint=_CONFIRM_NOT_FOUND_HINT,
728
+ detail=error.detail,
729
+ )
730
+ exit_with_error(context, error)
731
+ except AuthError as error:
732
+ exit_with_error(
733
+ context,
734
+ OutputError(code="authentication_failed", message=str(error)),
735
+ semantic=ErrorSemantic.AUTH,
736
+ )
737
+ except (ValidationError, ValueError, TypeError):
738
+ exit_with_error(
739
+ context,
740
+ OutputError(
741
+ code="invalid_budget_confirm_response",
742
+ message="The budget confirmation response was invalid.",
743
+ ),
744
+ )
745
+
746
+ output_engine(context).write_success(
747
+ schema="solwyn.budget-confirm.v1",
748
+ data=data,
749
+ table=_confirm_table(data),
750
+ )
751
+
752
+
753
+ @app.command(
754
+ "set",
755
+ epilog=help_contract(
756
+ endpoint="GET then PUT /api/v1/projects/{project}/budget",
757
+ auth="JWT",
758
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
759
+ schema="solwyn.budget-set.v1",
760
+ ),
761
+ )
762
+ def set_budget(
763
+ context: typer.Context,
764
+ limit: Annotated[
765
+ float | None,
766
+ typer.Option("--limit", help="Set the project budget limit in USD."),
767
+ ] = None,
768
+ period: Annotated[
769
+ BudgetPeriod | None,
770
+ typer.Option("--period", help="Set the project budget period."),
771
+ ] = None,
772
+ mode: Annotated[
773
+ BudgetMode | None,
774
+ typer.Option("--mode", help="Set the project budget enforcement mode."),
775
+ ] = None,
776
+ ) -> None:
777
+ """Update selected budget settings while preserving all other configuration."""
778
+ if limit is None and period is None and mode is None:
779
+ exit_with_error(
780
+ context,
781
+ OutputError(
782
+ code="budget_changes_required",
783
+ message="No budget changes were supplied.",
784
+ hint="Provide at least one of --limit, --period, or --mode.",
785
+ ),
786
+ semantic=ErrorSemantic.USAGE,
787
+ )
788
+ if limit is not None and (not math.isfinite(limit) or limit <= 0):
789
+ exit_with_error(
790
+ context,
791
+ OutputError(
792
+ code="invalid_budget_update_request",
793
+ message="The budget limit must be a finite positive number.",
794
+ hint="Provide --limit with a value greater than zero.",
795
+ ),
796
+ semantic=ErrorSemantic.USAGE,
797
+ )
798
+
799
+ runtime = command_context(context)
800
+ if runtime.project is None:
801
+ exit_with_error(
802
+ context,
803
+ OutputError(
804
+ code="project_required",
805
+ message="A project is required.",
806
+ hint=(
807
+ "Choose a project with --project, SOLWYN_PROJECT_ID, "
808
+ "or config default_project."
809
+ ),
810
+ ),
811
+ semantic=ErrorSemantic.USAGE,
812
+ )
813
+
814
+ credential = required_credential(context, resolve=resolve_credential)
815
+ if isinstance(credential, ProjectKeyCredential):
816
+ hint = (
817
+ "Unset SOLWYN_API_KEY, run solwyn login, then retry budget set."
818
+ if credential.source == "environment"
819
+ else "Run solwyn logout, then solwyn login, and retry budget set."
820
+ )
821
+ exit_with_error(
822
+ context,
823
+ OutputError(
824
+ code="authentication_required",
825
+ message="User login is required to update a budget.",
826
+ hint=hint,
827
+ ),
828
+ semantic=ErrorSemantic.AUTH,
829
+ )
830
+
831
+ project_id = runtime.project
832
+ project_path = quote(project_id, safe="")
833
+ endpoint = f"/api/v1/projects/{project_path}/budget"
834
+ try:
835
+ with SolwynClient(
836
+ api_url=runtime.api_url,
837
+ credential=credential,
838
+ ) as client:
839
+ current_response = client.request("GET", endpoint)
840
+ current = _BudgetStatusData.model_validate(current_response.json())
841
+ if current.project_id != project_id:
842
+ raise ValueError("Budget response project did not match the request.")
843
+
844
+ request = BudgetUpdateRequest(
845
+ limit_usd=limit if limit is not None else current.budget_limit,
846
+ period=period if period is not None else current.budget_period,
847
+ mode=mode if mode is not None else current.budget_mode,
848
+ sub_period=current.sub_period,
849
+ sub_limit_usd=current.sub_limit,
850
+ thresholds=_threshold_configs(current),
851
+ )
852
+ updated_response = client.request(
853
+ "PUT",
854
+ endpoint,
855
+ json=request.model_dump(mode="json"),
856
+ )
857
+ updated = _BudgetStatusData.model_validate(updated_response.json())
858
+ if updated.project_id != project_id:
859
+ raise ValueError("Budget response project did not match the request.")
860
+ except ApiError as error:
861
+ exit_with_error(context, error)
862
+ except AuthError as error:
863
+ exit_with_error(
864
+ context,
865
+ OutputError(code="authentication_failed", message=str(error)),
866
+ semantic=ErrorSemantic.AUTH,
867
+ )
868
+ except (ValidationError, ValueError, TypeError):
869
+ exit_with_error(
870
+ context,
871
+ OutputError(
872
+ code="invalid_budget_response",
873
+ message="The budget response was invalid.",
874
+ ),
875
+ )
876
+
877
+ data = _set_data(updated)
878
+ output_engine(context).write_success(
879
+ schema="solwyn.budget-set.v1",
880
+ data=data,
881
+ table=_set_table(data),
882
+ )
883
+
884
+
885
+ @app.command(
886
+ "status",
887
+ epilog=help_contract(
888
+ endpoint="GET /api/v1/projects/{project}/budget",
889
+ auth="any",
890
+ exits=(
891
+ "0 success; 1 generic/API; 2 usage; 3 auth; 4 utilization gate; 5 not found"
892
+ ),
893
+ schema="solwyn.budget-status.v1",
894
+ guidance=(
895
+ "Pure read/poll; never reserves or authorizes spend. "
896
+ "Poll with status, never check.",
897
+ ),
898
+ ),
899
+ )
900
+ def status(
901
+ context: typer.Context,
902
+ max_utilization: Annotated[
903
+ float | None,
904
+ typer.Option(
905
+ "--max-utilization",
906
+ min=0.0,
907
+ max=100.0,
908
+ help=(
909
+ "CI deploy gate: exit 4 when utilization is at or above this "
910
+ "percentage."
911
+ ),
912
+ ),
913
+ ] = None,
914
+ ) -> None:
915
+ """Read current project budget status without authorizing spend."""
916
+ if max_utilization is not None and not math.isfinite(max_utilization):
917
+ raise typer.BadParameter(
918
+ "Must be a finite percentage from 0 through 100.",
919
+ param_hint="max-utilization",
920
+ )
921
+
922
+ runtime = command_context(context)
923
+ if runtime.project is None:
924
+ exit_with_error(
925
+ context,
926
+ OutputError(
927
+ code="project_required",
928
+ message="A project is required.",
929
+ hint=(
930
+ "Choose a project with --project, SOLWYN_PROJECT_ID, "
931
+ "or config default_project."
932
+ ),
933
+ ),
934
+ semantic=ErrorSemantic.USAGE,
935
+ )
936
+
937
+ credential = required_credential(context, resolve=resolve_credential)
938
+ try:
939
+ data = fetch_budget_status(
940
+ api_url=runtime.api_url,
941
+ credential=credential,
942
+ project_id=runtime.project,
943
+ )
944
+ except ApiError as error:
945
+ exit_with_error(context, error)
946
+ except AuthError as error:
947
+ exit_with_error(
948
+ context,
949
+ OutputError(code="authentication_failed", message=str(error)),
950
+ semantic=ErrorSemantic.AUTH,
951
+ )
952
+ except (ValidationError, ValueError, TypeError):
953
+ exit_with_error(
954
+ context,
955
+ OutputError(
956
+ code="invalid_budget_response",
957
+ message="The budget response was invalid.",
958
+ ),
959
+ )
960
+
961
+ rendered = _BudgetStatusOutput(
962
+ project_id=data.project_id,
963
+ budget_limit=data.budget_limit,
964
+ current_usage=data.current_usage,
965
+ remaining_budget=data.remaining_budget,
966
+ utilization_pct=data.utilization_pct,
967
+ budget_mode=data.budget_mode,
968
+ budget_period=data.budget_period,
969
+ )
970
+ output_engine(context).write_success(
971
+ schema="solwyn.budget-status.v1",
972
+ data=rendered,
973
+ table=_table(rendered),
974
+ )
975
+ if max_utilization is not None and data.utilization_pct >= max_utilization:
976
+ raise typer.Exit(code=ExitCode.BUDGET_DENIED)