Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| `<agent>_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 |
Expand Down
43 changes: 43 additions & 0 deletions tests/test_live_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""`<agent>_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 <level>", "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,
Expand Down
24 changes: 23 additions & 1 deletion tokeneconomizer/plugin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
* loopguard.<name> -- 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;
* <agent>_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;
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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}

Expand Down Expand Up @@ -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",
Expand Down
17 changes: 11 additions & 6 deletions tokeneconomizer/plugin/sidecall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Loading