codefreedom 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,3 @@
1
+ """CodeFreedom — Claude Code launcher and LiteLLM proxy management."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m codefreedom`."""
2
+
3
+ from codefreedom.cli.main import main
4
+
5
+ main()
@@ -0,0 +1 @@
1
+ """CodeFreedom CLI package."""
@@ -0,0 +1,122 @@
1
+ """Claude Code subcommand — launch Claude Code with profile-based routing.
2
+
3
+ Usage:
4
+ codefreedom claude [--profile NAME] [--sandbox] [--stop|--status|--list-profiles] [claude-args...]
5
+ cf cc [same]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ from codefreedom.env_loader import eprint, load_env_chain
14
+ from codefreedom.launcher import run_docker, run_local, status, stop
15
+ from codefreedom.profiles import (
16
+ get_profile_sandbox_image,
17
+ list_profiles,
18
+ load_profile_env,
19
+ )
20
+
21
+ # Default location for profiles — ~/.codefreedom/profiles/claude-code.json
22
+ # Can be overridden with CODEFREEDOM_PROFILES_FILE env var
23
+ import os as _os
24
+
25
+ _CODEFREEDOM_DIR = Path.home() / ".codefreedom"
26
+
27
+ DEFAULT_PROFILES_FILE = _os.environ.get(
28
+ "CODEFREEDOM_PROFILES_FILE",
29
+ str(_CODEFREEDOM_DIR / "profiles" / "claude-code.json"),
30
+ )
31
+
32
+
33
+ def run(args: argparse.Namespace) -> int:
34
+ """Execute the claude subcommand. Returns exit code."""
35
+
36
+ # Fast-path flags (no env loading needed)
37
+ if args.list_profiles:
38
+ profiles_path = _resolve_profiles_path()
39
+ profiles = list_profiles(profiles_path)
40
+ if not profiles:
41
+ eprint("[PROFILES] No profiles found.")
42
+ return 0
43
+ eprint(f"[PROFILES] Available profiles ({profiles_path}):\n")
44
+ for p in profiles:
45
+ override_word = "override" if len(p["env_keys"]) == 1 else "overrides"
46
+ inheritance = (
47
+ "standalone"
48
+ if p["standalone"]
49
+ else f"inherits from 'default' — {len(p['env_keys'])} {override_word}"
50
+ )
51
+ eprint(f" {p['name']}")
52
+ eprint(f" {p['description']}")
53
+ eprint(f" ({inheritance})")
54
+ if p["env_keys"]:
55
+ keys_summary = ", ".join(p["env_keys"][:5])
56
+ if len(p["env_keys"]) > 5:
57
+ keys_summary += ", …"
58
+ eprint(f" sets: {keys_summary}")
59
+ if p.get("sandbox_env_keys"):
60
+ eprint(f" sandbox: {', '.join(p['sandbox_env_keys'])}")
61
+ if p.get("local_env_keys"):
62
+ eprint(f" local: {', '.join(p['local_env_keys'])}")
63
+ eprint()
64
+ return 0
65
+
66
+ if args.status:
67
+ return status()
68
+
69
+ if args.stop:
70
+ return stop()
71
+
72
+ # ── Load env chain ─────────────────────────────────────────────────────
73
+ workspace_dir = Path.cwd()
74
+ eprint("[ENV] Loading configuration...")
75
+ base_env = load_env_chain(workspace_dir)
76
+
77
+ # ── Load profile ───────────────────────────────────────────────────────
78
+ profile_name = args.profile or "default"
79
+ profiles_path = _resolve_profiles_path()
80
+
81
+ profile_env: dict = {}
82
+ sandbox_image: str | None = None
83
+ mode = "sandbox" if args.sandbox else "local"
84
+ if profiles_path.exists():
85
+ profile_env = load_profile_env(profile_name, profiles_path, base_env, mode)
86
+ sandbox_image = get_profile_sandbox_image(profile_name, profiles_path)
87
+ elif profile_name != "default":
88
+ eprint(
89
+ f"[ERROR] Profile '{profile_name}' requested but no profiles file found."
90
+ )
91
+ return 1
92
+ else:
93
+ eprint("[PROFILE] No profiles file found. Using defaults only.")
94
+
95
+ # ── Route execution ────────────────────────────────────────────────────
96
+ if args.native_models:
97
+ # Strip proxy-auth env vars so Claude Code uses its native /login auth
98
+ _NATIVE_STRIP_VARS = {
99
+ "ANTHROPIC_AUTH_TOKEN",
100
+ "ANTHROPIC_BASE_URL",
101
+ "IS_SANDBOX",
102
+ }
103
+ for var in _NATIVE_STRIP_VARS:
104
+ if var in profile_env:
105
+ eprint(
106
+ f"[NATIVE] Stripping '{var}' — using native Anthropic /login auth"
107
+ )
108
+ del profile_env[var]
109
+
110
+ if args.sandbox:
111
+ return run_docker(
112
+ profile_env, args.claude_args, workspace_dir, profile_name, sandbox_image
113
+ )
114
+ else:
115
+ return run_local(profile_env, args.claude_args)
116
+
117
+
118
+ def _resolve_profiles_path() -> Path:
119
+ """Return the profiles path (~/.codefreedom/profiles/claude-code.json)."""
120
+ if _os.environ.get("CODEFREEDOM_PROFILES_FILE"):
121
+ return Path(_os.environ["CODEFREEDOM_PROFILES_FILE"])
122
+ return _CODEFREEDOM_DIR / "profiles" / "claude-code.json"
@@ -0,0 +1,352 @@
1
+ """LiteLLM subcommand — start, stop, validate, status.
2
+
3
+ Usage:
4
+ codefreedom litellm --start Start LiteLLM proxy
5
+ codefreedom litellm --stop Stop LiteLLM proxy
6
+ codefreedom litellm --validate Validate config
7
+ codefreedom litellm --status Show proxy status
8
+ cf ll --start --native Run litellm directly (no Docker)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ from codefreedom.env_loader import eprint
21
+
22
+ # ── Path resolution ──────────────────────────────────────────────────────────
23
+
24
+ _PACKAGE_DIR = Path(__file__).resolve().parent.parent.parent.parent
25
+
26
+
27
+ def _find_compose_file() -> Optional[Path]:
28
+ """Find the LiteLLM docker-compose file."""
29
+ candidates = [
30
+ _PACKAGE_DIR / "litellm" / "docker-compose.litellm.yml",
31
+ Path.cwd() / "litellm" / "docker-compose.litellm.yml",
32
+ ]
33
+ for c in candidates:
34
+ if c.exists():
35
+ return c
36
+ return None
37
+
38
+
39
+ def _find_config_file() -> Optional[Path]:
40
+ """Find the LiteLLM config.yaml."""
41
+ candidates = [
42
+ _PACKAGE_DIR / "litellm" / "config" / "config.yaml",
43
+ Path.cwd() / "litellm" / "config" / "config.yaml",
44
+ ]
45
+ for c in candidates:
46
+ if c.exists():
47
+ return c
48
+ return None
49
+
50
+
51
+ # ── Entry point ──────────────────────────────────────────────────────────────
52
+
53
+
54
+ def run(args: argparse.Namespace) -> int:
55
+ """Execute the litellm subcommand. Returns exit code."""
56
+
57
+ if args.start:
58
+ return _start(args)
59
+ elif args.stop:
60
+ return _stop()
61
+ elif args.validate:
62
+ return _validate()
63
+ elif args.status:
64
+ return _status()
65
+ else:
66
+ eprint(
67
+ "[litellm] No action specified. Use --start, --stop, --validate, or --status."
68
+ )
69
+ return 1
70
+
71
+
72
+ # ── Start ────────────────────────────────────────────────────────────────────
73
+
74
+
75
+ def _start(args: argparse.Namespace) -> int:
76
+ """Start the LiteLLM proxy."""
77
+ if args.native:
78
+ return _start_native(args)
79
+ else:
80
+ return _start_compose()
81
+
82
+
83
+ def _start_compose() -> int:
84
+ """Start LiteLLM via docker compose."""
85
+ compose_file = _find_compose_file()
86
+ if not compose_file:
87
+ eprint("[ERROR] Could not find litellm/docker-compose.litellm.yml")
88
+ eprint(" Run from the codefreedom project root.")
89
+ return 1
90
+
91
+ eprint(f"[litellm] Starting LiteLLM via Docker Compose ({compose_file})...")
92
+ result = subprocess.run(
93
+ [
94
+ "docker",
95
+ "compose",
96
+ "-f",
97
+ str(compose_file),
98
+ "--profile",
99
+ "litellm",
100
+ "up",
101
+ "-d",
102
+ ],
103
+ capture_output=False,
104
+ check=False,
105
+ )
106
+ if result.returncode == 0:
107
+ eprint("[litellm] ✓ Proxy started at http://localhost:4000")
108
+ else:
109
+ eprint("[litellm] ✖ Failed to start. Check docker logs.")
110
+ return result.returncode
111
+
112
+
113
+ def _start_native(args: argparse.Namespace) -> int:
114
+ """Start LiteLLM directly as a Python process."""
115
+ try:
116
+ __import__("litellm")
117
+ except ImportError:
118
+ eprint("[ERROR] litellm package not installed.")
119
+ eprint(" Install: pip install codefreedom[litellm]")
120
+ eprint(" This installs litellm with proxy extras (websockets, etc.).")
121
+ eprint(" Or run without --native to use Docker Compose.")
122
+ return 1
123
+
124
+ # Find the litellm CLI binary (installed by the litellm package)
125
+ litellm_bin = shutil.which("litellm")
126
+ if not litellm_bin:
127
+ eprint("[ERROR] litellm CLI not found on PATH.")
128
+ eprint(" Ensure litellm is installed: pip install codefreedom[litellm]")
129
+ return 1
130
+
131
+ config_file = _find_config_file()
132
+ if not config_file:
133
+ eprint("[ERROR] Could not find litellm/config/config.yaml")
134
+ eprint(" Run: codefreedom setup")
135
+ return 1
136
+
137
+ port = args.port or 4000
138
+ host = args.host or "0.0.0.0"
139
+
140
+ eprint(f"[litellm] Starting natively on {host}:{port}...")
141
+ eprint(f"[litellm] Config: {config_file}")
142
+
143
+ cmd = [
144
+ litellm_bin,
145
+ "--config",
146
+ str(config_file),
147
+ "--port",
148
+ str(port),
149
+ "--host",
150
+ host,
151
+ ]
152
+
153
+ try:
154
+ proc = subprocess.Popen(cmd)
155
+ eprint(f"[litellm] ✓ Proxy starting (PID: {proc.pid})")
156
+ eprint("[litellm] Press Ctrl+C to stop.")
157
+ proc.wait()
158
+ return proc.returncode
159
+ except KeyboardInterrupt:
160
+ eprint("\n[litellm] Proxy stopped.")
161
+ return 0
162
+ except FileNotFoundError:
163
+ eprint("[ERROR] Could not find litellm executable.")
164
+ return 1
165
+
166
+
167
+ # ── Stop ─────────────────────────────────────────────────────────────────────
168
+
169
+
170
+ def _stop() -> int:
171
+ """Stop the LiteLLM proxy."""
172
+ compose_file = _find_compose_file()
173
+ if not compose_file:
174
+ eprint("[ERROR] Could not find litellm/docker-compose.litellm.yml")
175
+ return 1
176
+
177
+ eprint("[litellm] Stopping LiteLLM proxy...")
178
+ result = subprocess.run(
179
+ ["docker", "compose", "-f", str(compose_file), "--profile", "litellm", "down"],
180
+ capture_output=False,
181
+ check=False,
182
+ )
183
+ if result.returncode == 0:
184
+ eprint("[litellm] ✓ Proxy stopped.")
185
+ return result.returncode
186
+
187
+
188
+ # ── Validate ─────────────────────────────────────────────────────────────────
189
+
190
+
191
+ def _validate() -> int:
192
+ """Validate the LiteLLM configuration."""
193
+ config_file = _find_config_file()
194
+ if not config_file:
195
+ eprint("[ERROR] Could not find litellm/config/config.yaml")
196
+ eprint(" Run: codefreedom setup")
197
+ return 1
198
+
199
+ errors: List[str] = []
200
+
201
+ eprint(f"[litellm] Validating {config_file}...")
202
+ eprint()
203
+
204
+ # Parse config.yaml
205
+ try:
206
+ import yaml
207
+ except ImportError:
208
+ eprint("[WARN] PyYAML not installed. Using basic validation only.")
209
+ eprint(" Install: pip install pyyaml")
210
+ # Fallback: just check file existence
211
+ _validate_basic(config_file, errors)
212
+ _print_validation_result(errors)
213
+ return 0 if not errors else 1
214
+
215
+ try:
216
+ with open(config_file, encoding="utf-8") as f:
217
+ config = yaml.safe_load(f)
218
+ except yaml.YAMLError as e:
219
+ eprint(f" ✖ YAML parse error: {e}")
220
+ return 1
221
+ except FileNotFoundError:
222
+ eprint(f" ✖ File not found: {config_file}")
223
+ return 1
224
+
225
+ if not isinstance(config, dict):
226
+ eprint(" ✖ Config must be a YAML dictionary.")
227
+ return 1
228
+
229
+ # Check includes
230
+ includes = config.get("include", [])
231
+ if not includes:
232
+ eprint(" ⚠ No provider includes found in config.yaml")
233
+ eprint(" Run: codefreedom setup")
234
+ else:
235
+ config_dir = config_file.parent
236
+ for inc in includes:
237
+ provider_file = config_dir / inc
238
+ if provider_file.exists():
239
+ eprint(f" ✓ {inc}")
240
+ # Validate provider file
241
+ try:
242
+ with open(provider_file, encoding="utf-8") as f:
243
+ provider_config = yaml.safe_load(f)
244
+ models = provider_config.get("model_list", [])
245
+ for m in models:
246
+ name = m.get("model_name", "?")
247
+ params = m.get("litellm_params", {})
248
+ api_key_ref = params.get("api_key", "")
249
+ # Check if env var is set
250
+ if api_key_ref.startswith("os.environ/"):
251
+ env_var = api_key_ref[len("os.environ/") :]
252
+ if not _env_is_set(env_var):
253
+ eprint(f" ⚠ {name}: env var {env_var} is not set")
254
+ else:
255
+ eprint(f" ✓ {name} (auth: {env_var} ✓)")
256
+ else:
257
+ eprint(f" ✓ {name}")
258
+ except yaml.YAMLError as e:
259
+ eprint(f" ✖ {inc}: YAML error — {e}")
260
+ errors.append(f"YAML error in {inc}: {e}")
261
+ else:
262
+ eprint(f" ✖ {inc} — file not found")
263
+ errors.append(f"Missing provider file: {inc}")
264
+
265
+ # Check essential settings
266
+ general = config.get("general_settings", {})
267
+ if not general.get("database_url"):
268
+ eprint(" ⚠ database_url not set in general_settings")
269
+
270
+ litellm_settings = config.get("litellm_settings", {})
271
+ if not litellm_settings:
272
+ eprint(" ⚠ litellm_settings section is empty")
273
+
274
+ router = config.get("router_settings", {})
275
+ aliases = router.get("model_group_alias", {})
276
+ if aliases:
277
+ eprint(f" ✓ Model aliases: {len(aliases)} defined")
278
+ for alias, model in aliases.items():
279
+ eprint(f" {alias} → {model}")
280
+ else:
281
+ eprint(" ⚠ No model_group_alias defined")
282
+
283
+ eprint()
284
+ _print_validation_result(errors)
285
+ return 0 if not errors else 1
286
+
287
+
288
+ def _validate_basic(config_file: Path, errors: List[str]) -> None:
289
+ """Basic validation without PyYAML."""
290
+ content = config_file.read_text()
291
+
292
+ checks = [
293
+ ("include:", "provider includes"),
294
+ ("general_settings:", "general_settings section"),
295
+ ("router_settings:", "router_settings section"),
296
+ ("litellm_settings:", "litellm_settings section"),
297
+ ("model_group_alias:", "model aliases"),
298
+ ]
299
+ for marker, label in checks:
300
+ if marker in content:
301
+ eprint(f" ✓ {label} found")
302
+ else:
303
+ eprint(f" ✖ {label} missing")
304
+ errors.append(f"Missing: {label}")
305
+
306
+ # Check provider files exist
307
+ config_dir = config_file.parent
308
+ for line in content.split("\n"):
309
+ line = line.strip()
310
+ if line.startswith("- providers/"):
311
+ provider_file = line[2:].strip()
312
+ full = config_dir / provider_file
313
+ if full.exists():
314
+ eprint(f" ✓ {provider_file}")
315
+ else:
316
+ eprint(f" ✖ {provider_file} — not found")
317
+ errors.append(f"Missing: {provider_file}")
318
+
319
+
320
+ def _env_is_set(var_name: str) -> bool:
321
+ """Check if an environment variable is set and non-empty."""
322
+ import os
323
+
324
+ return bool(os.environ.get(var_name))
325
+
326
+
327
+ def _print_validation_result(errors: List[str]) -> None:
328
+ """Print validation summary."""
329
+ if errors:
330
+ eprint(f" ✖ {len(errors)} issue(s) found.")
331
+ for e in errors:
332
+ eprint(f" - {e}")
333
+ else:
334
+ eprint(" ✓ Configuration looks good!")
335
+
336
+
337
+ # ── Status ───────────────────────────────────────────────────────────────────
338
+
339
+
340
+ def _status() -> int:
341
+ """Show LiteLLM proxy status."""
342
+ compose_file = _find_compose_file()
343
+ if not compose_file:
344
+ eprint("[ERROR] Could not find litellm/docker-compose.litellm.yml")
345
+ return 1
346
+
347
+ result = subprocess.run(
348
+ ["docker", "compose", "-f", str(compose_file), "ps"],
349
+ capture_output=False,
350
+ check=False,
351
+ )
352
+ return result.returncode