diff --git a/plugin/README.md b/plugin/README.md index faabd0e..aae8b6c 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -795,7 +795,9 @@ first session there ends. | Key | Default | Values | |---|---|---| | `enabled` | `true` | `false`: the whole plugin is off; its hooks return at once and record nothing | -| `paid_model` | `""` | model for the plugin's own calls; empty: `haiku` on Claude Code, the agent's own default model on Codex and Pi, on Copilot `COPILOT_PROVIDER_MODEL_ID` or `COPILOT_MODEL` (unless it is `auto`), else a BYOK provider's Haiku, else Copilot's own default | +| `paid_model` | `""` | model for the plugin's own calls on every agent; empty: `haiku` on Claude Code, the agent's own default model on Codex and Pi, on Copilot `COPILOT_PROVIDER_MODEL_ID` or `COPILOT_MODEL` (unless it is `auto`), else a BYOK provider's Haiku, else Copilot's own default | +| `_paid_model` | `""` | the same for one agent (`claude`, `codex`, `copilot`, `pi`, `opencode`, `cursor`, `grok`, `hermes`), over `paid_model` | +| `claude_paid_effort`, `codex_paid_effort` | `""` | the reasoning effort of those calls: Claude Code `low` to `max`, Codex `none` to `max`; which values a model takes is the CLI's to say; empty: the CLI's default | | `pi_extensions` | `""` | Pi only: extension files (comma-separated paths) the plugin's own calls load, for a model whose provider an extension registers | | `pi_cli` | `""` | Pi only: the `pi` the plugin's own calls run; empty: the first on PATH | | `shadow` | `false` | decide and log everything, act on nothing | diff --git a/tests/test_live_core.py b/tests/test_live_core.py index 4dae487..16f5815 100644 --- a/tests/test_live_core.py +++ b/tests/test_live_core.py @@ -951,6 +951,49 @@ def test_claude_side_calls_stay_out_of_the_persons_history_where_the_cli_allows( assert sidecall.claude_flags("old") == [] +def test_every_agent_with_side_calls_has_its_own_paid_model_setting(): + from tokeneconomizer.plugin import config as live_config, hosts, sidecall + cfg = live_config.LiveConfig() + for name in set(hosts.HOSTS) - sidecall.NO_SIDE_CALLS: + assert cfg.set(f"{name}_paid_model", "m"), name + + +@pytest.mark.real_sidecalls +def test_side_calls_take_a_model_per_agent_and_an_effort(monkeypatch, tmp_path): + """`_paid_model` wins over the shared `paid_model`; `claude_paid_effort` and + `codex_paid_effort` reach each CLI in its own form, and only a Claude Code that lists + `--effort` is given it.""" + import subprocess + from tokeneconomizer.plugin import config as live_config, sidecall + seen, helps = [], {"new": " --effort ", "old": " -p"} + + def fake_run(cmd, **kw): + if cmd[1:] == ["--help"]: + return subprocess.CompletedProcess(cmd, 0, stdout=helps.get(cmd[0], ""), stderr="") + seen.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout=json.dumps({"result": "OK"}), stderr="") + + monkeypatch.setattr(sidecall.subprocess, "run", fake_run) + monkeypatch.setattr(sidecall, "_HELP", {}) + monkeypatch.setattr(sidecall, "claude_cli", lambda: "new") + cfg = live_config.load_config(str(tmp_path), env={ + "TE_PAID_MODEL": "shared", "TE_CODEX_PAID_MODEL": "small", + "TE_CLAUDE_PAID_EFFORT": "max", "TE_CODEX_PAID_EFFORT": "minimal"}) + assert not cfg.warnings + assert not cfg.set("claude_paid_effort", "minimal") and not cfg.set("codex_paid_effort", "x") + sidecall.runner_for("claude", cfg)("hi") + sidecall.runner_for("codex", cfg)("hi") + claude, codex = seen + assert claude[claude.index("--model") + 1] == "shared" + assert claude[claude.index("--effort") + 1] == "max" + assert codex[codex.index("-m") + 1] == "small" + assert codex[codex.index("-c") + 1] == 'model_reasoning_effort="minimal"' + sidecall.claude_call("hi", cli="old", effort="high") + assert "--effort" not in seen[-1] + sidecall.runner_for("codex", live_config.LiveConfig())("hi") + assert "-m" not in seen[-1] and "-c" not in seen[-1] + + @pytest.mark.real_sidecalls def test_a_claude_side_call_attaches_none_of_the_persons_mcp_servers(monkeypatch): """`--tools ""` leaves every MCP server attached: the side call drops them where the CLI can, diff --git a/tokeneconomizer/plugin/config.py b/tokeneconomizer/plugin/config.py index 3e58871..c71538b 100644 --- a/tokeneconomizer/plugin/config.py +++ b/tokeneconomizer/plugin/config.py @@ -46,6 +46,10 @@ * loopguard. -- one guard threshold, pinned over the preset and over what the repository's history learned (LOOPGUARD_KEYS); * repomap, repomap_tokens, repomap_python, repomap_timeout -- the opt-in repository map; + * _paid_model -- the model of the plugin's own calls on that agent, over + `paid_model` (claude, codex, copilot, pi, opencode, cursor, + grok, hermes); + * claude_paid_effort, codex_paid_effort -- their reasoning effort on those two agents; * pi_extensions, pi_cli -- on Pi, the extensions the plugin's own model calls load (a custom provider's, whose model they could not reach otherwise; comma-separated paths), and the `pi` they run; @@ -174,6 +178,16 @@ class LiveConfig: repomap_python: str = "" repomap_timeout: int = 8 paid_model: str = "" + claude_paid_model: str = "" + codex_paid_model: str = "" + copilot_paid_model: str = "" + pi_paid_model: str = "" + opencode_paid_model: str = "" + cursor_paid_model: str = "" + grok_paid_model: str = "" + hermes_paid_model: str = "" + claude_paid_effort: str = "" + codex_paid_effort: str = "" pi_extensions: list = field(default_factory=list) pi_cli: str = "" hermes_cli: str = "" @@ -187,7 +201,12 @@ class LiveConfig: _CHOICES = {"mode": MODES, "microskills": MICROSKILLS, "toolprune": TOOLPRUNE, "loopguard": LOOPGUARD, "repomap": REPOMAP, "loopguard_aggr": AGGR, - "vocabulary": ("auto", "generic", "python-git", "node-web")} + "vocabulary": ("auto", "generic", "python-git", "node-web"), + # Each CLI's own words (Claude Code 2.1.319, Codex 0.167.0); whether a model + # takes one is the CLI's to say. + "claude_paid_effort": ("", "low", "medium", "high", "xhigh", "max"), + "codex_paid_effort": ("", "none", "minimal", "low", "medium", "high", "xhigh", + "max")} # The smallest value a number setting takes, where it is not 1. _MIN = {"toolprune_quiet_minutes": 0} @@ -280,6 +299,9 @@ def _set_threshold(self, name, value, source): # Environment variables, old names first so that a new name set alongside its old one wins. _ENV = {"TE_MODE": "mode", "TE_SHADOW": "shadow", "TE_PAID_MODEL": "paid_model", "TE_VOCABULARY": "vocabulary", + **{f"TE_{a.upper()}_PAID_MODEL": f"{a}_paid_model" for a in ( + "claude", "codex", "copilot", "pi", "opencode", "cursor", "grok", "hermes")}, + "TE_CLAUDE_PAID_EFFORT": "claude_paid_effort", "TE_CODEX_PAID_EFFORT": "codex_paid_effort", "TE_INJECT": "microskills", "TE_INJECT_MAX_FACTS": "microskills_max_facts", "TE_INJECT_MAX_TOKENS": "microskills_max_tokens", "TE_PROPOSE_EVERY": "microskills_propose_every", diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index f30c114..dc617a2 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -276,12 +276,13 @@ def claude_json(stdout): return data if isinstance(data, dict) else {} -def claude_call(prompt, model="haiku", cli=None, timeout=TIMEOUT_S): +def claude_call(prompt, model="haiku", cli=None, timeout=TIMEOUT_S, effort=""): cli = cli or claude_cli() with tempfile.TemporaryDirectory(prefix="te-side-", ignore_cleanup_errors=True) as cwd: # `--mcp-config` takes several values, so an option follows it, never a bare argument. cmd = ([cli, "-p", "--output-format", "json", "--max-turns", "1", "--tools", ""] - + claude_no_mcp(cli, cwd) + ["--model", model or "haiku"] + claude_flags(cli)) + + claude_no_mcp(cli, cwd) + ["--model", model or "haiku"] + claude_flags(cli) + + (["--effort", effort] if effort and "--effort" in _claude_help(cli) else [])) out = subprocess.run(cmd, input=prompt, capture_output=True, encoding="utf-8", errors="replace", timeout=timeout, env=_env(), cwd=cwd) @@ -297,11 +298,13 @@ def claude_call(prompt, model="haiku", cli=None, timeout=TIMEOUT_S): return "" if failed else data["result"], eff -def codex_call(prompt, model="", cli="codex", timeout=TIMEOUT_S): +def codex_call(prompt, model="", cli="codex", timeout=TIMEOUT_S, effort=""): cmd = [*program(cli), "exec", "--json", "--disable", "hooks", "--ephemeral", "-s", "read-only", "--skip-git-repo-check"] if model: cmd += ["-m", model] + if effort: + cmd += ["-c", f'model_reasoning_effort="{effort}"'] cmd.append(prompt) with tempfile.TemporaryDirectory(prefix="te-side-", ignore_cleanup_errors=True) as cwd: out = subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace", @@ -764,7 +767,9 @@ def runner_for(host_name, config): def _host_call(host_name, config): if host_name in NO_SIDE_CALLS: return None - model = config.paid_model + model = getattr(config, f"{host_name}_paid_model", "") or config.paid_model + effort = getattr(config, f"{host_name}_paid_effort", "") + kw = {"effort": effort} if effort else {} if host_name == "pi": cli = os.path.expanduser(config.pi_cli) if config.pi_cli else "pi" extensions = list(config.pi_extensions) @@ -780,7 +785,7 @@ def _host_call(host_name, config): cli = os.path.expanduser(config.hermes_cli) if config.hermes_cli else None return lambda prompt, _m="agent": hermes_call(prompt, model=model, cli=cli) if host_name == "codex": - return lambda prompt, _m="agent": codex_call(prompt, model=model) + return lambda prompt, _m="agent": codex_call(prompt, model=model, **kw) if host_name == "copilot": return lambda prompt, _m="agent": copilot_call(prompt, model=model) - return lambda prompt, _m="agent": claude_call(prompt, model=model or "haiku") + return lambda prompt, _m="agent": claude_call(prompt, model=model or "haiku", **kw)