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.
- codefreedom/__init__.py +3 -0
- codefreedom/__main__.py +5 -0
- codefreedom/cli/__init__.py +1 -0
- codefreedom/cli/claude.py +122 -0
- codefreedom/cli/litellm_cli.py +352 -0
- codefreedom/cli/main.py +325 -0
- codefreedom/cli/proxy.py +332 -0
- codefreedom/env_loader.py +101 -0
- codefreedom/examples/.env.example +205 -0
- codefreedom/examples/.env.secrets.example +79 -0
- codefreedom/examples/profiles/claude-code-profiles.json +81 -0
- codefreedom/examples/profiles/claude-code-profiles.schema.json +84 -0
- codefreedom/examples/proxy/config.yaml +112 -0
- codefreedom/examples/proxy/docker-compose.yml +127 -0
- codefreedom/examples/proxy/providers/anthropic-compatible.yaml +50 -0
- codefreedom/examples/proxy/providers/azure-foundry.yaml +76 -0
- codefreedom/examples/proxy/providers/deepseek.yaml +71 -0
- codefreedom/examples/proxy/providers/local.yaml +107 -0
- codefreedom/examples/proxy/providers/nvidia.yaml +103 -0
- codefreedom/examples/proxy/providers/openai-compatible.yaml +57 -0
- codefreedom/examples/proxy/providers/opencode-zen.yaml +87 -0
- codefreedom/launcher.py +389 -0
- codefreedom/profiles.py +200 -0
- codefreedom-0.1.0.dist-info/METADATA +262 -0
- codefreedom-0.1.0.dist-info/RECORD +29 -0
- codefreedom-0.1.0.dist-info/WHEEL +5 -0
- codefreedom-0.1.0.dist-info/entry_points.txt +3 -0
- codefreedom-0.1.0.dist-info/licenses/LICENSE +201 -0
- codefreedom-0.1.0.dist-info/top_level.txt +1 -0
codefreedom/cli/main.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Top-level CLI entry point — parses args and dispatches to subcommands.
|
|
2
|
+
|
|
3
|
+
Entry point: codefreedom | cf
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_CODEFREEDOM_DIR = Path.home() / ".codefreedom"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_project_root() -> Path:
|
|
17
|
+
"""Find the project root directory (where profiles.examples/ and litellm.examples/ live)."""
|
|
18
|
+
return Path(__file__).resolve().parent.parent.parent.parent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_bundled_examples() -> Path:
|
|
22
|
+
"""Find the bundled examples directory inside the installed package."""
|
|
23
|
+
return Path(__file__).resolve().parent.parent / "examples"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _init_codefreedom(
|
|
27
|
+
force: bool = False,
|
|
28
|
+
project_root: Path | None = None,
|
|
29
|
+
cf_dir: Path | None = None,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Initialize ~/.codefreedom/ with default profiles and proxy configs.
|
|
32
|
+
|
|
33
|
+
Copies from the project's examples directories (profiles.examples/
|
|
34
|
+
and litellm.examples/) or from the bundled package examples into
|
|
35
|
+
~/.codefreedom/.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
force: Overwrite existing files.
|
|
39
|
+
project_root: Override the project root (for testing).
|
|
40
|
+
cf_dir: Override the ~/.codefreedom directory (for testing).
|
|
41
|
+
"""
|
|
42
|
+
if project_root is None:
|
|
43
|
+
project_root = _find_project_root()
|
|
44
|
+
if cf_dir is None:
|
|
45
|
+
cf_dir = _CODEFREEDOM_DIR
|
|
46
|
+
|
|
47
|
+
bundled = _find_bundled_examples()
|
|
48
|
+
|
|
49
|
+
profiles_src = project_root / "profiles.examples" / "claude-code-profiles.json"
|
|
50
|
+
schema_src = project_root / "profiles.examples" / "claude-code-profiles.schema.json"
|
|
51
|
+
proxy_src = project_root / "litellm.examples"
|
|
52
|
+
|
|
53
|
+
# Fall back to bundled examples if project-root sources don't exist
|
|
54
|
+
if not profiles_src.exists():
|
|
55
|
+
profiles_src = bundled / "profiles" / "claude-code-profiles.json"
|
|
56
|
+
if not schema_src.exists():
|
|
57
|
+
schema_src = bundled / "profiles" / "claude-code-profiles.schema.json"
|
|
58
|
+
if not proxy_src.exists():
|
|
59
|
+
proxy_src = bundled / "proxy"
|
|
60
|
+
|
|
61
|
+
profiles_dst_dir = cf_dir / "profiles"
|
|
62
|
+
profiles_dst = profiles_dst_dir / "claude-code.json"
|
|
63
|
+
schema_dst = profiles_dst_dir / "claude-code-profiles.schema.json"
|
|
64
|
+
proxy_dst = cf_dir / "proxy"
|
|
65
|
+
|
|
66
|
+
created_any = False
|
|
67
|
+
skipped_any = False
|
|
68
|
+
|
|
69
|
+
# ── Profiles ───────────────────────────────────────────────────────────
|
|
70
|
+
if not force and profiles_dst.exists():
|
|
71
|
+
print(f"[init] Profiles already exist: {profiles_dst}")
|
|
72
|
+
print(" Use --init --force to overwrite.")
|
|
73
|
+
skipped_any = True
|
|
74
|
+
elif profiles_src.exists():
|
|
75
|
+
profiles_dst_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
shutil.copy2(profiles_src, profiles_dst)
|
|
77
|
+
print(f"[init] ✓ Created {profiles_dst}")
|
|
78
|
+
created_any = True
|
|
79
|
+
else:
|
|
80
|
+
print(f"[init] ✖ Profiles example not found: {profiles_src}")
|
|
81
|
+
print(" Make sure profiles.examples/claude-code-profiles.json exists.")
|
|
82
|
+
|
|
83
|
+
# ── Schema ─────────────────────────────────────────────────────────────
|
|
84
|
+
if not force and schema_dst.exists():
|
|
85
|
+
print(f"[init] Schema already exists: {schema_dst}")
|
|
86
|
+
skipped_any = True
|
|
87
|
+
elif schema_src.exists():
|
|
88
|
+
profiles_dst_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
shutil.copy2(schema_src, schema_dst)
|
|
90
|
+
print(f"[init] ✓ Created {schema_dst}")
|
|
91
|
+
created_any = True
|
|
92
|
+
else:
|
|
93
|
+
print(f"[init] ✖ Schema example not found: {schema_src}")
|
|
94
|
+
print(
|
|
95
|
+
" Make sure profiles.examples/claude-code-profiles.schema.json exists."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# ── Proxy configs (litellm) ─────────────────────────────────────────────
|
|
99
|
+
if not force and proxy_dst.exists():
|
|
100
|
+
print(f"[init] Proxy configs already exist: {proxy_dst}")
|
|
101
|
+
print(" Use --init --force to overwrite.")
|
|
102
|
+
skipped_any = True
|
|
103
|
+
elif proxy_src.exists():
|
|
104
|
+
if proxy_dst.exists() and force:
|
|
105
|
+
shutil.rmtree(proxy_dst)
|
|
106
|
+
|
|
107
|
+
# Source layout (litellm.examples/ or bundled proxy/):
|
|
108
|
+
# config.yaml → proxy/config/config.yaml
|
|
109
|
+
# docker-compose.yml → proxy/docker-compose.yml
|
|
110
|
+
# providers/ → proxy/config/providers/
|
|
111
|
+
proxy_config_dir = proxy_dst / "config"
|
|
112
|
+
proxy_config_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
# Copy config.yaml into config/ subdirectory
|
|
115
|
+
src_config = proxy_src / "config.yaml"
|
|
116
|
+
if src_config.exists():
|
|
117
|
+
shutil.copy2(src_config, proxy_config_dir / "config.yaml")
|
|
118
|
+
|
|
119
|
+
# Copy docker-compose.yml to proxy root
|
|
120
|
+
src_compose = proxy_src / "docker-compose.yml"
|
|
121
|
+
if src_compose.exists():
|
|
122
|
+
shutil.copy2(src_compose, proxy_dst / "docker-compose.yml")
|
|
123
|
+
|
|
124
|
+
# Copy providers into config/providers/
|
|
125
|
+
src_providers = proxy_src / "providers"
|
|
126
|
+
if src_providers.exists():
|
|
127
|
+
dst_providers = proxy_config_dir / "providers"
|
|
128
|
+
if dst_providers.exists() and force:
|
|
129
|
+
shutil.rmtree(dst_providers)
|
|
130
|
+
shutil.copytree(src_providers, dst_providers, dirs_exist_ok=True)
|
|
131
|
+
|
|
132
|
+
print(f"[init] ✓ Created {proxy_dst}")
|
|
133
|
+
created_any = True
|
|
134
|
+
else:
|
|
135
|
+
print(f"[init] ✖ Proxy examples not found: {proxy_src}")
|
|
136
|
+
print(" Make sure litellm.examples/ exists.")
|
|
137
|
+
|
|
138
|
+
# ── Environment files (.env / .env.secrets) ────────────────────────────
|
|
139
|
+
# Copy fully-commented templates from project root or bundled examples.
|
|
140
|
+
# These are only created if the destination file doesn't already exist
|
|
141
|
+
# (never overwritten — user edits them manually).
|
|
142
|
+
env_src = project_root / ".env.example"
|
|
143
|
+
secrets_src = project_root / ".env.secrets.example"
|
|
144
|
+
|
|
145
|
+
if not env_src.exists():
|
|
146
|
+
env_src = bundled / ".env.example"
|
|
147
|
+
if not secrets_src.exists():
|
|
148
|
+
secrets_src = bundled / ".env.secrets.example"
|
|
149
|
+
|
|
150
|
+
env_dst = cf_dir / ".env"
|
|
151
|
+
secrets_dst = cf_dir / ".env.secrets"
|
|
152
|
+
|
|
153
|
+
if env_dst.exists():
|
|
154
|
+
print(f"[init] .env already exists: {env_dst} (skipping — edit it manually)")
|
|
155
|
+
skipped_any = True
|
|
156
|
+
elif env_src.exists():
|
|
157
|
+
cf_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
shutil.copy2(env_src, env_dst)
|
|
159
|
+
print(
|
|
160
|
+
f"[init] ✓ Created {env_dst} (fully commented — uncomment variables you need)"
|
|
161
|
+
)
|
|
162
|
+
created_any = True
|
|
163
|
+
else:
|
|
164
|
+
print(f"[init] ✖ .env.example not found: {env_src}")
|
|
165
|
+
|
|
166
|
+
# .env.secrets is optional — only copy if source exists and dest doesn't
|
|
167
|
+
if secrets_dst.exists():
|
|
168
|
+
print(f"[init] .env.secrets already exists: {secrets_dst} (skipping)")
|
|
169
|
+
elif secrets_src.exists():
|
|
170
|
+
cf_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
shutil.copy2(secrets_src, secrets_dst)
|
|
172
|
+
print(f"[init] ✓ Created {secrets_dst} (fully commented — add your API keys)")
|
|
173
|
+
created_any = True
|
|
174
|
+
|
|
175
|
+
if created_any:
|
|
176
|
+
print()
|
|
177
|
+
print("[init] CodeFreedom is initialized!")
|
|
178
|
+
print(f" Profiles: {profiles_dst_dir}")
|
|
179
|
+
print(f" - claude-code.json")
|
|
180
|
+
print(f" - claude-code-profiles.schema.json")
|
|
181
|
+
print(f" Proxy: {proxy_dst}")
|
|
182
|
+
print(f" Env: {cf_dir}")
|
|
183
|
+
print(f" - .env (fully commented)")
|
|
184
|
+
print(f" - .env.secrets (fully commented)")
|
|
185
|
+
print(" Edit these files to customize your setup.")
|
|
186
|
+
elif skipped_any:
|
|
187
|
+
print()
|
|
188
|
+
print("[init] Nothing to do — all files already exist.")
|
|
189
|
+
else:
|
|
190
|
+
print()
|
|
191
|
+
print("[init] No source files found to copy.")
|
|
192
|
+
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> None:
|
|
197
|
+
"""Top-level CLI entry point: codefreedom | cf."""
|
|
198
|
+
parser = argparse.ArgumentParser(
|
|
199
|
+
prog="codefreedom",
|
|
200
|
+
description="CodeFreedom — Claude Code launcher and LiteLLM proxy management.",
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"--init",
|
|
204
|
+
action="store_true",
|
|
205
|
+
help="Initialize ~/.codefreedom/ with default profiles and proxy configs",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--force",
|
|
209
|
+
action="store_true",
|
|
210
|
+
help="Force overwrite existing configs (use with --init)",
|
|
211
|
+
)
|
|
212
|
+
subparsers = parser.add_subparsers(dest="command", title="commands")
|
|
213
|
+
|
|
214
|
+
# ── claude subcommand ──────────────────────────────────────────────────
|
|
215
|
+
claude_parser = subparsers.add_parser(
|
|
216
|
+
"claude",
|
|
217
|
+
aliases=["cc"],
|
|
218
|
+
help="Launch Claude Code with profile-based model routing",
|
|
219
|
+
description="Run Claude Code natively (default) or in a sandboxed Docker container.",
|
|
220
|
+
)
|
|
221
|
+
claude_parser.add_argument(
|
|
222
|
+
"--sandbox",
|
|
223
|
+
action="store_true",
|
|
224
|
+
help="Run Claude Code inside a sandboxed Docker container (default: native)",
|
|
225
|
+
)
|
|
226
|
+
claude_parser.add_argument(
|
|
227
|
+
"--native-models",
|
|
228
|
+
action="store_true",
|
|
229
|
+
help="Use native Anthropic models/auth (/login) — strips ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN",
|
|
230
|
+
)
|
|
231
|
+
claude_parser.add_argument(
|
|
232
|
+
"--profile",
|
|
233
|
+
type=str,
|
|
234
|
+
default="default",
|
|
235
|
+
metavar="NAME",
|
|
236
|
+
help="Load a named profile (default: 'default')",
|
|
237
|
+
)
|
|
238
|
+
claude_parser.add_argument(
|
|
239
|
+
"--stop",
|
|
240
|
+
action="store_true",
|
|
241
|
+
help="Stop and remove the persistent Docker container",
|
|
242
|
+
)
|
|
243
|
+
claude_parser.add_argument(
|
|
244
|
+
"--status",
|
|
245
|
+
action="store_true",
|
|
246
|
+
help="Show persistent container status and exit",
|
|
247
|
+
)
|
|
248
|
+
claude_parser.add_argument(
|
|
249
|
+
"--list-profiles",
|
|
250
|
+
action="store_true",
|
|
251
|
+
help="List available profiles and exit",
|
|
252
|
+
)
|
|
253
|
+
claude_parser.add_argument(
|
|
254
|
+
"claude_args",
|
|
255
|
+
nargs=argparse.REMAINDER,
|
|
256
|
+
help="Arguments forwarded to the 'claude' CLI",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ── proxy subcommand ───────────────────────────────────────────────────
|
|
260
|
+
proxy_parser = subparsers.add_parser(
|
|
261
|
+
"proxy",
|
|
262
|
+
aliases=["px"],
|
|
263
|
+
help="Manage the LiteLLM proxy (start, stop, validate, status)",
|
|
264
|
+
description="Manage the LiteLLM proxy lifecycle.",
|
|
265
|
+
)
|
|
266
|
+
proxy_parser.add_argument(
|
|
267
|
+
"--up",
|
|
268
|
+
action="store_true",
|
|
269
|
+
help="Start the LiteLLM proxy (native by default; use --docker for Compose)",
|
|
270
|
+
)
|
|
271
|
+
proxy_parser.add_argument(
|
|
272
|
+
"--down",
|
|
273
|
+
action="store_true",
|
|
274
|
+
help="Stop the LiteLLM proxy",
|
|
275
|
+
)
|
|
276
|
+
proxy_parser.add_argument(
|
|
277
|
+
"--status",
|
|
278
|
+
action="store_true",
|
|
279
|
+
help="Show LiteLLM proxy status",
|
|
280
|
+
)
|
|
281
|
+
proxy_parser.add_argument(
|
|
282
|
+
"--validate",
|
|
283
|
+
action="store_true",
|
|
284
|
+
help="Validate the LiteLLM configuration",
|
|
285
|
+
)
|
|
286
|
+
proxy_parser.add_argument(
|
|
287
|
+
"--docker",
|
|
288
|
+
action="store_true",
|
|
289
|
+
help="Run via Docker Compose instead of native Python",
|
|
290
|
+
)
|
|
291
|
+
proxy_parser.add_argument(
|
|
292
|
+
"--port",
|
|
293
|
+
type=int,
|
|
294
|
+
default=4000,
|
|
295
|
+
help="Port for proxy (default: 4000)",
|
|
296
|
+
)
|
|
297
|
+
proxy_parser.add_argument(
|
|
298
|
+
"--host",
|
|
299
|
+
type=str,
|
|
300
|
+
default="0.0.0.0",
|
|
301
|
+
help="Bind host for proxy (default: 0.0.0.0)",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
args = parser.parse_args()
|
|
305
|
+
|
|
306
|
+
# ── --init: bootstrap ~/.codefreedom/ ───────────────────────────────────
|
|
307
|
+
if args.init:
|
|
308
|
+
sys.exit(_init_codefreedom(force=args.force))
|
|
309
|
+
|
|
310
|
+
if args.command in ("claude", "cc"):
|
|
311
|
+
# Lazy import to keep CLI startup fast
|
|
312
|
+
from codefreedom.cli.claude import run as claude_run
|
|
313
|
+
|
|
314
|
+
sys.exit(claude_run(args))
|
|
315
|
+
elif args.command in ("proxy", "px"):
|
|
316
|
+
from codefreedom.cli.proxy import run as proxy_run
|
|
317
|
+
|
|
318
|
+
sys.exit(proxy_run(args))
|
|
319
|
+
else:
|
|
320
|
+
parser.print_help()
|
|
321
|
+
sys.exit(0)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
main()
|
codefreedom/cli/proxy.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Proxy subcommand — manage the LiteLLM proxy.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
codefreedom proxy --up Start the LiteLLM proxy (native, default)
|
|
5
|
+
codefreedom proxy --up --docker Start via Docker Compose
|
|
6
|
+
codefreedom proxy --down Stop the LiteLLM proxy
|
|
7
|
+
codefreedom proxy --status Show proxy status
|
|
8
|
+
codefreedom proxy --validate Validate LiteLLM configuration
|
|
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
|
+
_CODEFREEDOM_DIR = Path.home() / ".codefreedom"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_compose_file() -> Optional[Path]:
|
|
28
|
+
"""Find the LiteLLM docker-compose file in ~/.codefreedom/proxy/."""
|
|
29
|
+
candidate = _CODEFREEDOM_DIR / "proxy" / "docker-compose.yml"
|
|
30
|
+
if candidate.exists():
|
|
31
|
+
return candidate
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_config_file() -> Optional[Path]:
|
|
36
|
+
"""Find the LiteLLM config.yaml in ~/.codefreedom/proxy/config/."""
|
|
37
|
+
candidate = _CODEFREEDOM_DIR / "proxy" / "config" / "config.yaml"
|
|
38
|
+
if candidate.exists():
|
|
39
|
+
return candidate
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Entry point ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run(args: argparse.Namespace) -> int:
|
|
47
|
+
"""Execute the proxy subcommand. Returns exit code."""
|
|
48
|
+
|
|
49
|
+
if args.up:
|
|
50
|
+
return _start(args)
|
|
51
|
+
elif args.down:
|
|
52
|
+
return _stop()
|
|
53
|
+
elif args.status:
|
|
54
|
+
return _status()
|
|
55
|
+
elif args.validate:
|
|
56
|
+
return _validate()
|
|
57
|
+
else:
|
|
58
|
+
eprint(
|
|
59
|
+
"[proxy] No action specified. Use --up, --down, --status, or --validate."
|
|
60
|
+
)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Start ────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _start(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Start the LiteLLM proxy. Defaults to native; --docker uses Compose."""
|
|
69
|
+
if args.docker:
|
|
70
|
+
return _start_compose()
|
|
71
|
+
else:
|
|
72
|
+
return _start_native(args)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _start_compose() -> int:
|
|
76
|
+
"""Start LiteLLM via docker compose."""
|
|
77
|
+
compose_file = _find_compose_file()
|
|
78
|
+
if not compose_file:
|
|
79
|
+
eprint("[ERROR] Could not find ~/.codefreedom/proxy/docker-compose.yml")
|
|
80
|
+
eprint(" Run: codefreedom --init")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
eprint(f"[proxy] Starting LiteLLM via Docker Compose ({compose_file})...")
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
[
|
|
86
|
+
"docker",
|
|
87
|
+
"compose",
|
|
88
|
+
"-f",
|
|
89
|
+
str(compose_file),
|
|
90
|
+
"--profile",
|
|
91
|
+
"litellm",
|
|
92
|
+
"up",
|
|
93
|
+
"-d",
|
|
94
|
+
],
|
|
95
|
+
capture_output=False,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode == 0:
|
|
99
|
+
eprint("[proxy] ✓ Proxy started at http://localhost:4000")
|
|
100
|
+
else:
|
|
101
|
+
eprint("[proxy] ✖ Failed to start. Check docker logs.")
|
|
102
|
+
return result.returncode
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _start_native(args: argparse.Namespace) -> int:
|
|
106
|
+
"""Start LiteLLM directly as a Python process."""
|
|
107
|
+
try:
|
|
108
|
+
__import__("litellm")
|
|
109
|
+
except ImportError:
|
|
110
|
+
eprint("[ERROR] litellm package not installed.")
|
|
111
|
+
eprint(" Install: pip install codefreedom[litellm]")
|
|
112
|
+
eprint(" This installs litellm with proxy extras (websockets, etc.).")
|
|
113
|
+
eprint(" Or run without --native to use Docker Compose.")
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
litellm_bin = shutil.which("litellm")
|
|
117
|
+
if not litellm_bin:
|
|
118
|
+
eprint("[ERROR] litellm CLI not found on PATH.")
|
|
119
|
+
eprint(" Ensure litellm is installed: pip install codefreedom[litellm]")
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
config_file = _find_config_file()
|
|
123
|
+
if not config_file:
|
|
124
|
+
eprint("[ERROR] Could not find ~/.codefreedom/proxy/config/config.yaml")
|
|
125
|
+
eprint(" Run: codefreedom --init")
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
port = args.port or 4000
|
|
129
|
+
host = args.host or "0.0.0.0"
|
|
130
|
+
|
|
131
|
+
eprint(f"[proxy] Starting natively on {host}:{port}...")
|
|
132
|
+
eprint(f"[proxy] Config: {config_file}")
|
|
133
|
+
|
|
134
|
+
cmd = [
|
|
135
|
+
litellm_bin,
|
|
136
|
+
"--config",
|
|
137
|
+
str(config_file),
|
|
138
|
+
"--port",
|
|
139
|
+
str(port),
|
|
140
|
+
"--host",
|
|
141
|
+
host,
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.Popen(cmd)
|
|
146
|
+
eprint(f"[proxy] ✓ Proxy starting (PID: {proc.pid})")
|
|
147
|
+
eprint("[proxy] Press Ctrl+C to stop.")
|
|
148
|
+
proc.wait()
|
|
149
|
+
return proc.returncode
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
eprint("\n[proxy] Proxy stopped.")
|
|
152
|
+
return 0
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
eprint("[ERROR] Could not find litellm executable.")
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Stop ─────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _stop() -> int:
|
|
162
|
+
"""Stop the LiteLLM proxy."""
|
|
163
|
+
compose_file = _find_compose_file()
|
|
164
|
+
if not compose_file:
|
|
165
|
+
eprint("[ERROR] Could not find ~/.codefreedom/proxy/docker-compose.yml")
|
|
166
|
+
eprint(" Run: codefreedom --init")
|
|
167
|
+
return 1
|
|
168
|
+
|
|
169
|
+
eprint("[proxy] Stopping LiteLLM proxy...")
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
["docker", "compose", "-f", str(compose_file), "--profile", "litellm", "down"],
|
|
172
|
+
capture_output=False,
|
|
173
|
+
check=False,
|
|
174
|
+
)
|
|
175
|
+
if result.returncode == 0:
|
|
176
|
+
eprint("[proxy] ✓ Proxy stopped.")
|
|
177
|
+
return result.returncode
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Status ───────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _status() -> int:
|
|
184
|
+
"""Show LiteLLM proxy status."""
|
|
185
|
+
compose_file = _find_compose_file()
|
|
186
|
+
if not compose_file:
|
|
187
|
+
eprint("[ERROR] Could not find ~/.codefreedom/proxy/docker-compose.yml")
|
|
188
|
+
eprint(" Run: codefreedom --init")
|
|
189
|
+
return 1
|
|
190
|
+
|
|
191
|
+
result = subprocess.run(
|
|
192
|
+
["docker", "compose", "-f", str(compose_file), "--profile", "litellm", "ps"],
|
|
193
|
+
capture_output=False,
|
|
194
|
+
check=False,
|
|
195
|
+
)
|
|
196
|
+
return result.returncode
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Validate ─────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _validate() -> int:
|
|
203
|
+
"""Validate the LiteLLM configuration."""
|
|
204
|
+
config_file = _find_config_file()
|
|
205
|
+
if not config_file:
|
|
206
|
+
eprint("[ERROR] Could not find ~/.codefreedom/proxy/config/config.yaml")
|
|
207
|
+
eprint(" Run: codefreedom --init")
|
|
208
|
+
return 1
|
|
209
|
+
|
|
210
|
+
errors: List[str] = []
|
|
211
|
+
|
|
212
|
+
eprint(f"[proxy] Validating {config_file}...")
|
|
213
|
+
eprint()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
import yaml
|
|
217
|
+
except ImportError:
|
|
218
|
+
eprint("[WARN] PyYAML not installed. Using basic validation only.")
|
|
219
|
+
eprint(" Install: pip install pyyaml")
|
|
220
|
+
_validate_basic(config_file, errors)
|
|
221
|
+
_print_validation_result(errors)
|
|
222
|
+
return 0 if not errors else 1
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
with open(config_file, encoding="utf-8") as f:
|
|
226
|
+
config = yaml.safe_load(f)
|
|
227
|
+
except yaml.YAMLError as e:
|
|
228
|
+
eprint(f" ✖ YAML parse error: {e}")
|
|
229
|
+
return 1
|
|
230
|
+
except FileNotFoundError:
|
|
231
|
+
eprint(f" ✖ File not found: {config_file}")
|
|
232
|
+
return 1
|
|
233
|
+
|
|
234
|
+
if not isinstance(config, dict):
|
|
235
|
+
eprint(" ✖ Config must be a YAML dictionary.")
|
|
236
|
+
return 1
|
|
237
|
+
|
|
238
|
+
includes = config.get("include", [])
|
|
239
|
+
if not includes:
|
|
240
|
+
eprint(" ⚠ No provider includes found in config.yaml")
|
|
241
|
+
else:
|
|
242
|
+
config_dir = config_file.parent
|
|
243
|
+
for inc in includes:
|
|
244
|
+
provider_file = config_dir / inc
|
|
245
|
+
if provider_file.exists():
|
|
246
|
+
eprint(f" ✓ {inc}")
|
|
247
|
+
try:
|
|
248
|
+
with open(provider_file, encoding="utf-8") as f:
|
|
249
|
+
provider_config = yaml.safe_load(f)
|
|
250
|
+
models = provider_config.get("model_list", [])
|
|
251
|
+
for m in models:
|
|
252
|
+
name = m.get("model_name", "?")
|
|
253
|
+
params = m.get("litellm_params", {})
|
|
254
|
+
api_key_ref = params.get("api_key", "")
|
|
255
|
+
if api_key_ref.startswith("os.environ/"):
|
|
256
|
+
env_var = api_key_ref[len("os.environ/") :]
|
|
257
|
+
if not _env_is_set(env_var):
|
|
258
|
+
eprint(f" ⚠ {name}: env var {env_var} is not set")
|
|
259
|
+
else:
|
|
260
|
+
eprint(f" ✓ {name} (auth: {env_var} ✓)")
|
|
261
|
+
else:
|
|
262
|
+
eprint(f" ✓ {name}")
|
|
263
|
+
except yaml.YAMLError as e:
|
|
264
|
+
eprint(f" ✖ {inc}: YAML error — {e}")
|
|
265
|
+
errors.append(f"YAML error in {inc}: {e}")
|
|
266
|
+
else:
|
|
267
|
+
eprint(f" ✖ {inc} — file not found")
|
|
268
|
+
errors.append(f"Missing provider file: {inc}")
|
|
269
|
+
|
|
270
|
+
general = config.get("general_settings", {})
|
|
271
|
+
if not general.get("database_url"):
|
|
272
|
+
eprint(" ⚠ database_url not set (stateless mode)")
|
|
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
|
+
checks = [
|
|
292
|
+
("include:", "provider includes"),
|
|
293
|
+
("general_settings:", "general_settings section"),
|
|
294
|
+
("router_settings:", "router_settings section"),
|
|
295
|
+
("litellm_settings:", "litellm_settings section"),
|
|
296
|
+
("model_group_alias:", "model aliases"),
|
|
297
|
+
]
|
|
298
|
+
for marker, label in checks:
|
|
299
|
+
if marker in content:
|
|
300
|
+
eprint(f" ✓ {label} found")
|
|
301
|
+
else:
|
|
302
|
+
eprint(f" ✖ {label} missing")
|
|
303
|
+
errors.append(f"Missing: {label}")
|
|
304
|
+
|
|
305
|
+
config_dir = config_file.parent
|
|
306
|
+
for line in content.split("\n"):
|
|
307
|
+
line = line.strip()
|
|
308
|
+
if line.startswith("- providers/"):
|
|
309
|
+
provider_file = line[2:].strip()
|
|
310
|
+
full = config_dir / provider_file
|
|
311
|
+
if full.exists():
|
|
312
|
+
eprint(f" ✓ {provider_file}")
|
|
313
|
+
else:
|
|
314
|
+
eprint(f" ✖ {provider_file} — not found")
|
|
315
|
+
errors.append(f"Missing: {provider_file}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _env_is_set(var_name: str) -> bool:
|
|
319
|
+
"""Check if an environment variable is set and non-empty."""
|
|
320
|
+
import os
|
|
321
|
+
|
|
322
|
+
return bool(os.environ.get(var_name))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _print_validation_result(errors: List[str]) -> None:
|
|
326
|
+
"""Print validation summary."""
|
|
327
|
+
if errors:
|
|
328
|
+
eprint(f" ✖ {len(errors)} issue(s) found.")
|
|
329
|
+
for e in errors:
|
|
330
|
+
eprint(f" - {e}")
|
|
331
|
+
else:
|
|
332
|
+
eprint(" ✓ Configuration looks good!")
|