lovelytics-cli 0.4.14__tar.gz → 0.4.16__tar.gz
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.
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/PKG-INFO +1 -1
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/cli.py +58 -1
- lovelytics_cli-0.4.16/love/conduit.py +158 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/config.py +2 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/crate.py +7 -4
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/pyproject.toml +1 -1
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/LICENSE +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/README.md +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/__main__.py +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/auth.py +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/forecast.py +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/genie.py +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/install.py +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/linguo.py +0 -0
- {lovelytics_cli-0.4.14 → lovelytics_cli-0.4.16}/love/utils.py +0 -0
|
@@ -6,6 +6,7 @@ from love import forecast as forecast_commands
|
|
|
6
6
|
from love import crate as crate_commands
|
|
7
7
|
from love import genie as genie_commands
|
|
8
8
|
from love import install as install_commands
|
|
9
|
+
from love import conduit as conduit_commands
|
|
9
10
|
import json
|
|
10
11
|
import logging
|
|
11
12
|
import click_log
|
|
@@ -212,7 +213,8 @@ def install_crate(crate_name, params_file, download):
|
|
|
212
213
|
Downloads and installs the specified solution from the Lovelytics CodeArtifact
|
|
213
214
|
repository via Lando. Parameters can be provided via a config file (--params-file) and/or
|
|
214
215
|
environment variables prefixed with CRATE_ (e.g., CRATE_CATALOG_NAME). Environment
|
|
215
|
-
variables take precedence over values in the config file.
|
|
216
|
+
variables take precedence over values in the config file. Any parameter without a
|
|
217
|
+
dedicated crate flag is passed as --variable key=value.
|
|
216
218
|
|
|
217
219
|
Downloaded solution archives are cached under ~/.lovelytics/crates/. Use --download
|
|
218
220
|
to force a fresh download even when a cached archive exists.
|
|
@@ -267,6 +269,61 @@ def install_crate(crate_name, params_file, download):
|
|
|
267
269
|
click.echo(f"Successfully installed solution '{crate_name}'.")
|
|
268
270
|
|
|
269
271
|
|
|
272
|
+
@cli.group()
|
|
273
|
+
def conduit():
|
|
274
|
+
"""Entrypoint for Conduit commands."""
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@conduit.command(name="list-budgets")
|
|
279
|
+
def conduit_list_budgets():
|
|
280
|
+
"""List your Conduit budgets, marking the active one."""
|
|
281
|
+
conduit_commands.list_budgets()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@conduit.command(name="set-active-budget")
|
|
285
|
+
@click.argument("budget_id")
|
|
286
|
+
def conduit_set_active_budget(budget_id):
|
|
287
|
+
"""Make BUDGET_ID the budget your Conduit usage is spent against."""
|
|
288
|
+
conduit_commands.set_active_budget(budget_id)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@conduit.command(name="list-models")
|
|
292
|
+
@click.option(
|
|
293
|
+
"--budget-id",
|
|
294
|
+
default=None,
|
|
295
|
+
help="Budget to list models for. Defaults to the active budget.",
|
|
296
|
+
)
|
|
297
|
+
def conduit_list_models(budget_id):
|
|
298
|
+
"""List the models available on a Conduit budget."""
|
|
299
|
+
conduit_commands.list_models(budget_id)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@conduit.command(
|
|
303
|
+
name="claude",
|
|
304
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
305
|
+
)
|
|
306
|
+
@click.option(
|
|
307
|
+
"--budget-id",
|
|
308
|
+
default=None,
|
|
309
|
+
help="Budget to spend against. Defaults to the active budget.",
|
|
310
|
+
)
|
|
311
|
+
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
|
|
312
|
+
def conduit_claude(budget_id, claude_args):
|
|
313
|
+
"""Start Claude Code against a Conduit budget.
|
|
314
|
+
|
|
315
|
+
The Conduit environment variables are scoped to the launched session and
|
|
316
|
+
your shell is left unchanged. Provider variables such as
|
|
317
|
+
CLAUDE_CODE_USE_BEDROCK are removed from the session. Extra arguments and
|
|
318
|
+
flags are passed straight to claude, e.g. `love conduit claude --resume` or
|
|
319
|
+
`love conduit claude -p "..." --model opus`. Use `--` only to forward
|
|
320
|
+
`--help` or a literal `--budget-id`, e.g. `love conduit claude -- --help`.
|
|
321
|
+
|
|
322
|
+
GATEWAY_URL / HEIMDALL_URL select the environment (production by default).
|
|
323
|
+
"""
|
|
324
|
+
conduit_commands.launch_claude(budget_id, claude_args)
|
|
325
|
+
|
|
326
|
+
|
|
270
327
|
@cli.group(name="list")
|
|
271
328
|
def list_group():
|
|
272
329
|
"""Entrypoint for list commands."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Logic for using Conduit budgets and running Claude Code against Conduit."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from love import auth, install
|
|
8
|
+
from love.config import CONDUIT_BUDGETS_URL, CONDUIT_URL
|
|
9
|
+
|
|
10
|
+
DEFAULT_OPUS_MODEL = "claude-opus-5-5"
|
|
11
|
+
DEFAULT_SONNET_MODEL = "claude-sonnet-5-5"
|
|
12
|
+
DEFAULT_HAIKU_MODEL = "claude-haiku-4-5"
|
|
13
|
+
CONDUIT_GRANT_HEADER = "X-Conduit-Grant-Id"
|
|
14
|
+
|
|
15
|
+
# Variables that would redirect or override Claude Code's provider, auth or model
|
|
16
|
+
# selection away from Conduit.
|
|
17
|
+
CONFLICTING_ENV_VARS = (
|
|
18
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
19
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
20
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
21
|
+
"CLAUDE_CODE_SKIP_BEDROCK_AUTH",
|
|
22
|
+
"CLAUDE_CODE_SKIP_VERTEX_AUTH",
|
|
23
|
+
"CLAUDE_CODE_SKIP_FOUNDRY_AUTH",
|
|
24
|
+
"ANTHROPIC_API_KEY",
|
|
25
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
26
|
+
"ANTHROPIC_BEDROCK_BASE_URL",
|
|
27
|
+
"ANTHROPIC_VERTEX_BASE_URL",
|
|
28
|
+
"ANTHROPIC_VERTEX_PROJECT_ID",
|
|
29
|
+
"ANTHROPIC_FOUNDRY_BASE_URL",
|
|
30
|
+
"ANTHROPIC_FOUNDRY_RESOURCE",
|
|
31
|
+
"ANTHROPIC_FOUNDRY_API_KEY",
|
|
32
|
+
"AWS_BEARER_TOKEN_BEDROCK",
|
|
33
|
+
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
34
|
+
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
|
35
|
+
"CLAUDE_CODE_SUBAGENT_MODEL",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _request(method, path, **kwargs):
|
|
40
|
+
"""Call the Conduit budget API and return the parsed JSON response."""
|
|
41
|
+
token = auth.AuthHandler.get_auth()["token"]
|
|
42
|
+
res = requests.request(
|
|
43
|
+
method,
|
|
44
|
+
f"{CONDUIT_BUDGETS_URL}{path}",
|
|
45
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
46
|
+
timeout=30,
|
|
47
|
+
**kwargs,
|
|
48
|
+
)
|
|
49
|
+
if not res.ok:
|
|
50
|
+
detail = res.text
|
|
51
|
+
try:
|
|
52
|
+
detail = res.json().get("detail") or detail
|
|
53
|
+
except (ValueError, AttributeError):
|
|
54
|
+
pass
|
|
55
|
+
raise click.ClickException(
|
|
56
|
+
f"Conduit request {method} {path} failed with HTTP {res.status_code}: "
|
|
57
|
+
f"{detail}"
|
|
58
|
+
)
|
|
59
|
+
return res.json()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _format_amount(minor, currency):
|
|
63
|
+
"""Format an amount in minor units, or '-' when there is none."""
|
|
64
|
+
if minor is None:
|
|
65
|
+
return "-"
|
|
66
|
+
return f"{minor / 100:.2f} {currency or ''}".strip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_budgets():
|
|
70
|
+
"""Print the caller's Conduit budgets, marking the active one."""
|
|
71
|
+
budgets = _request("GET", "/mine")
|
|
72
|
+
if not budgets:
|
|
73
|
+
click.echo("No Conduit budgets found.")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
active = _request("GET", "/active-grant")
|
|
77
|
+
active_id = active.get("id") if active else None
|
|
78
|
+
|
|
79
|
+
for budget in budgets:
|
|
80
|
+
marker = "*" if budget.get("id") == active_id else " "
|
|
81
|
+
currency = budget.get("currency")
|
|
82
|
+
click.echo(
|
|
83
|
+
f"{marker} {str(budget.get('id')):<38} "
|
|
84
|
+
f"{budget.get('name') or budget.get('kind') or '':<30} "
|
|
85
|
+
f"{budget.get('status') or '':<12} "
|
|
86
|
+
f"spent {_format_amount(budget.get('spent_minor'), currency)} / "
|
|
87
|
+
f"approved {_format_amount(budget.get('approved_amount_minor'), currency)}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_active_budget(budget_id):
|
|
92
|
+
"""Make the given budget the server-side active Conduit budget."""
|
|
93
|
+
grant = _request("PUT", "/active-grant", json={"grant_id": budget_id})
|
|
94
|
+
label = (grant or {}).get("name") or (grant or {}).get("id") or budget_id
|
|
95
|
+
click.echo(f"Active Conduit budget set to {label} ({budget_id}).")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_available_models(budget_id=None):
|
|
99
|
+
"""Fetch the models available on a budget, or the active budget if omitted."""
|
|
100
|
+
params = {"grant_id": budget_id} if budget_id else None
|
|
101
|
+
return _request("GET", "/available-models", params=params)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def list_models(budget_id=None):
|
|
105
|
+
"""Print the models available on a budget."""
|
|
106
|
+
info = get_available_models(budget_id)
|
|
107
|
+
label = info.get("grant_id") or info.get("grant_kind")
|
|
108
|
+
click.echo(f"Budget {label} ({info.get('grant_status')})")
|
|
109
|
+
for model in info.get("models", []):
|
|
110
|
+
availability = "available" if model.get("available") else "unavailable"
|
|
111
|
+
click.echo(
|
|
112
|
+
f"{str(model.get('id')):<40} {model.get('tier') or '':<12} {availability}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_claude_env(token, budget_id=None, base_env=None):
|
|
117
|
+
"""Build the environment for a Claude Code session against Conduit.
|
|
118
|
+
|
|
119
|
+
Returns a new mapping and never mutates os.environ or base_env.
|
|
120
|
+
"""
|
|
121
|
+
env = dict(os.environ if base_env is None else base_env)
|
|
122
|
+
for name in (*CONFLICTING_ENV_VARS, "ANTHROPIC_CUSTOM_HEADERS"):
|
|
123
|
+
env.pop(name, None)
|
|
124
|
+
|
|
125
|
+
env["ANTHROPIC_BASE_URL"] = CONDUIT_URL
|
|
126
|
+
env["ANTHROPIC_AUTH_TOKEN"] = token
|
|
127
|
+
env["ANTHROPIC_MODEL"] = DEFAULT_OPUS_MODEL
|
|
128
|
+
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = DEFAULT_OPUS_MODEL
|
|
129
|
+
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = DEFAULT_SONNET_MODEL
|
|
130
|
+
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = DEFAULT_HAIKU_MODEL
|
|
131
|
+
if budget_id:
|
|
132
|
+
env["ANTHROPIC_CUSTOM_HEADERS"] = f"{CONDUIT_GRANT_HEADER}: {budget_id}"
|
|
133
|
+
return env
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def launch_claude(budget_id=None, claude_args=()):
|
|
137
|
+
"""Replace this process with Claude Code configured to use Conduit.
|
|
138
|
+
|
|
139
|
+
execve is used instead of subprocess so Ctrl-C reaches Claude Code alone and
|
|
140
|
+
the child gets exactly the environment built here.
|
|
141
|
+
"""
|
|
142
|
+
claude_binary = install.ensure_claude_binary()
|
|
143
|
+
|
|
144
|
+
info = get_available_models(budget_id)
|
|
145
|
+
status = info.get("grant_status")
|
|
146
|
+
click.echo(
|
|
147
|
+
"Starting Claude Code against Conduit budget "
|
|
148
|
+
f"{info.get('grant_id') or info.get('grant_kind') or budget_id} ({status})..."
|
|
149
|
+
)
|
|
150
|
+
if status != "available":
|
|
151
|
+
click.echo(
|
|
152
|
+
f"Warning: this budget's status is '{status}', so requests may be "
|
|
153
|
+
"rejected or exceed its allowance."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
token = auth.AuthHandler.get_auth()["token"]
|
|
157
|
+
env = build_claude_env(token, budget_id)
|
|
158
|
+
os.execve(claude_binary, [claude_binary, *claude_args], env) # nosec
|
|
@@ -15,6 +15,8 @@ DBT_PROFILE_DIR = os.path.join(CONFIG_PATH, "profiles")
|
|
|
15
15
|
LANDO_URL = os.getenv("LANDO_URL", "https://landoapi.saas.lovelytics.com")
|
|
16
16
|
HEIMDALL_URL = os.getenv("HEIMDALL_URL", "https://heimdall.lovelytics.com")
|
|
17
17
|
GATEWAY_URL = os.getenv("GATEWAY_URL", "https://api.lovelytics.com")
|
|
18
|
+
CONDUIT_URL = f"{GATEWAY_URL}/v2/conduit"
|
|
19
|
+
CONDUIT_BUDGETS_URL = f"{GATEWAY_URL}/v2/aiutils/budget-requests"
|
|
18
20
|
|
|
19
21
|
CONFIG_OPTIONS = {
|
|
20
22
|
"runtime": {
|
|
@@ -155,9 +155,12 @@ def build_crate_install_args(source_path: str, params: dict) -> list:
|
|
|
155
155
|
if flag:
|
|
156
156
|
args.extend([flag, str(value)])
|
|
157
157
|
else:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
# crate's --variable splits on commas and quotes; such values can't round-trip.
|
|
159
|
+
if "," in str(value) or '"' in str(value):
|
|
160
|
+
raise click.ClickException(
|
|
161
|
+
f"Parameter '{key}' must not contain a comma or double quote."
|
|
162
|
+
)
|
|
163
|
+
# Anything else is a crate variable (crate.yaml `variables:`).
|
|
164
|
+
args.extend(["--variable", f"{key}={value}"])
|
|
162
165
|
|
|
163
166
|
return args
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|