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,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
|
+
)
|