ai-switch-cli 0.2.2__tar.gz → 0.3.2__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.
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/PKG-INFO +23 -7
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/README.md +22 -6
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/ai_switch.py +172 -60
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/ai_switch_cli.egg-info/PKG-INFO +23 -7
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/pyproject.toml +1 -1
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/ai_switch_cli.egg-info/SOURCES.txt +0 -0
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/ai_switch_cli.egg-info/dependency_links.txt +0 -0
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/ai_switch_cli.egg-info/entry_points.txt +0 -0
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/ai_switch_cli.egg-info/top_level.txt +0 -0
- {ai_switch_cli-0.2.2 → ai_switch_cli-0.3.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-switch-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: One-command provider and model switching for Claude Code and Codex, with session-history checks
|
|
5
5
|
Author: ZidongS
|
|
6
6
|
License: MIT
|
|
@@ -78,13 +78,26 @@ Select `glm` or `deepseek`, enter the API key, and activate the generated config
|
|
|
78
78
|
ai-switch use glm
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
`use`
|
|
81
|
+
`use` activates the profile and does **not** ask anything: the profile's whole model list is published, so you
|
|
82
|
+
pick the model inside the agent with `/model` (Codex) or its Opus/Sonnet/Haiku categories (Claude Code). The
|
|
83
|
+
default for new sessions is the one you last used, or the profile default. Add `--choose` if you want to be
|
|
84
|
+
asked, or `-m/--model` to set the default directly.
|
|
82
85
|
|
|
83
86
|
```text
|
|
84
|
-
|
|
87
|
+
Active profile: glm
|
|
88
|
+
Default model: glm-5.3 (new sessions)
|
|
89
|
+
Published models: 3 - switch any time with /model inside codex (Claude Code uses its
|
|
90
|
+
Opus/Sonnet/Haiku mappings), or change the default with -m/--model.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With `--choose` you get the picker instead:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
This profile provides 3 models:
|
|
85
97
|
1) glm-5.3 GLM-5.3 flagship (1M context) [default]
|
|
86
|
-
2) glm-5.3-flash GLM-5.3
|
|
87
|
-
|
|
98
|
+
2) glm-5.3-flash GLM-5.3 Flash (fast, text+image, 1M context)
|
|
99
|
+
3) glm-5-turbo GLM-5 Turbo (agent-optimized, 200K context)
|
|
100
|
+
Select model [1-3, Enter=glm-5.3, q=cancel]:
|
|
88
101
|
```
|
|
89
102
|
|
|
90
103
|
For scripts and servers, choose without prompting (name, unique prefix, or index):
|
|
@@ -93,8 +106,11 @@ For scripts and servers, choose without prompting (name, unique prefix, or index
|
|
|
93
106
|
ai-switch use glm --model glm-5.3-flash
|
|
94
107
|
ai-switch use glm -m 2 -y
|
|
95
108
|
ai-switch use glm --dry-run # show what would change
|
|
109
|
+
ai-switch use glm -m glm-5.3 --pin # publish only this model (see below)
|
|
96
110
|
```
|
|
97
111
|
|
|
112
|
+
Selecting a model sets the **default for new sessions** in both agents, and the profile's other models stay in the catalogue so you can still switch inside the agent. That means an in-app `/model` choice is a per-session override — and Codex writes its own choice back to `~/.codex/config.toml` when it exits, so the two agents can drift apart without anything else noticing; `ai-switch doctor` reports exactly that. If you want an activation to be exclusive instead, add `--pin`: the published catalogue then holds only the selected model, so Codex's own picker has nothing else to switch to (the next `ai-switch use` without `--pin` restores the full menu).
|
|
113
|
+
|
|
98
114
|
Edit `~/.codex/config.toml` and `~/.claude/settings.json` for another provider, then save that configuration as a second profile:
|
|
99
115
|
|
|
100
116
|
```bash
|
|
@@ -133,7 +149,7 @@ For every model a profile records the Codex catalogue entry and the Claude Code
|
|
|
133
149
|
* Codex: the top-level `model` (and `model_reasoning_effort`), plus a `~/.codex/models.json` catalogue that lists **every** model of the profile, which is what makes Codex's own `/model` picker show them.
|
|
134
150
|
* Claude Code: the default model in `settings.json` plus the Opus/Sonnet/Haiku mappings. The presets map the three categories to *different* provider models where they exist, and they never set `ANTHROPIC_MODEL`, because a pinned environment model overrides your selection and makes `/model` do nothing.
|
|
135
151
|
|
|
136
|
-
The `glm` and `deepseek` presets ship multi-model lists. A custom (OpenAI-compatible) profile is open-ended: it
|
|
152
|
+
The `glm` and `deepseek` presets ship multi-model lists. A custom (OpenAI-compatible) profile is open-ended as well: when you create it you can name **several models at once, separated by spaces or commas** (for example `DeepSeek-V4.1-Flash Kimi-K3 Qwen3.8-Max`), and those become the profile's catalogue — both agents can then pick them. Any other name the endpoint serves still works with `ai-switch use NAME --model <name>`, and it is added to the catalogue when you use it.
|
|
137
153
|
|
|
138
154
|
### When a provider adds or renames a model
|
|
139
155
|
|
|
@@ -180,7 +196,7 @@ ai-switch doctor --fix # quarantine damaged Codex runtime databases
|
|
|
180
196
|
export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER # a local, persistent directory
|
|
181
197
|
```
|
|
182
198
|
|
|
183
|
-
`
|
|
199
|
+
Codex only honours the **environment variable**: a `sqlite_home` key in `config.toml` is parsed but ignored (checked with `codex doctor`). `ai-switch add` asks for a local directory when it detects a network filesystem, creates it and prints the export line to put in your shell profile; `ai-switch doctor` reports the variable's status.
|
|
184
200
|
* **A stale model catalogue.** A catalogue left behind by the previous provider hides the new provider's models from Codex and makes sessions that used them unresumable. ai-switch removes that leftover file (only if it wrote it itself, and it backs it up first), and `doctor` verifies the configured model exists in the catalogue.
|
|
185
201
|
* **Disabled history persistence** (`[history] persistence = "none"`), a Claude Code model pinned by `ANTHROPIC_MODEL`, and `claude`/`codex` processes that are running while you switch and will rewrite their configuration on exit.
|
|
186
202
|
|
|
@@ -64,13 +64,26 @@ Select `glm` or `deepseek`, enter the API key, and activate the generated config
|
|
|
64
64
|
ai-switch use glm
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
`use`
|
|
67
|
+
`use` activates the profile and does **not** ask anything: the profile's whole model list is published, so you
|
|
68
|
+
pick the model inside the agent with `/model` (Codex) or its Opus/Sonnet/Haiku categories (Claude Code). The
|
|
69
|
+
default for new sessions is the one you last used, or the profile default. Add `--choose` if you want to be
|
|
70
|
+
asked, or `-m/--model` to set the default directly.
|
|
68
71
|
|
|
69
72
|
```text
|
|
70
|
-
|
|
73
|
+
Active profile: glm
|
|
74
|
+
Default model: glm-5.3 (new sessions)
|
|
75
|
+
Published models: 3 - switch any time with /model inside codex (Claude Code uses its
|
|
76
|
+
Opus/Sonnet/Haiku mappings), or change the default with -m/--model.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With `--choose` you get the picker instead:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
This profile provides 3 models:
|
|
71
83
|
1) glm-5.3 GLM-5.3 flagship (1M context) [default]
|
|
72
|
-
2) glm-5.3-flash GLM-5.3
|
|
73
|
-
|
|
84
|
+
2) glm-5.3-flash GLM-5.3 Flash (fast, text+image, 1M context)
|
|
85
|
+
3) glm-5-turbo GLM-5 Turbo (agent-optimized, 200K context)
|
|
86
|
+
Select model [1-3, Enter=glm-5.3, q=cancel]:
|
|
74
87
|
```
|
|
75
88
|
|
|
76
89
|
For scripts and servers, choose without prompting (name, unique prefix, or index):
|
|
@@ -79,8 +92,11 @@ For scripts and servers, choose without prompting (name, unique prefix, or index
|
|
|
79
92
|
ai-switch use glm --model glm-5.3-flash
|
|
80
93
|
ai-switch use glm -m 2 -y
|
|
81
94
|
ai-switch use glm --dry-run # show what would change
|
|
95
|
+
ai-switch use glm -m glm-5.3 --pin # publish only this model (see below)
|
|
82
96
|
```
|
|
83
97
|
|
|
98
|
+
Selecting a model sets the **default for new sessions** in both agents, and the profile's other models stay in the catalogue so you can still switch inside the agent. That means an in-app `/model` choice is a per-session override — and Codex writes its own choice back to `~/.codex/config.toml` when it exits, so the two agents can drift apart without anything else noticing; `ai-switch doctor` reports exactly that. If you want an activation to be exclusive instead, add `--pin`: the published catalogue then holds only the selected model, so Codex's own picker has nothing else to switch to (the next `ai-switch use` without `--pin` restores the full menu).
|
|
99
|
+
|
|
84
100
|
Edit `~/.codex/config.toml` and `~/.claude/settings.json` for another provider, then save that configuration as a second profile:
|
|
85
101
|
|
|
86
102
|
```bash
|
|
@@ -119,7 +135,7 @@ For every model a profile records the Codex catalogue entry and the Claude Code
|
|
|
119
135
|
* Codex: the top-level `model` (and `model_reasoning_effort`), plus a `~/.codex/models.json` catalogue that lists **every** model of the profile, which is what makes Codex's own `/model` picker show them.
|
|
120
136
|
* Claude Code: the default model in `settings.json` plus the Opus/Sonnet/Haiku mappings. The presets map the three categories to *different* provider models where they exist, and they never set `ANTHROPIC_MODEL`, because a pinned environment model overrides your selection and makes `/model` do nothing.
|
|
121
137
|
|
|
122
|
-
The `glm` and `deepseek` presets ship multi-model lists. A custom (OpenAI-compatible) profile is open-ended: it
|
|
138
|
+
The `glm` and `deepseek` presets ship multi-model lists. A custom (OpenAI-compatible) profile is open-ended as well: when you create it you can name **several models at once, separated by spaces or commas** (for example `DeepSeek-V4.1-Flash Kimi-K3 Qwen3.8-Max`), and those become the profile's catalogue — both agents can then pick them. Any other name the endpoint serves still works with `ai-switch use NAME --model <name>`, and it is added to the catalogue when you use it.
|
|
123
139
|
|
|
124
140
|
### When a provider adds or renames a model
|
|
125
141
|
|
|
@@ -166,7 +182,7 @@ ai-switch doctor --fix # quarantine damaged Codex runtime databases
|
|
|
166
182
|
export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER # a local, persistent directory
|
|
167
183
|
```
|
|
168
184
|
|
|
169
|
-
`
|
|
185
|
+
Codex only honours the **environment variable**: a `sqlite_home` key in `config.toml` is parsed but ignored (checked with `codex doctor`). `ai-switch add` asks for a local directory when it detects a network filesystem, creates it and prints the export line to put in your shell profile; `ai-switch doctor` reports the variable's status.
|
|
170
186
|
* **A stale model catalogue.** A catalogue left behind by the previous provider hides the new provider's models from Codex and makes sessions that used them unresumable. ai-switch removes that leftover file (only if it wrote it itself, and it backs it up first), and `doctor` verifies the configured model exists in the catalogue.
|
|
171
187
|
* **Disabled history persistence** (`[history] persistence = "none"`), a Claude Code model pinned by `ANTHROPIC_MODEL`, and `claude`/`codex` processes that are running while you switch and will rewrite their configuration on exit.
|
|
172
188
|
|
|
@@ -23,7 +23,7 @@ Conversation history is never part of a profile
|
|
|
23
23
|
import argparse, hashlib, json, os, re, shutil, sqlite3, sys, tempfile, time
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
|
-
VERSION = "0.
|
|
26
|
+
VERSION = "0.3.2"
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _env_path(name, default):
|
|
@@ -267,8 +267,9 @@ def build_claude_settings(live, profile_settings, entry):
|
|
|
267
267
|
env = {k: v for k, v in live_env.items() if k not in CLAUDE_PROVIDER_ENV}
|
|
268
268
|
env.update({k: v for k, v in prof_env.items() if k not in CLAUDE_MODEL_PINS})
|
|
269
269
|
if entry:
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
mapped = entry.get("claude") if isinstance(entry.get("claude"), dict) else {}
|
|
271
|
+
env.update(mapped.get("env") or {})
|
|
272
|
+
out["model"] = mapped.get("model") or entry["slug"]
|
|
272
273
|
elif "model" in profile_settings:
|
|
273
274
|
out["model"] = profile_settings["model"]
|
|
274
275
|
out["env"] = env
|
|
@@ -322,6 +323,22 @@ def claude_mapping(model, opus=None, sonnet=None, haiku=None, subagent=None):
|
|
|
322
323
|
return {"model": model, "env": env}
|
|
323
324
|
|
|
324
325
|
|
|
326
|
+
def adhoc_model(slug, index=99):
|
|
327
|
+
"""Model entry for an endpoint whose catalogue we do not know (custom provider).
|
|
328
|
+
|
|
329
|
+
Every Claude Code category points at the same model: a gateway usually serves
|
|
330
|
+
one namespace, so Claude's own alias names (``claude-sonnet-*``) would be
|
|
331
|
+
rejected by it.
|
|
332
|
+
"""
|
|
333
|
+
return {"slug": slug, "label": "custom model", "reasoning": "",
|
|
334
|
+
"codex": codex_entry(slug, "custom model", ("text",), index, 1048576, "high"),
|
|
335
|
+
"claude": claude_mapping(slug, opus=slug, sonnet=slug, haiku=slug, subagent=slug)}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def split_models(text):
|
|
339
|
+
return [part for part in re.split(r"[,\s]+", text or "") if part]
|
|
340
|
+
|
|
341
|
+
|
|
325
342
|
GLM_MODELS = [
|
|
326
343
|
# Model names are exactly what the ZAI endpoint accepts; the ``[1m]`` marker
|
|
327
344
|
# some proxies use is rejected there (glm-5.3 is 1M context by default), so
|
|
@@ -459,7 +476,7 @@ def resolve_model(catalog, token):
|
|
|
459
476
|
raise ValueError(f"unknown model '{token}'; this profile offers: {available}")
|
|
460
477
|
|
|
461
478
|
|
|
462
|
-
def pick_model(catalog, requested, fallback, assume_yes=False):
|
|
479
|
+
def pick_model(catalog, requested, fallback, assume_yes=False, force=False):
|
|
463
480
|
"""Choose the model to activate (no prompting when requested/non-interactive)."""
|
|
464
481
|
if not catalog:
|
|
465
482
|
return requested
|
|
@@ -469,7 +486,7 @@ def pick_model(catalog, requested, fallback, assume_yes=False):
|
|
|
469
486
|
return resolve_model(catalog, requested)
|
|
470
487
|
if fallback not in slugs:
|
|
471
488
|
fallback = catalog["default"] if catalog["default"] in slugs else slugs[0]
|
|
472
|
-
if assume_yes or not sys.stdin.isatty() or len(entries) == 1:
|
|
489
|
+
if assume_yes or (not force and (not sys.stdin.isatty() or len(entries) == 1)):
|
|
473
490
|
return fallback
|
|
474
491
|
print(f"This profile provides {len(entries)} models:")
|
|
475
492
|
for index, entry in enumerate(entries, 1):
|
|
@@ -500,16 +517,17 @@ def catalog_target(text, catalog_path):
|
|
|
500
517
|
return catalog_path
|
|
501
518
|
|
|
502
519
|
|
|
503
|
-
def build_codex_catalog(d, catalog):
|
|
520
|
+
def build_codex_catalog(d, catalog, include_profile_entries=True):
|
|
504
521
|
"""Union of the profile's catalog file and every selectable model entry."""
|
|
505
522
|
entries = {}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if isinstance(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
523
|
+
if include_profile_entries:
|
|
524
|
+
try:
|
|
525
|
+
raw = json.loads((d / "codex-models.json").read_text())
|
|
526
|
+
for entry in (raw.get("models") if isinstance(raw, dict) else raw) or []:
|
|
527
|
+
if isinstance(entry, dict) and entry.get("slug"):
|
|
528
|
+
entries[entry["slug"]] = entry
|
|
529
|
+
except (OSError, ValueError, AttributeError):
|
|
530
|
+
pass
|
|
513
531
|
for entry in catalog["models"]:
|
|
514
532
|
if isinstance(entry.get("codex"), dict):
|
|
515
533
|
entries[entry["slug"]] = entry["codex"]
|
|
@@ -520,7 +538,7 @@ def build_codex_catalog(d, catalog):
|
|
|
520
538
|
return {"models": ordered}
|
|
521
539
|
|
|
522
540
|
|
|
523
|
-
def compute_changes(d, slug):
|
|
541
|
+
def compute_changes(d, slug, pin=False):
|
|
524
542
|
"""Return {path: text-or-None}: None means "remove this stale file"."""
|
|
525
543
|
changes, notes = {}, []
|
|
526
544
|
catalog = load_catalog(d)
|
|
@@ -530,12 +548,23 @@ def compute_changes(d, slug):
|
|
|
530
548
|
|
|
531
549
|
text = read_text(d / "codex-config.toml")
|
|
532
550
|
codex_catalog = None
|
|
551
|
+
# A model that is used but not listed (open-ended custom endpoint, or a legacy
|
|
552
|
+
# profile without a model list) gets a generated entry so that both agents are
|
|
553
|
+
# pointed at the requested model instead of falling back to the snapshot.
|
|
554
|
+
if slug and entry is None:
|
|
555
|
+
entry = adhoc_model(slug)
|
|
556
|
+
if catalog and catalog.get("open_ended"):
|
|
557
|
+
catalog = dict(catalog, models=catalog["models"] + [entry])
|
|
533
558
|
if text is not None:
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
# Codex
|
|
537
|
-
if catalog
|
|
538
|
-
|
|
559
|
+
# Every profile publishes its models, so the picker inside Codex lists
|
|
560
|
+
# them too. --pin publishes only the activated one, which removes the
|
|
561
|
+
# other entries from Codex's own picker.
|
|
562
|
+
if catalog:
|
|
563
|
+
if pin and entry:
|
|
564
|
+
codex_catalog = build_codex_catalog(d, dict(catalog, models=[entry]),
|
|
565
|
+
include_profile_entries=False)
|
|
566
|
+
else:
|
|
567
|
+
codex_catalog = build_codex_catalog(d, catalog)
|
|
539
568
|
if codex_catalog is not None:
|
|
540
569
|
catalog_path = catalog_target(text, CODEX_MODELS)
|
|
541
570
|
changes[catalog_path] = json.dumps(codex_catalog, indent=2) + "\n"
|
|
@@ -657,9 +686,8 @@ def codex_health_warnings(check_codex=True):
|
|
|
657
686
|
if status != "ok":
|
|
658
687
|
warnings.append(f"Codex runtime DB {db.name} is unusable ({detail}) - sessions may be missing "
|
|
659
688
|
"from the resume picker; run 'ai-switch doctor --fix'")
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
warnings.append("running now: " + ", ".join(running) +
|
|
689
|
+
if running_agents():
|
|
690
|
+
warnings.append("running now: " + running_agents_summary() +
|
|
663
691
|
" - restart them after switching, a running agent rewrites its config on exit")
|
|
664
692
|
return warnings
|
|
665
693
|
|
|
@@ -672,23 +700,34 @@ def cmd_use(args):
|
|
|
672
700
|
if not any((d / profile_file).exists() for profile_file in PROFILE_FILES):
|
|
673
701
|
raise ValueError(f"profile '{name}' has no Codex or Claude configuration to activate")
|
|
674
702
|
catalog = load_catalog(d)
|
|
675
|
-
fallback =
|
|
676
|
-
|
|
703
|
+
fallback = last_model(name) or (catalog or {}).get("default")
|
|
704
|
+
slug = args.model or fallback
|
|
705
|
+
if args.model and catalog and catalog.get("open_ended"):
|
|
677
706
|
try:
|
|
678
707
|
slug = resolve_model(catalog, args.model)
|
|
679
708
|
except ValueError:
|
|
680
709
|
slug = args.model
|
|
681
710
|
print(f"Note: '{args.model}' is not in the profile's model list; using it as-is.", file=sys.stderr)
|
|
682
|
-
|
|
683
|
-
slug =
|
|
711
|
+
elif args.model and catalog:
|
|
712
|
+
slug = resolve_model(catalog, args.model)
|
|
713
|
+
elif catalog and args.choose:
|
|
714
|
+
# Picking a model here only sets the default for new sessions; every model
|
|
715
|
+
# of the profile is published, so it can also be changed inside the agent.
|
|
716
|
+
slug = pick_model(catalog, None, fallback, force=True)
|
|
684
717
|
if slug and catalog is None:
|
|
685
718
|
print(f"Note: profile '{name}' has no model list, using '{slug}' as-is.", file=sys.stderr)
|
|
686
|
-
changes, notes = compute_changes(d, slug)
|
|
719
|
+
changes, notes = compute_changes(d, slug, pin=args.pin)
|
|
687
720
|
for note in notes:
|
|
688
721
|
print(f"Note: {note}", file=sys.stderr)
|
|
689
722
|
backup = apply_changes(changes, name, slug, dry_run=args.dry_run)
|
|
690
723
|
if not args.dry_run:
|
|
691
|
-
print(f"Active profile: {name}" + (f"\
|
|
724
|
+
print(f"Active profile: {name}" + (f"\nDefault model: {slug} (new sessions)" if slug else ""))
|
|
725
|
+
if args.pin and slug:
|
|
726
|
+
print(f"Pinned: only '{slug}' is published, so Codex cannot switch to another model.")
|
|
727
|
+
elif catalog:
|
|
728
|
+
print(f"Published models: {len(catalog['models'])} - switch any time with /model inside "
|
|
729
|
+
"codex (Claude Code uses its Opus/Sonnet/Haiku mappings), or change the default "
|
|
730
|
+
"with -m/--model.")
|
|
692
731
|
print(f"Backup: {backup}" if backup else "Backup: nothing to back up (no files existed yet)")
|
|
693
732
|
print("Restart claude/codex so they reload their configuration.")
|
|
694
733
|
if not args.no_check:
|
|
@@ -912,15 +951,17 @@ def cmd_add(_):
|
|
|
912
951
|
if preset:
|
|
913
952
|
chosen = preset["models"]
|
|
914
953
|
print(f"{provider} preset offers: " + ", ".join(m["slug"] for m in chosen))
|
|
915
|
-
wanted = _ask("Models to include (comma separated, Enter=all)", "all")
|
|
954
|
+
wanted = _ask("Models to include (space or comma separated, Enter=all)", "all")
|
|
916
955
|
if wanted.lower() not in ("all", ""):
|
|
917
|
-
names =
|
|
956
|
+
names = split_models(wanted)
|
|
918
957
|
by_slug = {m["slug"]: m for m in chosen}
|
|
919
958
|
chosen = []
|
|
920
959
|
for token in names:
|
|
921
|
-
|
|
960
|
+
exact = [s for s in by_slug if s.lower() == token.lower()]
|
|
961
|
+
slug = exact or [s for s in by_slug if s.lower().startswith(token.lower())]
|
|
922
962
|
if len(slug) != 1:
|
|
923
|
-
raise ValueError(f"unknown model '{token}'"
|
|
963
|
+
raise ValueError(f"unknown or ambiguous model '{token}'"
|
|
964
|
+
f" (choose one of: {', '.join(by_slug)})")
|
|
924
965
|
chosen.append(by_slug[slug[0]])
|
|
925
966
|
default = _ask("Default model", chosen[0]["slug"])
|
|
926
967
|
default = resolve_model({"models": chosen}, default)
|
|
@@ -931,13 +972,21 @@ def cmd_add(_):
|
|
|
931
972
|
if not endpoint:
|
|
932
973
|
raise ValueError("an endpoint URL is required")
|
|
933
974
|
claude_endpoint = endpoint[:-3].rstrip("/") if endpoint.endswith("/v1") else endpoint.rstrip("/")
|
|
934
|
-
|
|
935
|
-
if not
|
|
936
|
-
raise ValueError("
|
|
937
|
-
chosen = [
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
975
|
+
names = split_models(_ask("Model name(s) (space or comma separated, e.g. 'Big-Model Small-Model')"))
|
|
976
|
+
if not names:
|
|
977
|
+
raise ValueError("at least one model name is required")
|
|
978
|
+
chosen = []
|
|
979
|
+
for index, name in enumerate(names):
|
|
980
|
+
model = adhoc_model(name, index)
|
|
981
|
+
if len(names) > 1:
|
|
982
|
+
model["label"] = f"custom model (use --model {name})"
|
|
983
|
+
chosen.append(model)
|
|
984
|
+
default = chosen[0]["slug"]
|
|
985
|
+
if len(chosen) > 1:
|
|
986
|
+
print("This endpoint is open ended: any name works with "
|
|
987
|
+
f"'ai-switch use <profile> --model <name>', and the ones listed above appear in the picker.")
|
|
988
|
+
default = resolve_model({"models": chosen}, _ask("Default model", chosen[0]["slug"]))
|
|
989
|
+
common_env = {}
|
|
941
990
|
name = _ask("Profile name")
|
|
942
991
|
if not name:
|
|
943
992
|
raise ValueError("a profile name is required")
|
|
@@ -950,7 +999,7 @@ def cmd_add(_):
|
|
|
950
999
|
suggested = f"/var/tmp/codex-sqlite-{os.environ.get('USER', 'user')}"
|
|
951
1000
|
print(f"\n{CODEX_DIR} is on a network filesystem ({fstype_for(CODEX_DIR)}); SQLite runtime databases "
|
|
952
1001
|
"can be corrupted there, which makes sessions disappear.")
|
|
953
|
-
sqlite_home = _ask("Local directory for Codex runtime databases (Enter=
|
|
1002
|
+
sqlite_home = _ask("Local directory for Codex runtime databases (Enter=skip)", suggested)
|
|
954
1003
|
d = profile(name)
|
|
955
1004
|
blank_profile_dir(d)
|
|
956
1005
|
catalog = {"version": 2, "provider": provider, "default": default, "models": chosen,
|
|
@@ -965,17 +1014,15 @@ def cmd_add(_):
|
|
|
965
1014
|
.replace("@CATALOG@", display_path(catalog_path))
|
|
966
1015
|
.replace("@ENDPOINT@", preset["endpoint"]).replace("@KEY@", key))
|
|
967
1016
|
else:
|
|
968
|
-
# A
|
|
969
|
-
#
|
|
1017
|
+
# A gateway often serves dozens of models; the catalogue holds the ones
|
|
1018
|
+
# you named, and ``use --model <other>`` extends it on demand.
|
|
970
1019
|
base = (f'model = "{default}"\nmodel_provider = "custom"\npreferred_auth_method = "apikey"\n'
|
|
971
|
-
f'forced_login_method = "api"\
|
|
1020
|
+
f'forced_login_method = "api"\nmodel_catalog_json = "{display_path(CODEX_MODELS)}"\n\n'
|
|
1021
|
+
f'[model_providers.custom]\nname = "custom"\n'
|
|
972
1022
|
f'base_url = "{endpoint}"\nwire_api = "responses"\n'
|
|
973
1023
|
f'requires_openai_auth = true\nexperimental_bearer_token = "{key}"\n')
|
|
974
|
-
if sqlite_home:
|
|
975
|
-
base += f'\n# Keeps Codex runtime SQLite databases off the network filesystem.\nsqlite_home = "{display_path(expand(sqlite_home))}"\n'
|
|
976
1024
|
write_atomic(d / "codex-config.toml", base)
|
|
977
|
-
|
|
978
|
-
write_atomic(d / "codex-models.json", json.dumps(build_codex_catalog(d, catalog), indent=2) + "\n")
|
|
1025
|
+
write_atomic(d / "codex-models.json", json.dumps(build_codex_catalog(d, catalog), indent=2) + "\n")
|
|
979
1026
|
write_atomic(d / "codex-auth.json", json.dumps({"OPENAI_API_KEY": key}, indent=2) + "\n")
|
|
980
1027
|
if clients in ("both", "claude"):
|
|
981
1028
|
env = {"ANTHROPIC_BASE_URL": claude_endpoint, "ANTHROPIC_AUTH_TOKEN": key}
|
|
@@ -986,6 +1033,16 @@ def cmd_add(_):
|
|
|
986
1033
|
"provider": provider, "default_model": default}, indent=2) + "\n")
|
|
987
1034
|
print(f"\nCreated profile: {name}")
|
|
988
1035
|
print(f"Activate it with: ai-switch use {name} (choose between {len(chosen)} model(s))")
|
|
1036
|
+
if sqlite_home:
|
|
1037
|
+
# Codex reads this from the environment only: a `sqlite_home` key in
|
|
1038
|
+
# config.toml is accepted but ignored (verified with `codex doctor`).
|
|
1039
|
+
target = expand(sqlite_home)
|
|
1040
|
+
try:
|
|
1041
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
1042
|
+
except OSError:
|
|
1043
|
+
pass
|
|
1044
|
+
print(f"\nKeep Codex's runtime databases off {fstype_for(CODEX_DIR)} by exporting this before "
|
|
1045
|
+
f"starting codex (add it to ~/.bashrc):\n export CODEX_SQLITE_HOME={sqlite_home}")
|
|
989
1046
|
return 0
|
|
990
1047
|
|
|
991
1048
|
|
|
@@ -1099,21 +1156,47 @@ def running_agents():
|
|
|
1099
1156
|
return names
|
|
1100
1157
|
|
|
1101
1158
|
|
|
1159
|
+
def running_agents_summary(limit=4):
|
|
1160
|
+
"""A short, readable list (this file server runs dozens of agents)."""
|
|
1161
|
+
running = running_agents()
|
|
1162
|
+
if len(running) <= limit:
|
|
1163
|
+
return ", ".join(running)
|
|
1164
|
+
return ", ".join(running[:limit]) + f" and {len(running) - limit} more"
|
|
1165
|
+
|
|
1166
|
+
|
|
1102
1167
|
def check_report():
|
|
1103
1168
|
checks = []
|
|
1104
1169
|
|
|
1105
1170
|
def add(status, title, detail, hint=""):
|
|
1106
1171
|
checks.append({"status": status, "title": title, "detail": detail, "hint": hint})
|
|
1107
1172
|
|
|
1173
|
+
sqlite_home = os.environ.get("CODEX_SQLITE_HOME", "")
|
|
1174
|
+
sqlite_home_fs = fstype_for(expand(sqlite_home)) if sqlite_home else ""
|
|
1108
1175
|
for label, path in (("CODEX_HOME", CODEX_DIR), ("Claude config", CLAUDE_DIR), ("ai-switch home", ROOT)):
|
|
1109
1176
|
fs_type = fstype_for(path) or "unknown"
|
|
1110
1177
|
if fs_type in NETWORK_FS:
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
f"
|
|
1178
|
+
moved = path == CODEX_DIR and sqlite_home and sqlite_home_fs not in NETWORK_FS
|
|
1179
|
+
detail = f"{path} ({fs_type})"
|
|
1180
|
+
if path == CODEX_DIR and sqlite_home:
|
|
1181
|
+
detail += f", runtime databases in {sqlite_home} ({sqlite_home_fs or 'unknown'})"
|
|
1182
|
+
if moved:
|
|
1183
|
+
add("warn", f"{label} is on {fs_type}, runtime databases are not", detail,
|
|
1184
|
+
"Codex keeps state/log/goal databases in CODEX_SQLITE_HOME now; only history.jsonl and "
|
|
1185
|
+
"the rollout files stay on the network share.")
|
|
1186
|
+
else:
|
|
1187
|
+
hint = ("SQLite databases (Codex runtime state, goals, memories, logs) can be corrupted on network "
|
|
1188
|
+
"filesystems, which makes sessions disappear. Codex only honours the environment variable "
|
|
1189
|
+
"(a sqlite_home config key is ignored):\n"
|
|
1190
|
+
" export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER")
|
|
1191
|
+
add("warn", f"{label} is on {fs_type}", detail, hint if path == CODEX_DIR else "")
|
|
1115
1192
|
else:
|
|
1116
1193
|
add("ok", f"{label} filesystem", f"{path} ({fs_type})")
|
|
1194
|
+
if sqlite_home:
|
|
1195
|
+
if sqlite_home_fs in NETWORK_FS:
|
|
1196
|
+
add("warn", "CODEX_SQLITE_HOME is also on a network filesystem", f"{sqlite_home} ({sqlite_home_fs})",
|
|
1197
|
+
"Pick a directory on a local disk, otherwise the runtime databases keep getting corrupted.")
|
|
1198
|
+
else:
|
|
1199
|
+
add("ok", "CODEX_SQLITE_HOME", f"{sqlite_home} ({sqlite_home_fs or 'unknown'})")
|
|
1117
1200
|
|
|
1118
1201
|
text = read_text(CODEX) or ""
|
|
1119
1202
|
persisted = section_value(text, "history", "persistence")
|
|
@@ -1132,9 +1215,19 @@ def check_report():
|
|
|
1132
1215
|
add("fail", "Codex model catalogue is missing", f"{catalog_path} does not exist",
|
|
1133
1216
|
"Re-run 'ai-switch use <profile>' to rewrite it.")
|
|
1134
1217
|
elif slugs and model and model not in slugs:
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1218
|
+
open_ended = False
|
|
1219
|
+
try:
|
|
1220
|
+
open_ended = bool(json.loads((PROFILES / active_profile() / "models.json").read_text()).get("open_ended"))
|
|
1221
|
+
except (OSError, ValueError):
|
|
1222
|
+
pass
|
|
1223
|
+
detail = f"model = {model}, catalogue has {', '.join(str(s) for s in slugs)}"
|
|
1224
|
+
if open_ended:
|
|
1225
|
+
add("warn", "Codex model catalogue does not list the configured model", detail,
|
|
1226
|
+
"The active profile describes an open-ended endpoint, so any model name is allowed; "
|
|
1227
|
+
"'ai-switch use NAME -m MODEL' adds it to the catalogue.")
|
|
1228
|
+
else:
|
|
1229
|
+
add("fail", "Codex model catalogue does not contain the configured model", detail,
|
|
1230
|
+
"Codex refuses to resume sessions whose model is unknown. Re-run 'ai-switch use <profile>'.")
|
|
1138
1231
|
elif slugs:
|
|
1139
1232
|
add("ok", "Codex model catalogue", f"{len(slugs)} model(s): {', '.join(str(s) for s in slugs)}")
|
|
1140
1233
|
else:
|
|
@@ -1197,15 +1290,30 @@ def check_report():
|
|
|
1197
1290
|
f"{len(transcripts)} transcript(s) under {display_path(projects)}")
|
|
1198
1291
|
|
|
1199
1292
|
state = load_state()
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1293
|
+
active = active_profile()
|
|
1294
|
+
recorded = (state.get("models") or {}).get(active) if isinstance(state.get("models"), dict) else None
|
|
1295
|
+
add("ok", "Active profile", f"{active or '(none)'}" + (f", model {recorded}" if recorded else ""))
|
|
1296
|
+
if recorded:
|
|
1297
|
+
# A running agent rewrites its own config on exit, so the live files can
|
|
1298
|
+
# silently diverge from what 'ai-switch use' activated.
|
|
1299
|
+
if model and model != recorded:
|
|
1300
|
+
add("warn", "Codex is not using the model ai-switch activated",
|
|
1301
|
+
f"config.toml has model = {model}, ai-switch activated {recorded}",
|
|
1302
|
+
"A running codex session rewrites config.toml when it exits, so a /model choice made "
|
|
1303
|
+
f"inside codex wins. Close codex and re-run 'ai-switch use {active} -m {recorded}', or use "
|
|
1304
|
+
"--pin so the in-app picker cannot switch away.")
|
|
1305
|
+
claude_model = str(settings.get("model") or "")
|
|
1306
|
+
stem = lambda text: re.split(r"[\[/]", text)[0]
|
|
1307
|
+
if claude_model and stem(claude_model) != stem(str(recorded)):
|
|
1308
|
+
add("warn", "Claude Code is not using the model ai-switch activated",
|
|
1309
|
+
f"settings.json has model = {claude_model}, ai-switch activated {recorded}",
|
|
1310
|
+
f"Re-run 'ai-switch use {active} -m {recorded}' to bring both agents back in sync.")
|
|
1203
1311
|
profiles = [p.name for p in sorted(PROFILES.iterdir()) if p.is_dir()] if PROFILES.exists() else []
|
|
1204
1312
|
add("ok" if profiles else "note", "Profiles", ", ".join(profiles) or "none yet")
|
|
1205
1313
|
|
|
1206
1314
|
running = running_agents()
|
|
1207
1315
|
if running:
|
|
1208
|
-
add("warn", "Agents are running", "
|
|
1316
|
+
add("warn", "Agents are running", running_agents_summary() + f" ({len(running)} process(es))",
|
|
1209
1317
|
"A running agent rewrites its configuration on exit and may overwrite the switch; restart it after switching.")
|
|
1210
1318
|
else:
|
|
1211
1319
|
add("ok", "No agent processes running", "config.toml / settings.json are not being rewritten")
|
|
@@ -1289,8 +1397,12 @@ files inside a profile are switched. Restart claude/codex after switching."""
|
|
|
1289
1397
|
|
|
1290
1398
|
p = sub.add_parser("use", help="activate a profile (optionally choosing a model)")
|
|
1291
1399
|
p.add_argument("name")
|
|
1292
|
-
p.add_argument("-m", "--model", help="model
|
|
1293
|
-
p.add_argument("
|
|
1400
|
+
p.add_argument("-m", "--model", help="set the default model for new sessions (name, unique prefix, or index)")
|
|
1401
|
+
p.add_argument("--choose", action="store_true",
|
|
1402
|
+
help="ask which model the new sessions should start with (the others stay selectable in /model)")
|
|
1403
|
+
p.add_argument("--pin", action="store_true",
|
|
1404
|
+
help="publish only this model, so Codex's own picker cannot switch to another one")
|
|
1405
|
+
p.add_argument("-y", "--yes", action="store_true", help="accepted for compatibility; use never prompts by default")
|
|
1294
1406
|
p.add_argument("-n", "--dry-run", action="store_true", help="show what would change")
|
|
1295
1407
|
p.add_argument("--no-check", action="store_true", help="skip the session-history health check")
|
|
1296
1408
|
p.set_defaults(fn=cmd_use)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-switch-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: One-command provider and model switching for Claude Code and Codex, with session-history checks
|
|
5
5
|
Author: ZidongS
|
|
6
6
|
License: MIT
|
|
@@ -78,13 +78,26 @@ Select `glm` or `deepseek`, enter the API key, and activate the generated config
|
|
|
78
78
|
ai-switch use glm
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
`use`
|
|
81
|
+
`use` activates the profile and does **not** ask anything: the profile's whole model list is published, so you
|
|
82
|
+
pick the model inside the agent with `/model` (Codex) or its Opus/Sonnet/Haiku categories (Claude Code). The
|
|
83
|
+
default for new sessions is the one you last used, or the profile default. Add `--choose` if you want to be
|
|
84
|
+
asked, or `-m/--model` to set the default directly.
|
|
82
85
|
|
|
83
86
|
```text
|
|
84
|
-
|
|
87
|
+
Active profile: glm
|
|
88
|
+
Default model: glm-5.3 (new sessions)
|
|
89
|
+
Published models: 3 - switch any time with /model inside codex (Claude Code uses its
|
|
90
|
+
Opus/Sonnet/Haiku mappings), or change the default with -m/--model.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With `--choose` you get the picker instead:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
This profile provides 3 models:
|
|
85
97
|
1) glm-5.3 GLM-5.3 flagship (1M context) [default]
|
|
86
|
-
2) glm-5.3-flash GLM-5.3
|
|
87
|
-
|
|
98
|
+
2) glm-5.3-flash GLM-5.3 Flash (fast, text+image, 1M context)
|
|
99
|
+
3) glm-5-turbo GLM-5 Turbo (agent-optimized, 200K context)
|
|
100
|
+
Select model [1-3, Enter=glm-5.3, q=cancel]:
|
|
88
101
|
```
|
|
89
102
|
|
|
90
103
|
For scripts and servers, choose without prompting (name, unique prefix, or index):
|
|
@@ -93,8 +106,11 @@ For scripts and servers, choose without prompting (name, unique prefix, or index
|
|
|
93
106
|
ai-switch use glm --model glm-5.3-flash
|
|
94
107
|
ai-switch use glm -m 2 -y
|
|
95
108
|
ai-switch use glm --dry-run # show what would change
|
|
109
|
+
ai-switch use glm -m glm-5.3 --pin # publish only this model (see below)
|
|
96
110
|
```
|
|
97
111
|
|
|
112
|
+
Selecting a model sets the **default for new sessions** in both agents, and the profile's other models stay in the catalogue so you can still switch inside the agent. That means an in-app `/model` choice is a per-session override — and Codex writes its own choice back to `~/.codex/config.toml` when it exits, so the two agents can drift apart without anything else noticing; `ai-switch doctor` reports exactly that. If you want an activation to be exclusive instead, add `--pin`: the published catalogue then holds only the selected model, so Codex's own picker has nothing else to switch to (the next `ai-switch use` without `--pin` restores the full menu).
|
|
113
|
+
|
|
98
114
|
Edit `~/.codex/config.toml` and `~/.claude/settings.json` for another provider, then save that configuration as a second profile:
|
|
99
115
|
|
|
100
116
|
```bash
|
|
@@ -133,7 +149,7 @@ For every model a profile records the Codex catalogue entry and the Claude Code
|
|
|
133
149
|
* Codex: the top-level `model` (and `model_reasoning_effort`), plus a `~/.codex/models.json` catalogue that lists **every** model of the profile, which is what makes Codex's own `/model` picker show them.
|
|
134
150
|
* Claude Code: the default model in `settings.json` plus the Opus/Sonnet/Haiku mappings. The presets map the three categories to *different* provider models where they exist, and they never set `ANTHROPIC_MODEL`, because a pinned environment model overrides your selection and makes `/model` do nothing.
|
|
135
151
|
|
|
136
|
-
The `glm` and `deepseek` presets ship multi-model lists. A custom (OpenAI-compatible) profile is open-ended: it
|
|
152
|
+
The `glm` and `deepseek` presets ship multi-model lists. A custom (OpenAI-compatible) profile is open-ended as well: when you create it you can name **several models at once, separated by spaces or commas** (for example `DeepSeek-V4.1-Flash Kimi-K3 Qwen3.8-Max`), and those become the profile's catalogue — both agents can then pick them. Any other name the endpoint serves still works with `ai-switch use NAME --model <name>`, and it is added to the catalogue when you use it.
|
|
137
153
|
|
|
138
154
|
### When a provider adds or renames a model
|
|
139
155
|
|
|
@@ -180,7 +196,7 @@ ai-switch doctor --fix # quarantine damaged Codex runtime databases
|
|
|
180
196
|
export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER # a local, persistent directory
|
|
181
197
|
```
|
|
182
198
|
|
|
183
|
-
`
|
|
199
|
+
Codex only honours the **environment variable**: a `sqlite_home` key in `config.toml` is parsed but ignored (checked with `codex doctor`). `ai-switch add` asks for a local directory when it detects a network filesystem, creates it and prints the export line to put in your shell profile; `ai-switch doctor` reports the variable's status.
|
|
184
200
|
* **A stale model catalogue.** A catalogue left behind by the previous provider hides the new provider's models from Codex and makes sessions that used them unresumable. ai-switch removes that leftover file (only if it wrote it itself, and it backs it up first), and `doctor` verifies the configured model exists in the catalogue.
|
|
185
201
|
* **Disabled history persistence** (`[history] persistence = "none"`), a Claude Code model pinned by `ANTHROPIC_MODEL`, and `claude`/`codex` processes that are running while you switch and will rewrite their configuration on exit.
|
|
186
202
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-switch-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "One-command provider and model switching for Claude Code and Codex, with session-history checks"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|