ai-switch-cli 0.2.2__tar.gz → 0.3.0__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.0
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
@@ -133,7 +133,7 @@ For every model a profile records the Codex catalogue entry and the Claude Code
133
133
  * 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
134
  * 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
135
 
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.
136
+ 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
137
 
138
138
  ### When a provider adds or renames a model
139
139
 
@@ -180,7 +180,7 @@ ai-switch doctor --fix # quarantine damaged Codex runtime databases
180
180
  export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER # a local, persistent directory
181
181
  ```
182
182
 
183
- `ai-switch add` offers to write `sqlite_home` into the generated Codex configuration when it detects a network filesystem.
183
+ 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
184
  * **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
185
  * **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
186
 
@@ -119,7 +119,7 @@ For every model a profile records the Codex catalogue entry and the Claude Code
119
119
  * 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
120
  * 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
121
 
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.
122
+ 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
123
 
124
124
  ### When a provider adds or renames a model
125
125
 
@@ -166,7 +166,7 @@ ai-switch doctor --fix # quarantine damaged Codex runtime databases
166
166
  export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER # a local, persistent directory
167
167
  ```
168
168
 
169
- `ai-switch add` offers to write `sqlite_home` into the generated Codex configuration when it detects a network filesystem.
169
+ 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
170
  * **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
171
  * **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
172
 
@@ -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.0"
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
@@ -530,11 +547,17 @@ def compute_changes(d, slug):
530
547
 
531
548
  text = read_text(d / "codex-config.toml")
532
549
  codex_catalog = None
550
+ # A model that is used but not listed (open-ended custom endpoint, or a legacy
551
+ # profile without a model list) gets a generated entry so that both agents are
552
+ # pointed at the requested model instead of falling back to the snapshot.
553
+ if slug and entry is None:
554
+ entry = adhoc_model(slug)
555
+ if catalog and catalog.get("open_ended"):
556
+ catalog = dict(catalog, models=catalog["models"] + [entry])
533
557
  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()):
558
+ # Every profile publishes its models, so the picker inside Codex lists
559
+ # them too.
560
+ if catalog:
538
561
  codex_catalog = build_codex_catalog(d, catalog)
539
562
  if codex_catalog is not None:
540
563
  catalog_path = catalog_target(text, CODEX_MODELS)
@@ -657,9 +680,8 @@ def codex_health_warnings(check_codex=True):
657
680
  if status != "ok":
658
681
  warnings.append(f"Codex runtime DB {db.name} is unusable ({detail}) - sessions may be missing "
659
682
  "from the resume picker; run 'ai-switch doctor --fix'")
660
- running = running_agents()
661
- if running:
662
- warnings.append("running now: " + ", ".join(running) +
683
+ if running_agents():
684
+ warnings.append("running now: " + running_agents_summary() +
663
685
  " - restart them after switching, a running agent rewrites its config on exit")
664
686
  return warnings
665
687
 
@@ -912,15 +934,17 @@ def cmd_add(_):
912
934
  if preset:
913
935
  chosen = preset["models"]
914
936
  print(f"{provider} preset offers: " + ", ".join(m["slug"] for m in chosen))
915
- wanted = _ask("Models to include (comma separated, Enter=all)", "all")
937
+ wanted = _ask("Models to include (space or comma separated, Enter=all)", "all")
916
938
  if wanted.lower() not in ("all", ""):
917
- names = [part.strip() for part in wanted.split(",") if part.strip()]
939
+ names = split_models(wanted)
918
940
  by_slug = {m["slug"]: m for m in chosen}
919
941
  chosen = []
920
942
  for token in names:
921
- slug = [s for s in by_slug if s.lower().startswith(token.lower())]
943
+ exact = [s for s in by_slug if s.lower() == token.lower()]
944
+ slug = exact or [s for s in by_slug if s.lower().startswith(token.lower())]
922
945
  if len(slug) != 1:
923
- raise ValueError(f"unknown model '{token}'")
946
+ raise ValueError(f"unknown or ambiguous model '{token}'"
947
+ f" (choose one of: {', '.join(by_slug)})")
924
948
  chosen.append(by_slug[slug[0]])
925
949
  default = _ask("Default model", chosen[0]["slug"])
926
950
  default = resolve_model({"models": chosen}, default)
@@ -931,13 +955,21 @@ def cmd_add(_):
931
955
  if not endpoint:
932
956
  raise ValueError("an endpoint URL is required")
933
957
  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, {}
958
+ names = split_models(_ask("Model name(s) (space or comma separated, e.g. 'Big-Model Small-Model')"))
959
+ if not names:
960
+ raise ValueError("at least one model name is required")
961
+ chosen = []
962
+ for index, name in enumerate(names):
963
+ model = adhoc_model(name, index)
964
+ if len(names) > 1:
965
+ model["label"] = f"custom model (use --model {name})"
966
+ chosen.append(model)
967
+ default = chosen[0]["slug"]
968
+ if len(chosen) > 1:
969
+ print("This endpoint is open ended: any name works with "
970
+ f"'ai-switch use <profile> --model <name>', and the ones listed above appear in the picker.")
971
+ default = resolve_model({"models": chosen}, _ask("Default model", chosen[0]["slug"]))
972
+ common_env = {}
941
973
  name = _ask("Profile name")
942
974
  if not name:
943
975
  raise ValueError("a profile name is required")
@@ -950,7 +982,7 @@ def cmd_add(_):
950
982
  suggested = f"/var/tmp/codex-sqlite-{os.environ.get('USER', 'user')}"
951
983
  print(f"\n{CODEX_DIR} is on a network filesystem ({fstype_for(CODEX_DIR)}); SQLite runtime databases "
952
984
  "can be corrupted there, which makes sessions disappear.")
953
- sqlite_home = _ask("Local directory for Codex runtime databases (Enter=leave default)", suggested)
985
+ sqlite_home = _ask("Local directory for Codex runtime databases (Enter=skip)", suggested)
954
986
  d = profile(name)
955
987
  blank_profile_dir(d)
956
988
  catalog = {"version": 2, "provider": provider, "default": default, "models": chosen,
@@ -965,17 +997,15 @@ def cmd_add(_):
965
997
  .replace("@CATALOG@", display_path(catalog_path))
966
998
  .replace("@ENDPOINT@", preset["endpoint"]).replace("@KEY@", key))
967
999
  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.
1000
+ # A gateway often serves dozens of models; the catalogue holds the ones
1001
+ # you named, and ``use --model <other>`` extends it on demand.
970
1002
  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'
1003
+ f'forced_login_method = "api"\nmodel_catalog_json = "{display_path(CODEX_MODELS)}"\n\n'
1004
+ f'[model_providers.custom]\nname = "custom"\n'
972
1005
  f'base_url = "{endpoint}"\nwire_api = "responses"\n'
973
1006
  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
1007
  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")
1008
+ write_atomic(d / "codex-models.json", json.dumps(build_codex_catalog(d, catalog), indent=2) + "\n")
979
1009
  write_atomic(d / "codex-auth.json", json.dumps({"OPENAI_API_KEY": key}, indent=2) + "\n")
980
1010
  if clients in ("both", "claude"):
981
1011
  env = {"ANTHROPIC_BASE_URL": claude_endpoint, "ANTHROPIC_AUTH_TOKEN": key}
@@ -986,6 +1016,16 @@ def cmd_add(_):
986
1016
  "provider": provider, "default_model": default}, indent=2) + "\n")
987
1017
  print(f"\nCreated profile: {name}")
988
1018
  print(f"Activate it with: ai-switch use {name} (choose between {len(chosen)} model(s))")
1019
+ if sqlite_home:
1020
+ # Codex reads this from the environment only: a `sqlite_home` key in
1021
+ # config.toml is accepted but ignored (verified with `codex doctor`).
1022
+ target = expand(sqlite_home)
1023
+ try:
1024
+ target.mkdir(parents=True, exist_ok=True)
1025
+ except OSError:
1026
+ pass
1027
+ print(f"\nKeep Codex's runtime databases off {fstype_for(CODEX_DIR)} by exporting this before "
1028
+ f"starting codex (add it to ~/.bashrc):\n export CODEX_SQLITE_HOME={sqlite_home}")
989
1029
  return 0
990
1030
 
991
1031
 
@@ -1099,21 +1139,47 @@ def running_agents():
1099
1139
  return names
1100
1140
 
1101
1141
 
1142
+ def running_agents_summary(limit=4):
1143
+ """A short, readable list (this file server runs dozens of agents)."""
1144
+ running = running_agents()
1145
+ if len(running) <= limit:
1146
+ return ", ".join(running)
1147
+ return ", ".join(running[:limit]) + f" and {len(running) - limit} more"
1148
+
1149
+
1102
1150
  def check_report():
1103
1151
  checks = []
1104
1152
 
1105
1153
  def add(status, title, detail, hint=""):
1106
1154
  checks.append({"status": status, "title": title, "detail": detail, "hint": hint})
1107
1155
 
1156
+ sqlite_home = os.environ.get("CODEX_SQLITE_HOME", "")
1157
+ sqlite_home_fs = fstype_for(expand(sqlite_home)) if sqlite_home else ""
1108
1158
  for label, path in (("CODEX_HOME", CODEX_DIR), ("Claude config", CLAUDE_DIR), ("ai-switch home", ROOT)):
1109
1159
  fs_type = fstype_for(path) or "unknown"
1110
1160
  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")
1161
+ moved = path == CODEX_DIR and sqlite_home and sqlite_home_fs not in NETWORK_FS
1162
+ detail = f"{path} ({fs_type})"
1163
+ if path == CODEX_DIR and sqlite_home:
1164
+ detail += f", runtime databases in {sqlite_home} ({sqlite_home_fs or 'unknown'})"
1165
+ if moved:
1166
+ add("warn", f"{label} is on {fs_type}, runtime databases are not", detail,
1167
+ "Codex keeps state/log/goal databases in CODEX_SQLITE_HOME now; only history.jsonl and "
1168
+ "the rollout files stay on the network share.")
1169
+ else:
1170
+ hint = ("SQLite databases (Codex runtime state, goals, memories, logs) can be corrupted on network "
1171
+ "filesystems, which makes sessions disappear. Codex only honours the environment variable "
1172
+ "(a sqlite_home config key is ignored):\n"
1173
+ " export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER")
1174
+ add("warn", f"{label} is on {fs_type}", detail, hint if path == CODEX_DIR else "")
1115
1175
  else:
1116
1176
  add("ok", f"{label} filesystem", f"{path} ({fs_type})")
1177
+ if sqlite_home:
1178
+ if sqlite_home_fs in NETWORK_FS:
1179
+ add("warn", "CODEX_SQLITE_HOME is also on a network filesystem", f"{sqlite_home} ({sqlite_home_fs})",
1180
+ "Pick a directory on a local disk, otherwise the runtime databases keep getting corrupted.")
1181
+ else:
1182
+ add("ok", "CODEX_SQLITE_HOME", f"{sqlite_home} ({sqlite_home_fs or 'unknown'})")
1117
1183
 
1118
1184
  text = read_text(CODEX) or ""
1119
1185
  persisted = section_value(text, "history", "persistence")
@@ -1132,9 +1198,19 @@ def check_report():
1132
1198
  add("fail", "Codex model catalogue is missing", f"{catalog_path} does not exist",
1133
1199
  "Re-run 'ai-switch use <profile>' to rewrite it.")
1134
1200
  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>'.")
1201
+ open_ended = False
1202
+ try:
1203
+ open_ended = bool(json.loads((PROFILES / active_profile() / "models.json").read_text()).get("open_ended"))
1204
+ except (OSError, ValueError):
1205
+ pass
1206
+ detail = f"model = {model}, catalogue has {', '.join(str(s) for s in slugs)}"
1207
+ if open_ended:
1208
+ add("warn", "Codex model catalogue does not list the configured model", detail,
1209
+ "The active profile describes an open-ended endpoint, so any model name is allowed; "
1210
+ "'ai-switch use NAME -m MODEL' adds it to the catalogue.")
1211
+ else:
1212
+ add("fail", "Codex model catalogue does not contain the configured model", detail,
1213
+ "Codex refuses to resume sessions whose model is unknown. Re-run 'ai-switch use <profile>'.")
1138
1214
  elif slugs:
1139
1215
  add("ok", "Codex model catalogue", f"{len(slugs)} model(s): {', '.join(str(s) for s in slugs)}")
1140
1216
  else:
@@ -1205,7 +1281,7 @@ def check_report():
1205
1281
 
1206
1282
  running = running_agents()
1207
1283
  if running:
1208
- add("warn", "Agents are running", ", ".join(running),
1284
+ add("warn", "Agents are running", running_agents_summary() + f" ({len(running)} process(es))",
1209
1285
  "A running agent rewrites its configuration on exit and may overwrite the switch; restart it after switching.")
1210
1286
  else:
1211
1287
  add("ok", "No agent processes running", "config.toml / settings.json are not being rewritten")
@@ -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.0
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
@@ -133,7 +133,7 @@ For every model a profile records the Codex catalogue entry and the Claude Code
133
133
  * 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
134
  * 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
135
 
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.
136
+ 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
137
 
138
138
  ### When a provider adds or renames a model
139
139
 
@@ -180,7 +180,7 @@ ai-switch doctor --fix # quarantine damaged Codex runtime databases
180
180
  export CODEX_SQLITE_HOME=/var/tmp/codex-sqlite-$USER # a local, persistent directory
181
181
  ```
182
182
 
183
- `ai-switch add` offers to write `sqlite_home` into the generated Codex configuration when it detects a network filesystem.
183
+ 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
184
  * **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
185
  * **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
186
 
@@ -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.0"
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