claws: PATCH /api/claws/{id}/model — swap the runtime-bound model
Auto-provisioned agents (via the research + loops team wizard) show
up on the Agents page (same agents::insert → agents::roster path),
and PATCH /api/claws/{id} already handled name/job_title/system_prompt/
avatar/accent/wallpaper. The one thing that was NOT reachable from the
Agents page: swapping the model.
Add a dedicated endpoint:
- PATCH /api/claws/{id}/model { model: string }
- Persists via agents::set_model_binding (DB)
- Best-effort runtime rebind via RuntimeProvisioner::provision_claw
(idempotent — overwrites agents.<alias>.model_provider on the
shared ZeroClaw config)
- Audit-logged as 'agent.model_changed' with the new model in payload
Now an operator can open the Agents page, click a claw that was
auto-provisioned by the team wizard, and swap its model
(claude-sonnet-5 → glm-5.2 → whatever) without recreating the team.
Same DB row, same claw_id, same brain — just a new provider on the
next turn.
Frontend affordance not shipped in this commit — the endpoint is
usable via curl/psql/scripts today; a UI 'Model' picker on the claw
detail card can land in the next Agents-page pass.
This commit is contained in:
@@ -182,6 +182,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/claws/batch-delete", post(routes::claws::batch_delete))
|
.route("/api/claws/batch-delete", post(routes::claws::batch_delete))
|
||||||
.route("/api/claws/{id}", patch(routes::claws::patch))
|
.route("/api/claws/{id}", patch(routes::claws::patch))
|
||||||
.route("/api/claws/{id}", delete(routes::claws::delete))
|
.route("/api/claws/{id}", delete(routes::claws::delete))
|
||||||
|
.route("/api/claws/{id}/model", patch(routes::claws::set_model))
|
||||||
.route(
|
.route(
|
||||||
"/api/claws/{id}/access",
|
"/api/claws/{id}/access",
|
||||||
axum::routing::put(routes::claws::set_access),
|
axum::routing::put(routes::claws::set_access),
|
||||||
|
|||||||
@@ -957,6 +957,61 @@ pub async fn patch(
|
|||||||
Ok(Json(updated))
|
Ok(Json(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetModelRequest {
|
||||||
|
/// Model selector (claude / glm / glm-5.2 / kimi / gemini / groq /
|
||||||
|
/// specific model id like `claude-sonnet-5`). Resolved through the
|
||||||
|
/// same RuntimeProvisioner::provider_alias_for that team creation
|
||||||
|
/// uses, so shorthand + fully-qualified ids both work.
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/claws/{id}/model` — swap the model bound to a claw
|
||||||
|
/// (both the DB row + the live ZeroClaw runtime agent). Idempotent:
|
||||||
|
/// re-runs safely if the runtime agent was previously deprovisioned.
|
||||||
|
///
|
||||||
|
/// This is the missing piece that lets an operator open the Agents
|
||||||
|
/// page, click a claw that was auto-provisioned via the research or
|
||||||
|
/// loops team wizard, and swap its model without going through the
|
||||||
|
/// team-recreation flow. Complements `PATCH /api/claws/:id` (profile
|
||||||
|
/// fields) which was already there.
|
||||||
|
pub async fn set_model(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<AgentId>,
|
||||||
|
Json(body): Json<SetModelRequest>,
|
||||||
|
) -> Result<Json<Agent>, ApiError> {
|
||||||
|
let agent = workspace_agent(&state, &user, id).await?;
|
||||||
|
let model = body.model.trim();
|
||||||
|
if model.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
// 1. Persist the DB binding first — the runtime provision is
|
||||||
|
// idempotent and non-critical for read-only Agents-page display.
|
||||||
|
cm_db::repo::agents::set_model_binding(&state.pool, id, model).await?;
|
||||||
|
|
||||||
|
// 2. Best-effort runtime rebind so live sessions pick up the new
|
||||||
|
// model on their next turn. provision_claw overwrites
|
||||||
|
// agents.<alias>.model_provider on the shared ZeroClaw config.
|
||||||
|
if let Some(provisioner) = crate::runtime_provision::RuntimeProvisioner::from_env() {
|
||||||
|
if let Err(e) = provisioner.provision_claw(id.as_uuid(), model).await {
|
||||||
|
eprintln!("set_model({id}): runtime rebind failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cm_db::repo::audit::append(
|
||||||
|
&state.pool,
|
||||||
|
user.workspace_id,
|
||||||
|
Actor::User(user.user_id),
|
||||||
|
"agent.model_changed",
|
||||||
|
"agent",
|
||||||
|
&id.to_string(),
|
||||||
|
json!({ "model": model }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(agent))
|
||||||
|
}
|
||||||
|
|
||||||
/// DELETE /api/claws/{id} — destructive (§7.7): workspace owners or the
|
/// DELETE /api/claws/{id} — destructive (§7.7): workspace owners or the
|
||||||
/// claw's manager only. Soft delete keeps rows for audit.
|
/// claw's manager only. Soft delete keeps rows for audit.
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
|
|||||||
Reference in New Issue
Block a user