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:
parent
1f6606d3b9
commit
dbbead261d
3 changed files with 61 additions and 5 deletions
|
|
@ -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 <agent> sessions
|
||||
new --name <uuid> --cwd <dir> --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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue