fusionkit 0.5.0__tar.gz → 0.5.1__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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fusionkit
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Command line interface for fusionkit: local response-level model fusion.
5
- Requires-Dist: fusionkit-core==0.5.0
6
- Requires-Dist: fusionkit-evals==0.5.0
7
- Requires-Dist: fusionkit-mlx==0.5.0
8
- Requires-Dist: fusionkit-server==0.5.0
5
+ Requires-Dist: fusionkit-core==0.5.1
6
+ Requires-Dist: fusionkit-evals==0.5.1
7
+ Requires-Dist: fusionkit-mlx==0.5.1
8
+ Requires-Dist: fusionkit-server==0.5.1
9
9
  Requires-Dist: typer>=0.20.0
10
10
  Requires-Python: >=3.11
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "fusionkit"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "Command line interface for fusionkit: local response-level model fusion."
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
7
- "fusionkit-core==0.5.0",
8
- "fusionkit-evals==0.5.0",
9
- "fusionkit-mlx==0.5.0",
10
- "fusionkit-server==0.5.0",
7
+ "fusionkit-core==0.5.1",
8
+ "fusionkit-evals==0.5.1",
9
+ "fusionkit-mlx==0.5.1",
10
+ "fusionkit-server==0.5.1",
11
11
  "typer>=0.20.0",
12
12
  ]
13
13
 
@@ -10,7 +10,16 @@ from typing import Annotated, cast
10
10
  import typer
11
11
  import uvicorn
12
12
  from fusionkit_core.clients import build_clients
13
- from fusionkit_core.config import FusionMode, load_config
13
+ from fusionkit_core.config import (
14
+ EndpointAuth,
15
+ FusionConfig,
16
+ FusionMode,
17
+ ModelEndpoint,
18
+ ProviderKind,
19
+ SubscriptionAuthMode,
20
+ load_config,
21
+ )
22
+ from fusionkit_core.credentials import SubscriptionStatus, subscription_status
14
23
  from fusionkit_core.fusion import FusionEngine
15
24
  from fusionkit_core.prompts import SYSTEM_PROMPT_DEFAULTS
16
25
  from fusionkit_evals.bench_history import BenchRunRecord, append_run, drift_vs_previous
@@ -75,13 +84,32 @@ from fusionkit_evals.tiny import (
75
84
  write_tiny_jsonl,
76
85
  )
77
86
  from fusionkit_server.app import create_app
78
- from fusionkit_server.openai_endpoint import build_endpoint, serve_single_endpoint
87
+ from fusionkit_server.openai_endpoint import (
88
+ PROVIDER_DEFAULT_BASE_URL,
89
+ build_endpoint,
90
+ serve_single_endpoint,
91
+ )
92
+ from rich.console import Console
93
+ from rich.table import Table
94
+
95
+ from fusionkit_cli.onboarding import (
96
+ API_KEY_ENVS,
97
+ api_key_endpoint,
98
+ default_write_path,
99
+ detect_api_keys,
100
+ resolve_config_path,
101
+ subscription_endpoint,
102
+ write_config,
103
+ )
79
104
 
80
105
  app = typer.Typer(help="Local model fusion toolkit.")
81
106
 
82
107
  prompts_app = typer.Typer(help="Inspect and export the built-in fusion prompts.")
83
108
  app.add_typer(prompts_app, name="prompts")
84
109
 
110
+ auth_app = typer.Typer(help="Inspect and switch model authentication (API key / subscription).")
111
+ app.add_typer(auth_app, name="auth")
112
+
85
113
 
86
114
  @prompts_app.command("dump")
87
115
  def prompts_dump(
@@ -108,15 +136,246 @@ def prompts_dump(
108
136
 
109
137
  @app.command()
110
138
  def serve(
111
- config: Annotated[Path, typer.Option("--config", "-c")],
139
+ config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
112
140
  host: str = "127.0.0.1",
113
141
  port: int = 8080,
114
142
  ) -> None:
115
- fusion_config = load_config(config)
143
+ resolved = resolve_config_path(config)
144
+ if resolved is None:
145
+ typer.secho(
146
+ "No config found. Run `fusionkit init` to create one, or pass --config.",
147
+ fg=typer.colors.RED,
148
+ err=True,
149
+ )
150
+ raise typer.Exit(code=1)
151
+ fusion_config = load_config(resolved)
116
152
  api = create_app(fusion_config)
117
153
  uvicorn.run(api, host=host, port=port)
118
154
 
119
155
 
156
+ def _status_label(status: SubscriptionStatus) -> str:
157
+ if not status.available:
158
+ return "not logged in"
159
+ hours = status.hours_to_expiry
160
+ if status.expired:
161
+ return "expired (re-login)"
162
+ if hours is not None:
163
+ return f"logged in (expires in {hours:.1f}h)"
164
+ return "logged in"
165
+
166
+
167
+ @app.command()
168
+ def init(
169
+ output: Annotated[
170
+ Path | None, typer.Option("--output", "-o", help="config path to write")
171
+ ] = None,
172
+ global_: Annotated[
173
+ bool, typer.Option("--global", help="write to ~/.config/fusionkit/models.yaml")
174
+ ] = False,
175
+ yes: Annotated[
176
+ bool, typer.Option("--yes", "-y", help="accept all detected sources non-interactively")
177
+ ] = False,
178
+ force: Annotated[bool, typer.Option("--force", help="overwrite an existing config")] = False,
179
+ ) -> None:
180
+ """Detect logged-in subscriptions + API keys and scaffold a config."""
181
+ target = output or default_write_path(global_)
182
+ if target.exists() and not force:
183
+ typer.secho(
184
+ f"{target} already exists; pass --force to overwrite or -o to choose another path.",
185
+ fg=typer.colors.RED,
186
+ err=True,
187
+ )
188
+ raise typer.Exit(code=1)
189
+
190
+ claude = subscription_status("claude-code")
191
+ codex = subscription_status("codex")
192
+ api_keys = detect_api_keys()
193
+
194
+ typer.echo("Detected:")
195
+ typer.echo(f" Claude Code subscription : {_status_label(claude)}")
196
+ typer.echo(f" Codex subscription : {_status_label(codex)}")
197
+ typer.echo(f" API keys : {', '.join(api_keys.values()) or 'none'}")
198
+ typer.echo("")
199
+
200
+ def want(label: str) -> bool:
201
+ return True if yes else typer.confirm(f"Add {label}?", default=True)
202
+
203
+ endpoints: list[ModelEndpoint] = []
204
+ if claude.available and want("Claude Code subscription (claude-code)"):
205
+ endpoints.append(subscription_endpoint("claude-code"))
206
+ if codex.available and want("Codex subscription (codex)"):
207
+ endpoints.append(subscription_endpoint("codex"))
208
+ for provider, env_var in api_keys.items():
209
+ if want(f"{provider} via {env_var}"):
210
+ endpoints.append(api_key_endpoint(provider))
211
+
212
+ if not endpoints:
213
+ typer.secho(
214
+ "Nothing selected. Log in with `claude` / `codex login`, or set "
215
+ "OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY, then re-run `fusionkit init`.",
216
+ fg=typer.colors.YELLOW,
217
+ err=True,
218
+ )
219
+ raise typer.Exit(code=1)
220
+
221
+ choices = [endpoint.id for endpoint in endpoints]
222
+ default_model = choices[0]
223
+ mode: str = "router"
224
+ if not yes:
225
+ default_model = typer.prompt("Default model id", default=default_model)
226
+ if default_model not in choices:
227
+ raise typer.BadParameter(f"default model must be one of {choices}")
228
+ mode = typer.prompt("Default mode (single/self/panel/router)", default="router")
229
+
230
+ try:
231
+ config = FusionConfig(
232
+ endpoints=endpoints,
233
+ default_model=default_model,
234
+ default_mode=cast(FusionMode, mode),
235
+ panel_models=choices,
236
+ )
237
+ except Exception as exc: # noqa: BLE001 - surface pydantic validation as a CLI error
238
+ raise typer.BadParameter(str(exc)) from exc
239
+
240
+ write_config(config, target)
241
+ typer.secho(f"Wrote {target} with {len(endpoints)} endpoint(s).", fg=typer.colors.GREEN)
242
+ discovered = resolve_config_path(None)
243
+ serve_hint = "fusionkit serve" if discovered == target else f"fusionkit serve --config {target}"
244
+ typer.echo(f"Next: {serve_hint}")
245
+
246
+
247
+ @auth_app.command("status")
248
+ def auth_status(
249
+ config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
250
+ ) -> None:
251
+ """Show subscription logins, API keys, and how the config authenticates."""
252
+ console = Console()
253
+ subs = Table(title="Subscriptions")
254
+ subs.add_column("mode")
255
+ subs.add_column("state")
256
+ subs.add_column("account")
257
+ for status in (subscription_status("claude-code"), subscription_status("codex")):
258
+ subs.add_row(status.mode, _status_label(status), status.account_id or "-")
259
+ console.print(subs)
260
+
261
+ api_keys = detect_api_keys()
262
+ console.print(
263
+ "API keys: "
264
+ + (", ".join(f"{p} ({env})" for p, env in api_keys.items()) if api_keys else "none")
265
+ )
266
+
267
+ resolved = resolve_config_path(config)
268
+ if resolved is None:
269
+ console.print("Config: none found (run `fusionkit init`).")
270
+ return
271
+ cfg = load_config(resolved)
272
+ endpoints_table = Table(title=f"Config endpoints ({resolved})")
273
+ endpoints_table.add_column("id")
274
+ endpoints_table.add_column("provider")
275
+ endpoints_table.add_column("model")
276
+ endpoints_table.add_column("auth")
277
+ for endpoint in cfg.endpoints:
278
+ marker = " (default)" if endpoint.id == cfg.default_model else ""
279
+ endpoints_table.add_row(
280
+ endpoint.id + marker, endpoint.provider, endpoint.model, endpoint.auth.mode
281
+ )
282
+ console.print(endpoints_table)
283
+
284
+
285
+ def _switch_endpoint(
286
+ endpoint: ModelEndpoint, mode: SubscriptionAuthMode, api_key_env: str | None
287
+ ) -> ModelEndpoint:
288
+ """Return a copy of an endpoint with its auth mode changed (keeping provider coherent)."""
289
+ provider: ProviderKind = endpoint.provider
290
+ base_url = endpoint.base_url
291
+ resolved_key_env = endpoint.api_key_env
292
+ if mode == "claude-code":
293
+ provider = "anthropic"
294
+ elif mode == "codex":
295
+ provider = "codex"
296
+ else: # api_key
297
+ if provider == "codex":
298
+ # The codex provider only speaks the subscription Responses API; an
299
+ # API key means standard OpenAI chat completions.
300
+ provider = "openai"
301
+ resolved_key_env = api_key_env or endpoint.api_key_env or API_KEY_ENVS.get(provider)
302
+ if not base_url:
303
+ base_url = PROVIDER_DEFAULT_BASE_URL.get(provider, base_url)
304
+ return endpoint.model_copy(
305
+ update={
306
+ "provider": provider,
307
+ "base_url": base_url,
308
+ "api_key_env": resolved_key_env,
309
+ "auth": EndpointAuth(mode=mode),
310
+ }
311
+ )
312
+
313
+
314
+ @auth_app.command("switch")
315
+ def auth_switch(
316
+ endpoint_id: Annotated[str, typer.Argument(help="endpoint id to change")],
317
+ mode: Annotated[
318
+ str, typer.Option("--mode", help="api_key, claude-code, or codex")
319
+ ],
320
+ api_key_env: Annotated[
321
+ str | None, typer.Option("--api-key-env", help="env var for api_key mode")
322
+ ] = None,
323
+ config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
324
+ ) -> None:
325
+ """Switch one endpoint between API key and a subscription."""
326
+ if mode not in ("api_key", "claude-code", "codex"):
327
+ raise typer.BadParameter("mode must be api_key, claude-code, or codex")
328
+ resolved = resolve_config_path(config)
329
+ if resolved is None:
330
+ raise typer.BadParameter("No config found; run `fusionkit init` or pass --config.")
331
+ cfg = load_config(resolved)
332
+ try:
333
+ endpoint = cfg.endpoint_for(endpoint_id)
334
+ except KeyError:
335
+ raise typer.BadParameter(
336
+ f"unknown endpoint {endpoint_id!r}; known: {[e.id for e in cfg.endpoints]}"
337
+ ) from None
338
+ updated = _switch_endpoint(endpoint, cast(SubscriptionAuthMode, mode), api_key_env)
339
+ new_endpoints = [updated if e.id == endpoint_id else e for e in cfg.endpoints]
340
+ write_config(cfg.model_copy(update={"endpoints": new_endpoints}), resolved)
341
+ typer.secho(
342
+ f"{endpoint_id}: auth -> {mode} (provider {updated.provider}) in {resolved}",
343
+ fg=typer.colors.GREEN,
344
+ )
345
+
346
+
347
+ @auth_app.command("set-default")
348
+ def auth_set_default(
349
+ endpoint_id: Annotated[str, typer.Argument(help="endpoint id to make the default model")],
350
+ config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
351
+ ) -> None:
352
+ """Change the config's default_model."""
353
+ resolved = resolve_config_path(config)
354
+ if resolved is None:
355
+ raise typer.BadParameter("No config found; run `fusionkit init` or pass --config.")
356
+ cfg = load_config(resolved)
357
+ if endpoint_id not in {endpoint.id for endpoint in cfg.endpoints}:
358
+ raise typer.BadParameter(
359
+ f"unknown endpoint {endpoint_id!r}; known: {[e.id for e in cfg.endpoints]}"
360
+ )
361
+ write_config(cfg.model_copy(update={"default_model": endpoint_id}), resolved)
362
+ typer.secho(f"default_model -> {endpoint_id} in {resolved}", fg=typer.colors.GREEN)
363
+
364
+
365
+ @auth_app.command("login")
366
+ def auth_login(
367
+ provider: Annotated[str, typer.Argument(help="claude-code or codex")],
368
+ ) -> None:
369
+ """Show how to log in (FusionKit reuses the official CLI logins; it does not own OAuth)."""
370
+ if provider not in ("claude-code", "codex"):
371
+ raise typer.BadParameter("provider must be claude-code or codex")
372
+ command = "claude" if provider == "claude-code" else "codex login"
373
+ typer.echo(f"FusionKit reuses the {provider} CLI login read-only. To (re)authenticate, run:")
374
+ typer.secho(f" {command}", fg=typer.colors.CYAN)
375
+ status = subscription_status(cast(SubscriptionAuthMode, provider))
376
+ typer.echo(f"Current status: {_status_label(status)}")
377
+
378
+
120
379
  @app.command("serve-endpoint")
121
380
  def serve_endpoint(
122
381
  id: Annotated[str, typer.Option("--id", help="endpoint id exposed via /v1/models")],
@@ -129,6 +388,17 @@ def serve_endpoint(
129
388
  api_key_env: Annotated[
130
389
  str | None, typer.Option("--api-key-env", help="env var holding the API key")
131
390
  ] = None,
391
+ auth_mode: Annotated[
392
+ str,
393
+ typer.Option(
394
+ "--auth-mode",
395
+ help="credential source: api_key, claude-code, or codex (reuse the CLI login)",
396
+ ),
397
+ ] = "api_key",
398
+ credentials_path: Annotated[
399
+ str | None,
400
+ typer.Option("--credentials-path", help="override the CLI credential file path"),
401
+ ] = None,
132
402
  timeout_s: Annotated[float, typer.Option("--timeout-s")] = 120.0,
133
403
  host: Annotated[str, typer.Option("--host")] = "127.0.0.1",
134
404
  ) -> None:
@@ -140,6 +410,8 @@ def serve_endpoint(
140
410
  base_url=base_url,
141
411
  api_key_env=api_key_env,
142
412
  timeout_s=timeout_s,
413
+ auth_mode=auth_mode,
414
+ credentials_path=credentials_path,
143
415
  )
144
416
  serve_single_endpoint(endpoint, host=host, port=port)
145
417
 
@@ -0,0 +1,151 @@
1
+ """Helpers for `fusionkit init` / `fusionkit auth`: detection, config discovery,
2
+ and config read/write.
3
+
4
+ FusionKit owns the config file it generates here, so writes re-emit the file from
5
+ the validated model (hand-added YAML comments are not preserved across rewrites).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+ from fusionkit_core.config import (
15
+ EndpointAuth,
16
+ FusionConfig,
17
+ ModelEndpoint,
18
+ ProviderKind,
19
+ SubscriptionAuthMode,
20
+ )
21
+ from fusionkit_server.openai_endpoint import PROVIDER_DEFAULT_BASE_URL
22
+
23
+ CONFIG_ENV_VAR = "FUSIONKIT_CONFIG"
24
+ PROJECT_CONFIG = Path("fusionkit.yaml")
25
+ PROJECT_DOTDIR_CONFIG = Path(".fusionkit/models.yaml")
26
+
27
+ # Live-verified defaults; the wizard lets the user override them.
28
+ DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-5"
29
+ DEFAULT_CODEX_MODEL = "gpt-5.5"
30
+
31
+ # API-key providers the wizard knows how to scaffold.
32
+ API_KEY_ENVS: dict[ProviderKind, str] = {
33
+ "openai": "OPENAI_API_KEY",
34
+ "anthropic": "ANTHROPIC_API_KEY",
35
+ "google": "GEMINI_API_KEY",
36
+ }
37
+ DEFAULT_API_MODELS: dict[ProviderKind, str] = {
38
+ "openai": "gpt-5.5",
39
+ "anthropic": "claude-sonnet-4-5",
40
+ "google": "gemini-2.5-flash",
41
+ }
42
+
43
+ CONFIG_HEADER = (
44
+ "# Generated by `fusionkit init`. Edit freely, or manage with `fusionkit auth`.\n"
45
+ "# Subscription endpoints (auth.mode: claude-code / codex) reuse your local\n"
46
+ "# Claude Code / Codex CLI logins read-only; run `claude` or `codex login` to refresh.\n"
47
+ )
48
+
49
+
50
+ def global_config_path() -> Path:
51
+ return Path(os.path.expanduser("~/.config/fusionkit/models.yaml"))
52
+
53
+
54
+ def resolve_config_path(explicit: Path | None) -> Path | None:
55
+ """Find the config to use: explicit -> $FUSIONKIT_CONFIG -> project -> global."""
56
+ if explicit is not None:
57
+ return explicit
58
+ env_value = os.environ.get(CONFIG_ENV_VAR)
59
+ if env_value:
60
+ return Path(env_value)
61
+ for candidate in (PROJECT_CONFIG, PROJECT_DOTDIR_CONFIG, global_config_path()):
62
+ if candidate.exists():
63
+ return candidate
64
+ return None
65
+
66
+
67
+ def default_write_path(global_: bool) -> Path:
68
+ return global_config_path() if global_ else PROJECT_CONFIG
69
+
70
+
71
+ def write_config(config: FusionConfig, path: Path) -> None:
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ data = config.model_dump(mode="json", exclude_defaults=True)
74
+ body = yaml.safe_dump(data, sort_keys=False)
75
+ path.write_text(CONFIG_HEADER + body)
76
+
77
+
78
+ def detect_api_keys() -> dict[ProviderKind, str]:
79
+ """Return {provider: env_var} for API-key env vars that are currently set."""
80
+ return {
81
+ provider: env_var
82
+ for provider, env_var in API_KEY_ENVS.items()
83
+ if os.environ.get(env_var)
84
+ }
85
+
86
+
87
+ def detect_codex_model() -> str | None:
88
+ """Best-effort read of the model the Codex CLI is pinned to."""
89
+ path = Path(os.path.expanduser("~/.codex/config.toml"))
90
+ if not path.exists():
91
+ return None
92
+ try:
93
+ data = tomllib.loads(path.read_text())
94
+ except (OSError, tomllib.TOMLDecodeError):
95
+ return None
96
+ model = data.get("model")
97
+ return model if isinstance(model, str) and model else None
98
+
99
+
100
+ def subscription_endpoint(
101
+ mode: SubscriptionAuthMode,
102
+ model: str | None = None,
103
+ endpoint_id: str | None = None,
104
+ ) -> ModelEndpoint:
105
+ """Build a subscription-backed endpoint (base_url omitted; client defaults)."""
106
+ if mode == "claude-code":
107
+ return ModelEndpoint(
108
+ id=endpoint_id or "claude-code",
109
+ provider="anthropic",
110
+ model=model or DEFAULT_CLAUDE_MODEL,
111
+ auth=EndpointAuth(mode="claude-code"),
112
+ )
113
+ if mode == "codex":
114
+ return ModelEndpoint(
115
+ id=endpoint_id or "codex",
116
+ provider="codex",
117
+ model=model or detect_codex_model() or DEFAULT_CODEX_MODEL,
118
+ auth=EndpointAuth(mode="codex"),
119
+ )
120
+ raise ValueError(f"{mode!r} is not a subscription mode")
121
+
122
+
123
+ def api_key_endpoint(
124
+ provider: ProviderKind,
125
+ model: str | None = None,
126
+ endpoint_id: str | None = None,
127
+ ) -> ModelEndpoint:
128
+ return ModelEndpoint(
129
+ id=endpoint_id or f"{provider}-api",
130
+ provider=provider,
131
+ model=model or DEFAULT_API_MODELS.get(provider, "model"),
132
+ base_url=PROVIDER_DEFAULT_BASE_URL.get(provider, ""),
133
+ api_key_env=API_KEY_ENVS.get(provider),
134
+ )
135
+
136
+
137
+ __all__ = [
138
+ "API_KEY_ENVS",
139
+ "CONFIG_ENV_VAR",
140
+ "DEFAULT_API_MODELS",
141
+ "DEFAULT_CLAUDE_MODEL",
142
+ "DEFAULT_CODEX_MODEL",
143
+ "api_key_endpoint",
144
+ "default_write_path",
145
+ "detect_api_keys",
146
+ "detect_codex_model",
147
+ "global_config_path",
148
+ "resolve_config_path",
149
+ "subscription_endpoint",
150
+ "write_config",
151
+ ]