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.
- {fusionkit-0.5.0 → fusionkit-0.5.1}/PKG-INFO +5 -5
- {fusionkit-0.5.0 → fusionkit-0.5.1}/pyproject.toml +5 -5
- {fusionkit-0.5.0 → fusionkit-0.5.1}/src/fusionkit_cli/main.py +276 -4
- fusionkit-0.5.1/src/fusionkit_cli/onboarding.py +151 -0
- {fusionkit-0.5.0 → fusionkit-0.5.1}/src/fusionkit_cli/__init__.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fusionkit
|
|
3
|
-
Version: 0.5.
|
|
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.
|
|
6
|
-
Requires-Dist: fusionkit-evals==0.5.
|
|
7
|
-
Requires-Dist: fusionkit-mlx==0.5.
|
|
8
|
-
Requires-Dist: fusionkit-server==0.5.
|
|
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.
|
|
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.
|
|
8
|
-
"fusionkit-evals==0.5.
|
|
9
|
-
"fusionkit-mlx==0.5.
|
|
10
|
-
"fusionkit-server==0.5.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
]
|
|
File without changes
|