llamactl 0.2.7a1__py3-none-any.whl → 0.3.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.
Files changed (41) hide show
  1. llama_deploy/cli/__init__.py +9 -22
  2. llama_deploy/cli/app.py +69 -0
  3. llama_deploy/cli/auth/client.py +362 -0
  4. llama_deploy/cli/client.py +47 -170
  5. llama_deploy/cli/commands/aliased_group.py +33 -0
  6. llama_deploy/cli/commands/auth.py +696 -0
  7. llama_deploy/cli/commands/deployment.py +300 -0
  8. llama_deploy/cli/commands/env.py +211 -0
  9. llama_deploy/cli/commands/init.py +313 -0
  10. llama_deploy/cli/commands/serve.py +239 -0
  11. llama_deploy/cli/config/_config.py +390 -0
  12. llama_deploy/cli/config/_migrations.py +65 -0
  13. llama_deploy/cli/config/auth_service.py +130 -0
  14. llama_deploy/cli/config/env_service.py +67 -0
  15. llama_deploy/cli/config/migrations/0001_init.sql +35 -0
  16. llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
  17. llama_deploy/cli/config/migrations/__init__.py +7 -0
  18. llama_deploy/cli/config/schema.py +61 -0
  19. llama_deploy/cli/env.py +5 -3
  20. llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
  21. llama_deploy/cli/interactive_prompts/utils.py +6 -72
  22. llama_deploy/cli/options.py +27 -5
  23. llama_deploy/cli/py.typed +0 -0
  24. llama_deploy/cli/styles.py +10 -0
  25. llama_deploy/cli/textual/deployment_form.py +263 -36
  26. llama_deploy/cli/textual/deployment_help.py +53 -0
  27. llama_deploy/cli/textual/deployment_monitor.py +466 -0
  28. llama_deploy/cli/textual/git_validation.py +20 -21
  29. llama_deploy/cli/textual/github_callback_server.py +17 -14
  30. llama_deploy/cli/textual/llama_loader.py +13 -1
  31. llama_deploy/cli/textual/secrets_form.py +28 -8
  32. llama_deploy/cli/textual/styles.tcss +49 -8
  33. llama_deploy/cli/utils/env_inject.py +23 -0
  34. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
  35. llamactl-0.3.0.dist-info/RECORD +38 -0
  36. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
  37. llama_deploy/cli/commands.py +0 -549
  38. llama_deploy/cli/config.py +0 -173
  39. llama_deploy/cli/textual/profile_form.py +0 -171
  40. llamactl-0.2.7a1.dist-info/RECORD +0 -19
  41. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,390 @@
1
+ """Configuration and profile management for llamactl"""
2
+
3
+ import functools
4
+ import os
5
+ import sqlite3
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ._migrations import run_migrations
11
+ from .schema import DEFAULT_ENVIRONMENT, Auth, DeviceOIDC, Environment
12
+
13
+
14
+ def _serialize_device_oidc(value: DeviceOIDC | None) -> str | None:
15
+ if value is None:
16
+ return None
17
+ return value.model_dump_json()
18
+
19
+
20
+ def _deserialize_device_oidc(value: str | None) -> DeviceOIDC | None:
21
+ if not value:
22
+ return None
23
+ return DeviceOIDC.model_validate_json(value)
24
+
25
+
26
+ def _to_auth(row: Any) -> Auth:
27
+ return Auth(
28
+ id=row[0],
29
+ name=row[1],
30
+ api_url=row[2],
31
+ project_id=row[3],
32
+ api_key=row[4],
33
+ api_key_id=row[5],
34
+ device_oidc=_deserialize_device_oidc(row[6]),
35
+ )
36
+
37
+
38
+ def _to_environment(row: Any) -> Environment:
39
+ return Environment(
40
+ api_url=row[0],
41
+ requires_auth=bool(row[1]),
42
+ min_llamactl_version=row[2],
43
+ )
44
+
45
+
46
+ class ConfigManager:
47
+ """Manages profiles and configuration using SQLite"""
48
+
49
+ def __init__(self, init_database: bool = True):
50
+ self.config_dir = self._get_config_dir()
51
+ self.db_path = self.config_dir / "profiles.db"
52
+ self._ensure_config_dir()
53
+ if init_database:
54
+ self._init_database()
55
+
56
+ def _get_config_dir(self) -> Path:
57
+ """Get the configuration directory path based on OS.
58
+
59
+ Honors LLAMACTL_CONFIG_DIR when set. This helps tests isolate state.
60
+ """
61
+ override = os.environ.get("LLAMACTL_CONFIG_DIR")
62
+ if override:
63
+ return Path(override).expanduser()
64
+ if os.name == "nt": # Windows
65
+ config_dir = Path(os.environ.get("APPDATA", "~")) / "llamactl"
66
+ else: # Unix-like (Linux, macOS)
67
+ config_dir = Path.home() / ".config" / "llamactl"
68
+ return config_dir.expanduser()
69
+
70
+ def _ensure_config_dir(self):
71
+ """Create configuration directory if it doesn't exist"""
72
+ self.config_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ def _init_database(self):
75
+ """Initialize SQLite database and run migrations; then seed defaults."""
76
+
77
+ with sqlite3.connect(self.db_path) as conn:
78
+ # Apply ad-hoc SQL migrations based on PRAGMA user_version
79
+ run_migrations(conn)
80
+
81
+ conn.commit()
82
+
83
+ def destroy_database(self):
84
+ """Destroy the database"""
85
+ self.db_path.unlink()
86
+ self._init_database()
87
+
88
+ #############################################
89
+ ## Settings
90
+ #############################################
91
+
92
+ def set_settings_current_profile(self, name: str | None):
93
+ """Set or clear the current active profile.
94
+
95
+ If name is None, the setting is removed.
96
+ """
97
+ with sqlite3.connect(self.db_path) as conn:
98
+ if name is None:
99
+ conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
100
+ else:
101
+ conn.execute(
102
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
103
+ (name,),
104
+ )
105
+ conn.commit()
106
+
107
+ def get_settings_current_profile_name(self) -> str | None:
108
+ """Get the name of the current active profile"""
109
+ with sqlite3.connect(self.db_path) as conn:
110
+ cursor = conn.execute(
111
+ "SELECT value FROM settings WHERE key = 'current_profile'"
112
+ )
113
+ row = cursor.fetchone()
114
+ return row[0] if row else None
115
+
116
+ def set_settings_current_environment(self, api_url: str) -> None:
117
+ """Set the current environment by URL.
118
+
119
+ Requires the environment row to already exist (validated elsewhere, e.g. via
120
+ a probe before creation). Raises ValueError if not found.
121
+ """
122
+ with sqlite3.connect(self.db_path) as conn:
123
+ conn.execute(
124
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
125
+ (api_url,),
126
+ )
127
+ conn.commit()
128
+
129
+ def create_profile(
130
+ self,
131
+ name: str,
132
+ api_url: str,
133
+ project_id: str,
134
+ api_key: str | None = None,
135
+ api_key_id: str | None = None,
136
+ device_oidc: DeviceOIDC | None = None,
137
+ ) -> Auth:
138
+ """Create a new auth profile"""
139
+ if not project_id.strip():
140
+ raise ValueError("Project ID is required")
141
+ profile = Auth(
142
+ id=str(uuid.uuid4()),
143
+ name=name,
144
+ api_url=api_url,
145
+ project_id=project_id,
146
+ api_key=api_key,
147
+ api_key_id=api_key_id,
148
+ device_oidc=device_oidc,
149
+ )
150
+
151
+ with sqlite3.connect(self.db_path) as conn:
152
+ try:
153
+ conn.execute(
154
+ "INSERT INTO profiles (id, name, api_url, project_id, api_key, api_key_id, device_oidc) VALUES (?, ?, ?, ?, ?, ?, ?)",
155
+ (
156
+ profile.id,
157
+ profile.name,
158
+ profile.api_url,
159
+ profile.project_id,
160
+ profile.api_key,
161
+ profile.api_key_id,
162
+ _serialize_device_oidc(profile.device_oidc),
163
+ ),
164
+ )
165
+ conn.commit()
166
+ except sqlite3.IntegrityError:
167
+ raise ValueError(
168
+ f"Profile '{name}' already exists for environment '{api_url}'"
169
+ )
170
+
171
+ return profile
172
+
173
+ def get_current_profile(self, env_url: str) -> Auth | None:
174
+ """Get the current active profile"""
175
+ current_name = self.get_settings_current_profile_name()
176
+ if current_name:
177
+ return self.get_profile(current_name, env_url)
178
+ return None
179
+
180
+ def get_current_environment(self) -> Environment:
181
+ """Get the current active environment"""
182
+ with sqlite3.connect(self.db_path) as conn:
183
+ cursor = conn.execute(
184
+ "SELECT value FROM settings WHERE key = 'current_environment_api_url'"
185
+ )
186
+ row = cursor.fetchone()
187
+ api_url = row[0] if row else DEFAULT_ENVIRONMENT.api_url
188
+
189
+ env_row = conn.execute(
190
+ "SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
191
+ (api_url,),
192
+ ).fetchone()
193
+ if env_row:
194
+ return _to_environment(env_row)
195
+
196
+ # Fallback: return an in-memory default without writing to DB
197
+ if api_url == DEFAULT_ENVIRONMENT.api_url:
198
+ return DEFAULT_ENVIRONMENT
199
+ return Environment(
200
+ api_url=api_url, requires_auth=False, min_llamactl_version=None
201
+ )
202
+
203
+ ##################################
204
+ ## Profiles
205
+ ##################################
206
+
207
+ def get_profile(self, name: str, env_url: str) -> Auth | None:
208
+ """Get a profile by name"""
209
+ with sqlite3.connect(self.db_path) as conn:
210
+ row = conn.execute(
211
+ "SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE name = ? AND api_url = ?",
212
+ (name, env_url),
213
+ ).fetchone()
214
+ if row:
215
+ return _to_auth(row)
216
+ return None
217
+
218
+ def get_profile_by_id(self, id: str) -> Auth | None:
219
+ """Get a profile by ID"""
220
+ with sqlite3.connect(self.db_path) as conn:
221
+ row = conn.execute(
222
+ "SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE id = ?",
223
+ (id,),
224
+ ).fetchone()
225
+ if row:
226
+ return _to_auth(row)
227
+ return None
228
+
229
+ def get_profile_by_api_key(self, env_url: str, api_key: str) -> Auth | None:
230
+ """Get a profile by api_key within an environment URL."""
231
+ with sqlite3.connect(self.db_path) as conn:
232
+ row = conn.execute(
233
+ """
234
+ SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc
235
+ FROM profiles
236
+ WHERE api_url = ? AND api_key = ?
237
+ LIMIT 1
238
+ """,
239
+ (env_url, api_key),
240
+ ).fetchone()
241
+ if row:
242
+ return _to_auth(row)
243
+ return None
244
+
245
+ def get_profile_by_device_user_id(self, env_url: str, user_id: str) -> Auth | None:
246
+ """Get a profile by device OIDC user_id within an environment URL."""
247
+ with sqlite3.connect(self.db_path) as conn:
248
+ row = conn.execute(
249
+ """
250
+ SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc
251
+ FROM profiles
252
+ WHERE api_url = ? AND JSON_EXTRACT(device_oidc, '$.user_id') = ?
253
+ LIMIT 1
254
+ """,
255
+ (env_url, user_id),
256
+ ).fetchone()
257
+ if row:
258
+ return _to_auth(row)
259
+ return None
260
+
261
+ def list_profiles(self, env_url: str) -> list[Auth]:
262
+ """List all profiles"""
263
+ with sqlite3.connect(self.db_path) as conn:
264
+ return [
265
+ _to_auth(row)
266
+ for row in conn.execute(
267
+ "SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE api_url = ? ORDER BY name",
268
+ (env_url,),
269
+ ).fetchall()
270
+ ]
271
+
272
+ def delete_profile(self, name: str, env_url: str) -> bool:
273
+ """Delete a profile by name. Returns True if deleted, False if not found."""
274
+ with sqlite3.connect(self.db_path) as conn:
275
+ cursor = conn.execute(
276
+ "DELETE FROM profiles WHERE name = ? AND api_url = ?", (name, env_url)
277
+ )
278
+ conn.commit()
279
+
280
+ # If this was the active profile, clear it
281
+ if self.get_settings_current_profile_name() == name:
282
+ self.set_settings_current_profile(None)
283
+
284
+ return cursor.rowcount > 0
285
+
286
+ def set_project(self, profile_name: str, env_url: str, project_id: str) -> bool:
287
+ """Set the project for a profile. Returns True if profile exists."""
288
+ with sqlite3.connect(self.db_path) as conn:
289
+ cursor = conn.execute(
290
+ "UPDATE profiles SET project_id = ? WHERE name = ? AND api_url = ?",
291
+ (project_id, profile_name, env_url),
292
+ )
293
+ conn.commit()
294
+ return cursor.rowcount > 0
295
+
296
+ def update_profile(self, profile: Auth) -> None:
297
+ """Update a profile"""
298
+ with sqlite3.connect(self.db_path) as conn:
299
+ conn.execute(
300
+ "UPDATE profiles SET name = ?, api_url = ?, project_id = ?, api_key = ?, api_key_id = ?, device_oidc = ? WHERE id = ?",
301
+ (
302
+ profile.name,
303
+ profile.api_url,
304
+ profile.project_id,
305
+ profile.api_key,
306
+ profile.api_key_id,
307
+ _serialize_device_oidc(profile.device_oidc),
308
+ profile.id,
309
+ ),
310
+ )
311
+ conn.commit()
312
+
313
+ ##################################
314
+ ## Environments
315
+ ##################################
316
+ def create_or_update_environment(
317
+ self, api_url: str, requires_auth: bool, min_llamactl_version: str | None = None
318
+ ) -> None:
319
+ """Create or update an environment row."""
320
+ with sqlite3.connect(self.db_path) as conn:
321
+ conn.execute(
322
+ "INSERT OR REPLACE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
323
+ (api_url, 1 if requires_auth else 0, min_llamactl_version),
324
+ )
325
+ conn.commit()
326
+
327
+ def get_environment(self, api_url: str) -> Environment | None:
328
+ """Retrieve an environment by URL."""
329
+ with sqlite3.connect(self.db_path) as conn:
330
+ row = conn.execute(
331
+ "SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
332
+ (api_url,),
333
+ ).fetchone()
334
+ if row:
335
+ return _to_environment(row)
336
+ return None
337
+
338
+ def list_environments(self) -> list[Environment]:
339
+ """List all environments."""
340
+ with sqlite3.connect(self.db_path) as conn:
341
+ envs = [
342
+ _to_environment(row)
343
+ for row in conn.execute(
344
+ "SELECT api_url, requires_auth, min_llamactl_version FROM environments ORDER BY api_url"
345
+ ).fetchall()
346
+ ]
347
+ if not envs:
348
+ envs = [DEFAULT_ENVIRONMENT]
349
+ return envs
350
+
351
+ def delete_environment(self, api_url: str) -> bool:
352
+ """Delete an environment and all associated profiles.
353
+
354
+ Returns True if the environment existed and was deleted, False otherwise.
355
+ If the deleted environment was current, switch current to the default URL.
356
+ """
357
+ with sqlite3.connect(self.db_path) as conn:
358
+ # Check existence
359
+ exists_cursor = conn.execute(
360
+ "SELECT 1 FROM environments WHERE api_url = ?",
361
+ (api_url,),
362
+ )
363
+ if exists_cursor.fetchone() is None:
364
+ return False
365
+
366
+ # Delete profiles tied to this environment
367
+ conn.execute("DELETE FROM profiles WHERE api_url = ?", (api_url,))
368
+
369
+ # Delete environment row
370
+ conn.execute("DELETE FROM environments WHERE api_url = ?", (api_url,))
371
+
372
+ # If current environment is this one, reset to default
373
+ setting_cursor = conn.execute(
374
+ "SELECT value FROM settings WHERE key = 'current_environment_api_url'"
375
+ )
376
+ row = setting_cursor.fetchone()
377
+ if row and row[0] == api_url:
378
+ conn.execute(
379
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
380
+ (DEFAULT_ENVIRONMENT.api_url,),
381
+ )
382
+
383
+ conn.commit()
384
+ return True
385
+
386
+
387
+ # Global config manager instance
388
+ @functools.cache
389
+ def config_manager() -> ConfigManager:
390
+ return ConfigManager()
@@ -0,0 +1,65 @@
1
+ """Ad-hoc SQLite schema migrations using PRAGMA user_version.
2
+
3
+ Inspired by https://eskerda.com/sqlite-schema-migrations-python/
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ import sqlite3
11
+ from importlib import import_module, resources
12
+ from importlib.resources.abc import Traversable
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ _MIGRATIONS_PKG = "llama_deploy.cli.config.migrations"
18
+ _USER_VERSION_PATTERN = re.compile(r"pragma\s+user_version\s*=\s*(\d+)", re.IGNORECASE)
19
+
20
+
21
+ def _iter_migration_files() -> list[Traversable]:
22
+ """Yield packaged SQL migration files in lexicographic order."""
23
+ pkg = import_module(_MIGRATIONS_PKG)
24
+ root = resources.files(pkg)
25
+ files = (p for p in root.iterdir() if p.name.endswith(".sql"))
26
+ if not files:
27
+ raise ValueError("No migration files found")
28
+ return sorted(files, key=lambda p: p.name)
29
+
30
+
31
+ def _parse_target_version(sql_text: str) -> int | None:
32
+ """Return target schema version declared in the first PRAGMA line, if any."""
33
+ first_line = sql_text.splitlines()[0] if sql_text else ""
34
+ match = _USER_VERSION_PATTERN.search(first_line)
35
+ return int(match.group(1)) if match else None
36
+
37
+
38
+ def run_migrations(conn: sqlite3.Connection) -> None:
39
+ """Apply pending migrations found under the migrations package.
40
+
41
+ Each migration file should start with a `PRAGMA user_version=N;` line.
42
+ Files are applied in lexicographic order and only when N > current_version.
43
+ """
44
+ cur = conn.cursor()
45
+ current_version_row = cur.execute("PRAGMA user_version").fetchone()
46
+ current_version = int(current_version_row[0]) if current_version_row else 0
47
+
48
+ for path in _iter_migration_files():
49
+ sql_text = path.read_text()
50
+ target_version = _parse_target_version(sql_text) or 0
51
+ if target_version <= current_version:
52
+ continue
53
+
54
+ try:
55
+ logger.debug(
56
+ "Applying migration %s → target version %s", path.name, target_version
57
+ )
58
+ cur.executescript("BEGIN;\n" + sql_text)
59
+ except Exception as exc: # noqa: BLE001 – we surface the exact error
60
+ logger.error("Failed migration %s: %s", path.name, exc)
61
+ cur.execute("ROLLBACK")
62
+ raise
63
+ else:
64
+ cur.execute("COMMIT")
65
+ current_version = target_version
@@ -0,0 +1,130 @@
1
+ import asyncio
2
+
3
+ from llama_deploy.cli.auth.client import PlatformAuthClient, RefreshMiddleware
4
+ from llama_deploy.cli.config._config import Auth, ConfigManager, Environment
5
+ from llama_deploy.cli.config.schema import DeviceOIDC
6
+ from llama_deploy.core.client.manage_client import ControlPlaneClient, httpx
7
+ from llama_deploy.core.schema import VersionResponse
8
+ from llama_deploy.core.schema.projects import ProjectSummary
9
+
10
+
11
+ class AuthService:
12
+ def __init__(self, config_manager: ConfigManager, env: Environment):
13
+ self.config_manager = config_manager
14
+ self.env = env
15
+
16
+ def list_profiles(self) -> list[Auth]:
17
+ return self.config_manager.list_profiles(self.env.api_url)
18
+
19
+ def get_profile(self, name: str) -> Auth | None:
20
+ return self.config_manager.get_profile(name, self.env.api_url)
21
+
22
+ def get_profile_by_id(self, id: str) -> Auth | None:
23
+ return self.config_manager.get_profile_by_id(id)
24
+
25
+ def set_current_profile(self, name: str) -> None:
26
+ self.config_manager.set_settings_current_profile(name)
27
+
28
+ def select_any_profile(self) -> None:
29
+ # best effort to select a profile within the environment
30
+ profiles = self.list_profiles()
31
+ if profiles:
32
+ self.set_current_profile(profiles[0].name)
33
+
34
+ def get_current_profile(self) -> Auth | None:
35
+ return self.config_manager.get_current_profile(self.env.api_url)
36
+
37
+ def create_profile_from_token(self, project_id: str, api_key: str | None) -> Auth:
38
+ base = _auto_profile_name_from_token(api_key or "") if api_key else "default"
39
+ auth = self.config_manager.create_profile(
40
+ base, self.env.api_url, project_id, api_key
41
+ )
42
+ self.config_manager.set_settings_current_profile(auth.name)
43
+ return auth
44
+
45
+ def create_or_update_profile_from_oidc(
46
+ self, project_id: str, device_oidc: DeviceOIDC
47
+ ) -> Auth:
48
+ base = device_oidc.email
49
+ existing = self.config_manager.get_profile_by_device_user_id(
50
+ self.env.api_url, device_oidc.user_id
51
+ )
52
+ if existing:
53
+ existing.device_oidc = device_oidc
54
+ self.config_manager.update_profile(existing)
55
+ auth = existing
56
+ else:
57
+ auth = self.config_manager.create_profile(
58
+ base, self.env.api_url, project_id, device_oidc=device_oidc
59
+ )
60
+ self.config_manager.set_settings_current_profile(auth.name)
61
+ return auth
62
+
63
+ def update_profile(self, profile: Auth) -> None:
64
+ self.config_manager.update_profile(profile)
65
+
66
+ async def delete_profile(self, name: str) -> bool:
67
+ profile = self.get_profile(name)
68
+ if profile and profile.api_key_id:
69
+ async with self.profile_client(profile) as client:
70
+ try:
71
+ await client.delete_api_key(profile.api_key_id)
72
+ except Exception:
73
+ pass
74
+ return self.config_manager.delete_profile(name, self.env.api_url)
75
+
76
+ def set_project(self, name: str, project_id: str) -> None:
77
+ self.config_manager.set_project(name, self.env.api_url, project_id)
78
+
79
+ def fetch_server_version(self) -> VersionResponse:
80
+ async def _fetch_server_version() -> VersionResponse:
81
+ async with ControlPlaneClient.ctx(self.env.api_url) as client:
82
+ version = await client.server_version()
83
+ return version
84
+
85
+ return asyncio.run(_fetch_server_version())
86
+
87
+ def _validate_token_and_list_projects(self, api_key: str) -> list[ProjectSummary]:
88
+ async def _run():
89
+ async with ControlPlaneClient.ctx(self.env.api_url, api_key) as client:
90
+ return await client.list_projects()
91
+
92
+ return asyncio.run(_run())
93
+
94
+ def auth_middleware(self, profile: Auth | None = None) -> httpx.Auth | None:
95
+ profile = profile or self.get_current_profile()
96
+ if profile and profile.device_oidc:
97
+ _profile = profile # copy to assist type checker being inflexible
98
+
99
+ async def _on_refresh(updated: DeviceOIDC) -> None:
100
+ # Persist refreshed tokens to the database synchronously within async wrapper
101
+ self.refresh_to_db(_profile.id, updated)
102
+
103
+ return RefreshMiddleware(
104
+ profile.device_oidc,
105
+ _on_refresh,
106
+ )
107
+ return None
108
+
109
+ def refresh_to_db(self, profile_id: str, device_oidc: DeviceOIDC) -> None:
110
+ profile = self.get_profile_by_id(profile_id)
111
+ if profile:
112
+ profile.device_oidc = device_oidc
113
+ self.config_manager.update_profile(profile)
114
+
115
+ def profile_client(self, profile: Auth | None = None) -> PlatformAuthClient:
116
+ profile = profile or self.get_current_profile()
117
+ if not profile:
118
+ raise ValueError("No active profile")
119
+ return PlatformAuthClient(
120
+ profile.api_url, profile.api_key, self.auth_middleware(profile)
121
+ )
122
+
123
+
124
+ def _auto_profile_name_from_token(api_key: str) -> str:
125
+ token = api_key or "token"
126
+ cleaned = token.replace(" ", "")
127
+ first = cleaned[:6]
128
+ last = cleaned[-4:] if len(cleaned) > 10 else cleaned[-2:]
129
+ base = f"{first}****{last}"
130
+ return base
@@ -0,0 +1,67 @@
1
+ from dataclasses import replace
2
+ from typing import Callable
3
+
4
+ from llama_deploy.cli.config.schema import Environment
5
+
6
+ from ._config import ConfigManager, config_manager
7
+ from .auth_service import AuthService
8
+
9
+
10
+ class EnvService:
11
+ def __init__(self, config_manager: Callable[[], ConfigManager]):
12
+ self.config_manager = config_manager
13
+
14
+ def list_environments(self) -> list[Environment]:
15
+ return self.config_manager().list_environments()
16
+
17
+ def get_current_environment(self) -> Environment:
18
+ return self.config_manager().get_current_environment()
19
+
20
+ def switch_environment(self, api_url: str) -> Environment:
21
+ env = self.config_manager().get_environment(api_url)
22
+ if not env:
23
+ raise ValueError(
24
+ f"Environment '{api_url}' not found. Add it with 'llamactl auth env add <API_URL>'"
25
+ )
26
+ self.config_manager().set_settings_current_environment(api_url)
27
+ self.config_manager().set_settings_current_profile(None)
28
+ return env
29
+
30
+ def create_or_update_environment(self, env: Environment) -> None:
31
+ self.config_manager().create_or_update_environment(
32
+ env.api_url, env.requires_auth, env.min_llamactl_version
33
+ )
34
+ self.config_manager().set_settings_current_environment(env.api_url)
35
+ self.config_manager().set_settings_current_profile(None)
36
+
37
+ def delete_environment(self, api_url: str) -> bool:
38
+ return self.config_manager().delete_environment(api_url)
39
+
40
+ def current_auth_service(self) -> AuthService:
41
+ return AuthService(self.config_manager(), self.get_current_environment())
42
+
43
+ def auto_update_env(self, env: Environment) -> Environment:
44
+ svc = AuthService(self.config_manager(), env)
45
+ version = svc.fetch_server_version()
46
+ update = replace(env)
47
+ update.requires_auth = version.requires_auth
48
+ update.min_llamactl_version = version.min_llamactl_version
49
+ if update != env:
50
+ self.config_manager().create_or_update_environment(
51
+ update.api_url, update.requires_auth, update.min_llamactl_version
52
+ )
53
+ return update
54
+
55
+ def probe_environment(self, api_url: str) -> Environment:
56
+ clean = api_url.rstrip("/")
57
+ base_env = Environment(
58
+ api_url=clean, requires_auth=False, min_llamactl_version=None
59
+ )
60
+ svc = AuthService(self.config_manager(), base_env)
61
+ version = svc.fetch_server_version()
62
+ base_env.requires_auth = version.requires_auth
63
+ base_env.min_llamactl_version = version.min_llamactl_version
64
+ return base_env
65
+
66
+
67
+ service = EnvService(config_manager)
@@ -0,0 +1,35 @@
1
+ PRAGMA user_version=1;
2
+
3
+ -- Initial schema for llamactl config database
4
+
5
+ CREATE TABLE IF NOT EXISTS profiles (
6
+ name TEXT NOT NULL,
7
+ api_url TEXT NOT NULL,
8
+ project_id TEXT NOT NULL,
9
+ api_key TEXT,
10
+ PRIMARY KEY (name, api_url)
11
+ );
12
+
13
+ CREATE TABLE IF NOT EXISTS settings (
14
+ key TEXT PRIMARY KEY,
15
+ value TEXT NOT NULL
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS environments (
19
+ api_url TEXT PRIMARY KEY,
20
+ requires_auth INTEGER NOT NULL,
21
+ min_llamactl_version TEXT
22
+ );
23
+
24
+ -- Seed defaults (idempotent)
25
+ -- 1) Ensure current environment setting exists (do not overwrite if already set)
26
+ INSERT OR IGNORE INTO settings (key, value)
27
+ VALUES ('current_environment_api_url', 'https://api.cloud.llamaindex.ai');
28
+
29
+ -- 2) Backfill environments from any existing profiles (avoid duplicates)
30
+ INSERT OR IGNORE INTO environments (api_url, requires_auth)
31
+ SELECT DISTINCT api_url, 0 FROM profiles;
32
+
33
+ -- 3) Ensure the default cloud environment exists with auth required
34
+ INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version)
35
+ VALUES ('https://api.cloud.llamaindex.ai', 1, NULL);