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=<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.
This commit is contained in:
Kayos 2026-04-29 12:35:33 -07:00
parent 1f6606d3b9
commit dbbead261d
3 changed files with 61 additions and 5 deletions

View file

@ -39,6 +39,19 @@ ACPX_EXIT_NO_SESSION = 4
ACPX_EXIT_PERMISSION_DENIED = 5 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): class AcpxError(Exception):
"""Base class for acpx-runner failures surfaced to the API layer.""" """Base class for acpx-runner failures surfaced to the API layer."""
@ -92,6 +105,7 @@ class AcpxSession:
agent: str agent: str
cwd: Path cwd: Path
created_at: int created_at: int
model: str | None = None # optional per-session model override; propagated to subprocess env as ANTHROPIC_MODEL
closed: bool = False closed: bool = False
last_turn_at: int | None = None last_turn_at: int | None = None
turn_count: int = 0 turn_count: int = 0
@ -130,12 +144,23 @@ class AcpxManager:
# ---- public API ------------------------------------------------------ # ---- 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. """Create a new acpx-backed session.
Mints a UUID, makes a per-session cwd, runs `acpx <agent> sessions Mints a UUID, makes a per-session cwd, runs `acpx <agent> sessions
new --name <uuid> --cwd <dir> --format json`, captures the new --name <uuid> --cwd <dir> --format json`, captures the
session_ensured event, returns the in-memory handle. 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: async with self._registry_lock:
if self._count_open_locked() >= self.max_live_sessions: if self._count_open_locked() >= self.max_live_sessions:
@ -157,11 +182,13 @@ class AcpxManager:
"sessions", "new", "sessions", "new",
"--name", session_id, "--name", session_id,
] ]
env = _env_with_model(model)
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=env,
) )
except FileNotFoundError as e: except FileNotFoundError as e:
shutil.rmtree(cwd, ignore_errors=True) shutil.rmtree(cwd, ignore_errors=True)
@ -201,6 +228,7 @@ class AcpxManager:
agent=agent, agent=agent,
cwd=cwd, cwd=cwd,
created_at=int(time.time()), created_at=int(time.time()),
model=model,
) )
self._sessions[session_id] = sess self._sessions[session_id] = sess
return sess return sess
@ -256,6 +284,7 @@ class AcpxManager:
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_env_with_model(sess.model),
) )
except FileNotFoundError as e: except FileNotFoundError as e:
raise AcpxNotInstalled(f"acpx binary not found: {self.acpx_bin}") from e raise AcpxNotInstalled(f"acpx binary not found: {self.acpx_bin}") from e

View file

@ -114,6 +114,10 @@ class TokenCreateRequest(BaseModel):
class CreateSessionRequest(BaseModel): class CreateSessionRequest(BaseModel):
agent: str = Field(default="claude", min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$") 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 meta: dict | None = None
@ -257,7 +261,9 @@ async def create_session(
rec = auth.require_app(request, authorization) rec = auth.require_app(request, authorization)
try: 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: except AcpxPoolFull as e:
raise HTTPException(503, f"session pool full: {e}") raise HTTPException(503, f"session pool full: {e}")
except AcpxNotInstalled as e: except AcpxNotInstalled as e:
@ -269,6 +275,7 @@ async def create_session(
session_id=sess.session_id, session_id=sess.session_id,
app_name=rec["name"], app_name=rec["name"],
agent=body.agent, agent=body.agent,
model=body.model,
cwd=str(sess.cwd), cwd=str(sess.cwd),
meta=body.meta, meta=body.meta,
) )
@ -276,12 +283,13 @@ async def create_session(
session_id=sess.session_id, session_id=sess.session_id,
app_name=rec["name"], app_name=rec["name"],
event="create", event="create",
meta={"agent": body.agent}, meta={"agent": body.agent, "model": body.model},
) )
return { return {
"ok": True, "ok": True,
"session_id": sess.session_id, "session_id": sess.session_id,
"agent": body.agent, "agent": body.agent,
"model": body.model,
"created_at": row["created_at"], "created_at": row["created_at"],
"cwd": str(sess.cwd), "cwd": str(sess.cwd),
} }

View file

@ -88,6 +88,24 @@ class Store:
self.db_path = db_path self.db_path = db_path
with self._conn() as c: with self._conn() as c:
c.executescript(SCHEMA) 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 @contextmanager
def _conn(self): def _conn(self):
@ -211,14 +229,15 @@ class Store:
app_name: str, app_name: str,
agent: str, agent: str,
cwd: str, cwd: str,
model: str | None = None,
meta: dict | None = None, meta: dict | None = None,
) -> dict: ) -> dict:
now = int(time.time()) now = int(time.time())
meta_json = json.dumps(meta) if meta is not None else None meta_json = json.dumps(meta) if meta is not None else None
with self._conn() as c: with self._conn() as c:
c.execute( c.execute(
"INSERT INTO sessions (session_id, app_name, agent, cwd, created_at, meta_json) VALUES (?,?,?,?,?,?)", "INSERT INTO sessions (session_id, app_name, agent, model, cwd, created_at, meta_json) VALUES (?,?,?,?,?,?,?)",
(session_id, app_name, agent, cwd, now, meta_json), (session_id, app_name, agent, model, cwd, now, meta_json),
) )
return { return {
"session_id": session_id, "session_id": session_id,