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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-switch-cli
3
- Version: 0.2.2
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` lists the models the profile offers and remembers your choice:
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
- This profile provides 2 models:
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 flash (fast, 1M context)
87
- Select model [1-2, Enter=glm-5.3, q=cancel]:
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 does not publish a catalogue, and `ai-switch use NAME --model anything` accepts any model name the endpoint serves.
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
- `ai-switch add` offers to write `sqlite_home` into the generated Codex configuration when it detects a network filesystem.
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` lists the models the profile offers and remembers your choice:
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
- This profile provides 2 models:
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 flash (fast, 1M context)
73
- Select model [1-2, Enter=glm-5.3, q=cancel]:
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 does not publish a catalogue, and `ai-switch use NAME --model anything` accepts any model name the endpoint serves.
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
- `ai-switch add` offers to write `sqlite_home` into the generated Codex configuration when it detects a network filesystem.
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.2.2"
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
- env.update(entry.get("claude", {}).get("env") or {})
271
- out["model"] = entry["claude"].get("model") or entry["slug"]
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
- try:
507
- raw = json.loads((d / "codex-models.json").read_text())
508
- for entry in (raw.get("models") if isinstance(raw, dict) else raw) or []:
509
- if isinstance(entry, dict) and entry.get("slug"):
510
- entries[entry["slug"]] = entry
511
- except (OSError, ValueError, AttributeError):
512
- pass
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
- # Open-ended providers (custom endpoints) are not pinned to a catalogue;
535
- # everything else publishes every selectable model so the picker inside
536
- # Codex lists them all.
537
- if catalog and (not catalog.get("open_ended") or (d / "codex-models.json").exists()):
538
- codex_catalog = build_codex_catalog(d, catalog)
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
- running = running_agents()
661
- if running:
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 = args.model or last_model(name) or (catalog or {}).get("default")
676
- if catalog and args.model and catalog.get("open_ended"):
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
- else:
683
- slug = pick_model(catalog, args.model, fallback, assume_yes=args.yes)
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"\nActive model: {slug}" if slug else ""))
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 = [part.strip() for part in wanted.split(",") if part.strip()]
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
- slug = [s for s in by_slug if s.lower().startswith(token.lower())]
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
- slug = _ask("Model name")
935
- if not slug:
936
- raise ValueError("a model name is required")
937
- chosen = [{"slug": slug, "label": "custom model", "reasoning": "",
938
- "codex": codex_entry(slug, "custom model", ("text",), 0, 1048576, "high"),
939
- "claude": claude_mapping(slug)}]
940
- default, common_env = slug, {}
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=leave default)", suggested)
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 custom endpoint usually serves models we cannot know about, so no
969
- # catalogue is written: Codex keeps listing the models the endpoint has.
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"\n\n[model_providers.custom]\nname = "custom"\n'
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
- if preset:
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
- add("warn", f"{label} is on {fs_type}", f"{path} ({fs_type})",
1112
- "SQLite databases (Codex runtime state, goals, memories, logs) can be corrupted on network "
1113
- "filesystems, which makes sessions disappear. Point CODEX_SQLITE_HOME at a local disk, e.g. "
1114
- f"export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER")
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
- add("fail", "Codex model catalogue does not contain the configured model",
1136
- f"model = {model}, catalogue has {', '.join(str(s) for s in slugs)}",
1137
- "Codex refuses to resume sessions whose model is unknown. Re-run 'ai-switch use <profile>'.")
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
- add("ok", "Active profile", f"{active_profile() or '(none)'}"
1201
- + (f", model {state.get('models', {}).get(active_profile())}" if isinstance(state.get("models"), dict)
1202
- and state.get("models", {}).get(active_profile()) else ""))
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", ", ".join(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 to activate (name, unique prefix, or 1-based index)")
1293
- p.add_argument("-y", "--yes", action="store_true", help="never prompt; use the default model")
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.2.2
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` lists the models the profile offers and remembers your choice:
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
- This profile provides 2 models:
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 flash (fast, 1M context)
87
- Select model [1-2, Enter=glm-5.3, q=cancel]:
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 does not publish a catalogue, and `ai-switch use NAME --model anything` accepts any model name the endpoint serves.
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
- `ai-switch add` offers to write `sqlite_home` into the generated Codex configuration when it detects a network filesystem.
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.2.2"
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