From dbbead261db7af5b96c1f9aca0a69c2596adfc1d Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 29 Apr 2026 12:35:33 -0700 Subject: [PATCH] v0.2.1: optional per-session model override (Cobb wants Opus on code-work paths) - CreateSessionRequest gains optional `model` field (validated regex) - AcpxManager.create + AcpxSession dataclass carry the model - Subprocess env on both create and turn sets ANTHROPIC_MODEL= when set; subprocesses inherit parent env unchanged when NULL - store.insert_session takes optional model; sessions table grows a nullable model column via idempotent ALTER TABLE in Store.__init__ so live deployments migrate on next boot - POST /sessions response echoes model so callers can confirm - session_events 'create' meta records the model Backward-compatible: omitting model preserves current Sonnet-default behavior. The 14 SDK Session APIs work unchanged; SDK updates can land opportunistically when callers need to pin a model. crafting-table's patcher pins 'opus' in a follow-up commit on the crafting-table side. --- clawdforge/acpx_runner.py | 31 ++++++++++++++++++++++++++++++- clawdforge/server.py | 12 ++++++++++-- clawdforge/store.py | 23 +++++++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/clawdforge/acpx_runner.py b/clawdforge/acpx_runner.py index 4d370dc..5f26d4e 100644 --- a/clawdforge/acpx_runner.py +++ b/clawdforge/acpx_runner.py @@ -39,6 +39,19 @@ ACPX_EXIT_NO_SESSION = 4 ACPX_EXIT_PERMISSION_DENIED = 5 +def _env_with_model(model: str | None) -> dict[str, str] | None: + """Build subprocess env, optionally pinning ANTHROPIC_MODEL. + + The underlying claude CLI honors ANTHROPIC_MODEL env to pick the model + for an invocation. acpx forwards env to the agent process, so setting + it here propagates through the chain. Returns None when no override + is needed so create_subprocess_exec inherits the parent env unchanged. + """ + if not model: + return None + return {**os.environ, "ANTHROPIC_MODEL": model} + + class AcpxError(Exception): """Base class for acpx-runner failures surfaced to the API layer.""" @@ -92,6 +105,7 @@ class AcpxSession: agent: str cwd: Path created_at: int + model: str | None = None # optional per-session model override; propagated to subprocess env as ANTHROPIC_MODEL closed: bool = False last_turn_at: int | None = None turn_count: int = 0 @@ -130,12 +144,23 @@ class AcpxManager: # ---- public API ------------------------------------------------------ - async def create(self, *, app_name: str, agent: str = "claude") -> AcpxSession: + async def create( + self, + *, + app_name: str, + agent: str = "claude", + model: str | None = None, + ) -> AcpxSession: """Create a new acpx-backed session. Mints a UUID, makes a per-session cwd, runs `acpx sessions new --name --cwd --format json`, captures the session_ensured event, returns the in-memory handle. + + `model`, when set, is propagated to the underlying claude CLI via + the ANTHROPIC_MODEL env var on both this create call and every + turn() against the resulting session. NULL = let the CLI default + decide (Sonnet at the time of writing). """ async with self._registry_lock: if self._count_open_locked() >= self.max_live_sessions: @@ -157,11 +182,13 @@ class AcpxManager: "sessions", "new", "--name", session_id, ] + env = _env_with_model(model) try: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=env, ) except FileNotFoundError as e: shutil.rmtree(cwd, ignore_errors=True) @@ -201,6 +228,7 @@ class AcpxManager: agent=agent, cwd=cwd, created_at=int(time.time()), + model=model, ) self._sessions[session_id] = sess return sess @@ -256,6 +284,7 @@ class AcpxManager: *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=_env_with_model(sess.model), ) except FileNotFoundError as e: raise AcpxNotInstalled(f"acpx binary not found: {self.acpx_bin}") from e diff --git a/clawdforge/server.py b/clawdforge/server.py index 882a26d..4238866 100644 --- a/clawdforge/server.py +++ b/clawdforge/server.py @@ -114,6 +114,10 @@ class TokenCreateRequest(BaseModel): class CreateSessionRequest(BaseModel): agent: str = Field(default="claude", min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$") + # Optional per-session model override; propagated to the underlying claude CLI + # via ANTHROPIC_MODEL env. NULL = let the CLI default decide. Examples: "opus", + # "sonnet", or a full model id like "claude-opus-4-7". + model: str | None = Field(default=None, max_length=64, pattern=r"^[a-zA-Z0-9._-]+$") meta: dict | None = None @@ -257,7 +261,9 @@ async def create_session( rec = auth.require_app(request, authorization) try: - sess = await acpx_manager.create(app_name=rec["name"], agent=body.agent) + sess = await acpx_manager.create( + app_name=rec["name"], agent=body.agent, model=body.model + ) except AcpxPoolFull as e: raise HTTPException(503, f"session pool full: {e}") except AcpxNotInstalled as e: @@ -269,6 +275,7 @@ async def create_session( session_id=sess.session_id, app_name=rec["name"], agent=body.agent, + model=body.model, cwd=str(sess.cwd), meta=body.meta, ) @@ -276,12 +283,13 @@ async def create_session( session_id=sess.session_id, app_name=rec["name"], event="create", - meta={"agent": body.agent}, + meta={"agent": body.agent, "model": body.model}, ) return { "ok": True, "session_id": sess.session_id, "agent": body.agent, + "model": body.model, "created_at": row["created_at"], "cwd": str(sess.cwd), } diff --git a/clawdforge/store.py b/clawdforge/store.py index 7cbee58..472774a 100644 --- a/clawdforge/store.py +++ b/clawdforge/store.py @@ -88,6 +88,24 @@ class Store: self.db_path = db_path with self._conn() as c: c.executescript(SCHEMA) + self._migrate_v0_2_1(c) + + def _migrate_v0_2_1(self, c): + """Idempotent additive migrations layered on top of SCHEMA. + + Each ALTER TABLE is wrapped in try/except for OperationalError so + re-running on an already-migrated DB is a no-op. New deployments + get the columns via this same path immediately after CREATE TABLE. + """ + # Optional per-session model override (e.g. 'opus' / 'sonnet'). + # NULL = let underlying claude CLI default decide. Cobb's intent + # 2026-04-29: crafting-table's patcher pins 'opus' per session so + # code-work prompts get Opus's long-context reasoning while other + # consumers (cauldron) keep using whatever the CLI default is. + try: + c.execute("ALTER TABLE sessions ADD COLUMN model TEXT") + except sqlite3.OperationalError: + pass # column already present — re-run on already-migrated DB @contextmanager def _conn(self): @@ -211,14 +229,15 @@ class Store: app_name: str, agent: str, cwd: str, + model: str | None = None, meta: dict | None = None, ) -> dict: now = int(time.time()) meta_json = json.dumps(meta) if meta is not None else None with self._conn() as c: c.execute( - "INSERT INTO sessions (session_id, app_name, agent, cwd, created_at, meta_json) VALUES (?,?,?,?,?,?)", - (session_id, app_name, agent, cwd, now, meta_json), + "INSERT INTO sessions (session_id, app_name, agent, model, cwd, created_at, meta_json) VALUES (?,?,?,?,?,?,?)", + (session_id, app_name, agent, model, cwd, now, meta_json), ) return { "session_id": session_id,