wechatbridge-cli 1.4.2__tar.gz → 1.4.3__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.
Files changed (23) hide show
  1. {wechatbridge_cli-1.4.2/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.3}/PKG-INFO +3 -3
  2. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/README.md +2 -2
  3. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/tests/test_codex.py +132 -5
  4. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/tests/test_hardening.py +12 -0
  5. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/__init__.py +1 -1
  6. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/codex.py +242 -22
  7. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
  8. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/LICENSE +0 -0
  9. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/pyproject.toml +0 -0
  10. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/setup.cfg +0 -0
  11. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/__main__.py +0 -0
  12. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/agy.py +0 -0
  13. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/config.py +0 -0
  14. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/grok.py +0 -0
  15. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/ilink.py +0 -0
  16. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/main.py +0 -0
  17. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/runner_common.py +0 -0
  18. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge/update_check.py +0 -0
  19. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  20. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  21. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  22. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge_cli.egg-info/requires.txt +0 -0
  23. {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.3}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -250,8 +250,8 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
250
250
  | `/help` | list supported commands for the active backend |
251
251
  | `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
252
252
  | `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
253
- | `/model <name>` | set model (validation differs by backend: agy/grok check against the live model list; codex stores the name without verifying, so a typo only surfaces on the next run; see `/models`) |
254
- | `/models` | list models — agy/grok query the live CLI; codex returns a built-in reference list, not your actual account's models |
253
+ | `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
254
+ | `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
255
255
  | `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
256
256
  | `/planning` | set planning mode (**on only** — not a toggle) |
257
257
  | `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |
@@ -219,8 +219,8 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
219
219
  | `/help` | list supported commands for the active backend |
220
220
  | `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
221
221
  | `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
222
- | `/model <name>` | set model (validation differs by backend: agy/grok check against the live model list; codex stores the name without verifying, so a typo only surfaces on the next run; see `/models`) |
223
- | `/models` | list models — agy/grok query the live CLI; codex returns a built-in reference list, not your actual account's models |
222
+ | `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
223
+ | `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
224
224
  | `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
225
225
  | `/planning` | set planning mode (**on only** — not a toggle) |
226
226
  | `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import os
7
8
  import sys
8
9
  import tempfile
9
10
  import time
10
11
  import unittest
11
12
  from unittest import mock
13
+ from unittest.mock import AsyncMock
12
14
 
13
15
  _HERE = os.path.dirname(os.path.abspath(__file__))
14
16
  _FIXTURES = os.path.join(_HERE, "fixtures")
@@ -519,23 +521,148 @@ class TestCodexSlash(unittest.IsolatedAsyncioTestCase):
519
521
  self.assertEqual(_read_codex_thread_id(sd), "")
520
522
  self.assertFalse(os.path.exists(os.path.join(sd, ".initialized.codex")))
521
523
 
522
- async def test_model_unvalidated_stores(self):
524
+ async def test_model_validated_stores(self):
525
+ """Known model in live list → switch succeeds and prefs are written."""
523
526
  from wechatbridge.codex import handle_codex_slash_command
524
527
  from wechatbridge.runner_common import load_prefs
525
- reply = await handle_codex_slash_command("/model gpt-5.1-codex", "u-model")
528
+ from wechatbridge import codex as codex_mod
529
+
530
+ with mock.patch.object(
531
+ codex_mod,
532
+ "_fetch_codex_model_list",
533
+ new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1", "gpt-5"]),
534
+ ):
535
+ reply = await handle_codex_slash_command("/model gpt-5.1-codex", "u-model")
526
536
  self.assertIn("模型已切换", reply)
527
- self.assertIn("未校验", reply)
537
+ self.assertNotIn("未校验", reply)
528
538
  self.assertEqual(load_prefs("u-model")["model"], "gpt-5.1-codex")
529
539
 
540
+ async def test_model_unknown_rejects(self):
541
+ """Unknown model → refuse and prefs unchanged."""
542
+ from wechatbridge.codex import handle_codex_slash_command
543
+ from wechatbridge.runner_common import load_prefs, save_prefs
544
+ from wechatbridge import codex as codex_mod
545
+
546
+ uid = "u-model-unknown"
547
+ save_prefs(uid, {"model": "gpt-5.1", "effort": "", "mode": ""})
548
+ with mock.patch.object(
549
+ codex_mod,
550
+ "_fetch_codex_model_list",
551
+ new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1"]),
552
+ ):
553
+ reply = await handle_codex_slash_command("/model not-a-real-model", uid)
554
+ self.assertIn("模型不存在", reply)
555
+ self.assertNotIn("模型已切换", reply)
556
+ self.assertEqual(load_prefs(uid)["model"], "gpt-5.1")
557
+
558
+ async def test_model_list_fail_rejects(self):
559
+ """List fetch failure → refuse and do not write prefs (strict)."""
560
+ from wechatbridge.codex import handle_codex_slash_command
561
+ from wechatbridge.runner_common import load_prefs, save_prefs
562
+ from wechatbridge import codex as codex_mod
563
+
564
+ uid = "u-model-listfail"
565
+ save_prefs(uid, {"model": "gpt-5", "effort": "", "mode": ""})
566
+ with mock.patch.object(
567
+ codex_mod,
568
+ "_fetch_codex_model_list",
569
+ new=AsyncMock(return_value=None),
570
+ ):
571
+ reply = await handle_codex_slash_command("/model gpt-5.1-codex", uid)
572
+ self.assertIn("无法获取模型列表", reply)
573
+ self.assertNotIn("模型已切换", reply)
574
+ self.assertEqual(load_prefs(uid)["model"], "gpt-5")
575
+
576
+ async def test_model_prefix_match(self):
577
+ """Prefix match takes the first hit (same as agy)."""
578
+ from wechatbridge.codex import handle_codex_slash_command
579
+ from wechatbridge.runner_common import load_prefs
580
+ from wechatbridge import codex as codex_mod
581
+
582
+ with mock.patch.object(
583
+ codex_mod,
584
+ "_fetch_codex_model_list",
585
+ new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1", "gpt-5"]),
586
+ ):
587
+ reply = await handle_codex_slash_command("/model gpt-5.1", "u-model-prefix")
588
+ # exact "gpt-5.1" is in list → exact match wins over longer prefixes
589
+ self.assertIn("模型已切换", reply)
590
+ self.assertEqual(load_prefs("u-model-prefix")["model"], "gpt-5.1")
591
+
592
+ with mock.patch.object(
593
+ codex_mod,
594
+ "_fetch_codex_model_list",
595
+ new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5-codex"]),
596
+ ):
597
+ reply2 = await handle_codex_slash_command("/model gpt-5.1", "u-model-prefix2")
598
+ self.assertIn("模型已切换", reply2)
599
+ self.assertEqual(load_prefs("u-model-prefix2")["model"], "gpt-5.1-codex")
600
+
530
601
  async def test_model_empty_shows_current(self):
531
602
  from wechatbridge.codex import handle_codex_slash_command
603
+ from wechatbridge.runner_common import save_prefs
604
+
605
+ save_prefs("u-model-empty", {"model": "gpt-5.1-codex", "effort": "", "mode": ""})
532
606
  reply = await handle_codex_slash_command("/model", "u-model-empty")
533
607
  self.assertIn("当前模型", reply)
608
+ self.assertIn("gpt-5.1-codex", reply)
609
+ self.assertNotIn("不会校验", reply)
610
+ self.assertNotIn("未校验", reply)
534
611
 
535
- async def test_models_builtin(self):
612
+ async def test_models_live_list(self):
613
+ """/models under mock returns the live list."""
536
614
  from wechatbridge.codex import handle_codex_slash_command
537
- reply = await handle_codex_slash_command("/models", "u-models")
615
+ from wechatbridge import codex as codex_mod
616
+
617
+ with mock.patch.object(
618
+ codex_mod,
619
+ "_fetch_codex_model_list",
620
+ new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1"]),
621
+ ):
622
+ reply = await handle_codex_slash_command("/models", "u-models")
538
623
  self.assertIn("gpt-5.1-codex", reply)
624
+ self.assertIn("gpt-5.1", reply)
625
+ self.assertIn("debug models", reply)
626
+
627
+ async def test_models_fallback_builtin(self):
628
+ """/models falls back to built-in note when live list fails."""
629
+ from wechatbridge.codex import handle_codex_slash_command
630
+ from wechatbridge import codex as codex_mod
631
+
632
+ with mock.patch.object(
633
+ codex_mod,
634
+ "_fetch_codex_model_list",
635
+ new=AsyncMock(return_value=None),
636
+ ):
637
+ reply = await handle_codex_slash_command("/models", "u-models-fb")
638
+ self.assertIn("参考", reply)
639
+ self.assertIn("gpt-5.1-codex", reply)
640
+
641
+ async def test_parse_codex_models_json_and_lines(self):
642
+ from wechatbridge.codex import _parse_codex_models
643
+
644
+ j = json.dumps({
645
+ "models": [
646
+ {"slug": "gpt-5.1-codex"},
647
+ {"slug": "gpt-5"},
648
+ ]
649
+ })
650
+ self.assertEqual(_parse_codex_models(j), ["gpt-5.1-codex", "gpt-5"])
651
+
652
+ nested = json.dumps({
653
+ "models": [{"slug": "a"}, {"id": "b", "name": "ignored-without-slug-only"}]
654
+ })
655
+ # models[] item with slug + item with id (no slug) both accepted
656
+ self.assertEqual(_parse_codex_models(nested), ["a", "b"])
657
+
658
+ # bare string list
659
+ self.assertEqual(_parse_codex_models(json.dumps(["m1", "m2"])), ["m1", "m2"])
660
+
661
+ lines = "* gpt-5.1-codex (default)\n- gpt-5\n"
662
+ self.assertEqual(_parse_codex_models(lines), ["gpt-5.1-codex", "gpt-5"])
663
+
664
+ self.assertEqual(_parse_codex_models(""), [])
665
+ self.assertEqual(_parse_codex_models("{}"), [])
539
666
 
540
667
  async def test_fast_and_planning(self):
541
668
  from wechatbridge.codex import handle_codex_slash_command
@@ -1564,6 +1564,18 @@ class TestModelCmdRejectsBellThrottle(unittest.IsolatedAsyncioTestCase):
1564
1564
  self.assertIn("无法获取模型列表", out)
1565
1565
  self.assertNotIn("模型已切换", out)
1566
1566
 
1567
+ async def test_codex_model_bell_throttle(self):
1568
+ from wechatbridge.runner_common import format_notice
1569
+ from wechatbridge import codex as codex_mod
1570
+
1571
+ thr = format_notice("助手通道繁忙", "上游限流")
1572
+ with mock.patch.object(
1573
+ codex_mod, "_run_codex_subcommand", new=AsyncMock(return_value=thr)
1574
+ ):
1575
+ out = await codex_mod._cmd_model("gpt-5.1-codex", "u-model-codex")
1576
+ self.assertIn("无法获取模型列表", out)
1577
+ self.assertNotIn("模型已切换", out)
1578
+
1567
1579
 
1568
1580
  class TestEnvIntList(unittest.TestCase):
1569
1581
  """#8 optional: _env_int_list boundary cases."""
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.4.2"
2
+ __version__ = "1.4.3"
@@ -21,7 +21,7 @@ from .config import config
21
21
  from .runner_common import (
22
22
  sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
23
23
  clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
24
- sanitize_env, terminate_process, update_active_prefs,
24
+ sanitize_env, terminate_process, update_active_prefs, is_bridge_formatted_reply,
25
25
  format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir,
26
26
  )
27
27
 
@@ -776,45 +776,265 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
776
776
  # Slash command support
777
777
  # ---------------------------------------------------------------------------
778
778
 
779
- async def _cmd_model(args: str, user_id: str) -> str:
780
- """Handle /model <name>: store directly (codex has no models subcommand to validate)."""
781
- name = args.strip()
782
- if not name:
783
- prefs = load_prefs(user_id)
784
- return (
785
- "📋 **当前模型** 📋\n\n"
786
- f"`{format_model_label(prefs.get('model', ''))}`\n\n"
787
- "(codex 无列模型子命令,设置不会校验;填错会在下次运行时报错)"
779
+ async def _run_codex_subcommand(subcmd_args: list, user_id: str) -> str:
780
+ """Run a short codex subcommand (e.g. ``debug models``) and return cleaned output.
781
+
782
+ Timeout is fixed at 30 seconds. Session isolation matches ``run_codex``:
783
+ per-user ``HOME`` / ``CODEX_HOME``, optional ``CODEX_API_KEY`` refill after
784
+ ``sanitize_env``.
785
+ """
786
+ session_dir = ensure_user_codex(user_id)
787
+ cmd = [config.codex_binary_path] + list(subcmd_args)
788
+ process = None
789
+ try:
790
+ env = sanitize_env(session_dir)
791
+ env["CODEX_HOME"] = os.path.join(session_dir, ".codex")
792
+ env["PAGER"] = "cat"
793
+ env["CI"] = "true"
794
+ env["NONINTERACTIVE"] = "1"
795
+ env["PYTHONUNBUFFERED"] = "1"
796
+ raw_key = os.environ.get("CODEX_API_KEY")
797
+ if raw_key:
798
+ env["CODEX_API_KEY"] = raw_key
799
+
800
+ process = await asyncio.create_subprocess_exec(
801
+ *cmd,
802
+ stdout=asyncio.subprocess.PIPE,
803
+ stderr=asyncio.subprocess.PIPE,
804
+ stdin=asyncio.subprocess.DEVNULL,
805
+ cwd=session_dir,
806
+ env=env,
807
+ preexec_fn=os.setsid if hasattr(os, "setsid") else None,
788
808
  )
789
- update_active_prefs(user_id, model=name)
790
- return (
791
- f"✅ **模型已切换** ✅\n\n`{name}`\n\n"
792
- "(未校验,填错会在下次运行时报错)"
793
- )
809
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
810
+ process.communicate(), timeout=30.0
811
+ )
812
+ stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
813
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
814
+
815
+ if process.returncode != 0:
816
+ logger.warning(
817
+ "codex %s exited with code %s",
818
+ " ".join(subcmd_args),
819
+ process.returncode,
820
+ )
821
+ return format_cli_error(
822
+ stderr_text or stdout_text or "终端指令执行失败",
823
+ backend="codex",
824
+ )
825
+
826
+ return clean_output(stdout_text) or EMPTY_REPLY
827
+
828
+ except asyncio.TimeoutError:
829
+ await terminate_process(process, graceful=True)
830
+ return format_error("查询超时", "查询超时,请稍后再试。")
831
+ except asyncio.CancelledError:
832
+ await terminate_process(process, graceful=False)
833
+ raise
834
+ except Exception as e:
835
+ logger.exception("codex subcommand error: %s", e)
836
+ await terminate_process(process, graceful=False)
837
+ return format_error(
838
+ "执行出错",
839
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
840
+ )
841
+
842
+
843
+ def _parse_codex_models(output: str) -> list[str]:
844
+ """Loose-parse model slug list from ``codex debug models`` stdout.
845
+
846
+ Accepts JSON (any shape with ``slug`` fields, or a ``models`` array of
847
+ strings/objects) and falls back to line-oriented text (one name per line,
848
+ optional leading bullets).
849
+ """
850
+ if not output or not output.strip():
851
+ return []
852
+
853
+ text = output.strip()
854
+ models: list[str] = []
855
+ seen: set[str] = set()
856
+
857
+ def _add(name: str) -> None:
858
+ name = (name or "").strip()
859
+ if not name or name in seen:
860
+ return
861
+ # Skip obvious non-slug noise
862
+ if name.startswith(("[", "{", "(", "<")):
863
+ return
864
+ if name in (EMPTY_REPLY, "(empty response)"):
865
+ return
866
+ seen.add(name)
867
+ models.append(name)
868
+
869
+ def _walk(obj, *, under_models: bool = False) -> None:
870
+ if isinstance(obj, dict):
871
+ slug = obj.get("slug")
872
+ if isinstance(slug, str) and slug.strip():
873
+ _add(slug)
874
+ elif under_models:
875
+ # Inside a models array: accept id/name/model when slug absent
876
+ for key in ("id", "name", "model"):
877
+ val = obj.get(key)
878
+ if isinstance(val, str) and val.strip():
879
+ _add(val)
880
+ break
881
+ # Prefer the conventional "models" key, then recurse the rest
882
+ if "models" in obj:
883
+ _walk(obj["models"], under_models=True)
884
+ for k, v in obj.items():
885
+ if k == "models":
886
+ continue
887
+ _walk(v, under_models=False)
888
+ elif isinstance(obj, list):
889
+ for item in obj:
890
+ if isinstance(item, str) and under_models:
891
+ _add(item)
892
+ else:
893
+ _walk(item, under_models=under_models)
894
+
895
+ # Try full-text JSON first, then per-line JSON (JSONL-ish).
896
+ try:
897
+ data = json.loads(text)
898
+ # Top-level bare list of strings counts as a models array
899
+ if isinstance(data, list) and data and all(isinstance(x, str) for x in data):
900
+ _walk(data, under_models=True)
901
+ else:
902
+ _walk(data)
903
+ except (json.JSONDecodeError, TypeError, ValueError):
904
+ for line in text.splitlines():
905
+ line = line.strip()
906
+ if not line or not line.startswith(("{", "[")):
907
+ continue
908
+ try:
909
+ piece = json.loads(line)
910
+ if isinstance(piece, list) and piece and all(isinstance(x, str) for x in piece):
911
+ _walk(piece, under_models=True)
912
+ else:
913
+ _walk(piece)
914
+ except (json.JSONDecodeError, TypeError, ValueError):
915
+ continue
794
916
 
917
+ if models:
918
+ return models
795
919
 
796
- def _cmd_models() -> str:
797
- """Return a built-in note listing common codex models (no CLI to query)."""
920
+ # Line-oriented fallback (only when JSON yielded nothing useful)
921
+ for line in text.splitlines():
922
+ line = line.strip()
923
+ if not line:
924
+ continue
925
+ # Strip common bullets / list markers
926
+ for prefix in ("* ", "- ", "• ", "· "):
927
+ if line.startswith(prefix):
928
+ line = line[len(prefix):].strip()
929
+ break
930
+ # Take first token; drop trailing parenthetical notes
931
+ token = line.split()[0] if line.split() else ""
932
+ token = token.split("(")[0].strip().rstrip(",;:")
933
+ if token and not token.endswith(":") and not token.lower().startswith("available"):
934
+ _add(token)
935
+
936
+ return models
937
+
938
+
939
+ async def _fetch_codex_model_list(user_id: str) -> list[str] | None:
940
+ """Return live model slugs, or None if the list cannot be retrieved.
941
+
942
+ Tries ``codex debug models`` first, then ``codex debug models --bundled``.
943
+ Strict: empty / error / bridge-formatted failure → None (caller must not write prefs).
944
+ """
945
+ for args in (["debug", "models"], ["debug", "models", "--bundled"]):
946
+ output = await _run_codex_subcommand(args, user_id)
947
+ if not output or output.startswith("[error]") or is_bridge_formatted_reply(output):
948
+ continue
949
+ if output == EMPTY_REPLY:
950
+ continue
951
+ models = _parse_codex_models(output)
952
+ if models:
953
+ return models
954
+ return None
955
+
956
+
957
+ def _builtin_codex_models_note() -> str:
958
+ """Fallback reference list when live ``debug models`` is unavailable."""
798
959
  return (
799
960
  "📋 **codex 可用模型(参考)** 📋\n\n"
800
- "codex 没有 `models` 子命令可供查询,以下为常见模型名,以你的 OpenAI 账户实际可用为准:\n\n"
961
+ "暂时无法从本机 `codex debug models` 拉取实时列表,以下为常见模型名,"
962
+ "以你的 OpenAI 账户实际可用为准:\n\n"
801
963
  "- `gpt-5.1-codex`\n"
802
964
  "- `gpt-5.1`\n"
803
965
  "- `gpt-5-codex`\n"
804
966
  "- `gpt-5`\n"
805
967
  "- `gpt-4.1`\n\n"
806
- "直接 `/model <名称>` 即可;填错会在下次运行时报错。"
968
+ "请稍后重试 `/models`,或直接 `/model <名称>`(会校验实时列表)。"
807
969
  )
808
970
 
809
971
 
972
+ async def _cmd_model(args: str, user_id: str) -> str:
973
+ """Handle /model <name>: validate against ``codex debug models``, then save.
974
+
975
+ Strict: list fetch failure or unknown name → refuse and do not write prefs.
976
+ Matching order (same as agy): exact match, then first prefix match.
977
+ """
978
+ name = args.strip()
979
+ if not name:
980
+ prefs = load_prefs(user_id)
981
+ return (
982
+ "📋 **当前模型** 📋\n\n"
983
+ f"`{format_model_label(prefs.get('model', ''))}`\n\n"
984
+ "用 `/models` 查看可用模型,`/model <名称>` 切换。"
985
+ )
986
+
987
+ models = await _fetch_codex_model_list(user_id)
988
+ if models is None:
989
+ return "❌ **无法获取模型列表** ❌"
990
+
991
+ def _apply(model_name: str) -> str:
992
+ _, embedded = parse_model_effort(model_name)
993
+ if embedded:
994
+ update_active_prefs(user_id, model=model_name, effort="")
995
+ else:
996
+ update_active_prefs(user_id, model=model_name)
997
+ return f"✅ **模型已切换** ✅\n\n`{model_name}`"
998
+
999
+ # Exact match
1000
+ if name in models:
1001
+ return _apply(name)
1002
+
1003
+ # Prefix match (first hit, same as agy)
1004
+ prefix_matches = [m for m in models if m.startswith(name)]
1005
+ if prefix_matches:
1006
+ return _apply(prefix_matches[0])
1007
+
1008
+ return f"❌ **模型不存在** ❌\n\n`{name}`"
1009
+
1010
+
1011
+ async def _cmd_models(user_id: str) -> str:
1012
+ """List codex models via live ``debug models``; fall back to built-in note."""
1013
+ models = await _fetch_codex_model_list(user_id)
1014
+ if models is None:
1015
+ return _builtin_codex_models_note()
1016
+
1017
+ lines = [
1018
+ "📋 **codex 可用模型** 📋",
1019
+ "",
1020
+ "(via `codex debug models`)",
1021
+ "",
1022
+ ]
1023
+ for m in models:
1024
+ lines.append(f"- `{m}`")
1025
+ lines.append("")
1026
+ lines.append("用 `/model <名称>` 切换(支持前缀匹配)。")
1027
+ return "\n".join(lines)
1028
+
1029
+
810
1030
  def _cmd_help() -> str:
811
1031
  """Build /help response for codex backend."""
812
1032
  lines = [
813
1033
  "📋 **wechatbridge 支持指令 (codex)** 📋",
814
1034
  "",
815
1035
  "**模型控制**",
816
- "- `/model <名称>` — 切换模型(codex 无列模型子命令,直接存名字,未校验)",
817
- "- `/models` — 查看常见模型名(内置说明)",
1036
+ "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
1037
+ "- `/models` — 查看可用模型列表",
818
1038
  "- `/backend <agy|grok|codex>` — 切换助手引擎",
819
1039
  "",
820
1040
  "**对话控制**",
@@ -883,7 +1103,7 @@ async def handle_codex_slash_command(text: str, user_id: str) -> str | None:
883
1103
  return await _cmd_model(args, user_id)
884
1104
 
885
1105
  if cmd == "/models":
886
- return _cmd_models()
1106
+ return await _cmd_models(user_id)
887
1107
 
888
1108
  if cmd == "/add-dir":
889
1109
  path = args.strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -250,8 +250,8 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
250
250
  | `/help` | list supported commands for the active backend |
251
251
  | `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
252
252
  | `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
253
- | `/model <name>` | set model (validation differs by backend: agy/grok check against the live model list; codex stores the name without verifying, so a typo only surfaces on the next run; see `/models`) |
254
- | `/models` | list models — agy/grok query the live CLI; codex returns a built-in reference list, not your actual account's models |
253
+ | `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
254
+ | `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
255
255
  | `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
256
256
  | `/planning` | set planning mode (**on only** — not a toggle) |
257
257
  | `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |