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
10 changes: 10 additions & 0 deletions crucible-controller/migrations/0054_dispatch_default_role.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- 0054_dispatch_default_role.sql — a default per inference role.
--
-- A run reaches up to two models: the one its agent turns think with, and the one its route tasks
-- ask. Both are chosen the same way, scope then class, so a default row names the role it answers
-- for. Every row written before this one was an agent default.
ALTER TABLE dispatch_defaults ADD COLUMN role TEXT NOT NULL DEFAULT 'agent'
CHECK (role IN ('agent', 'decision'));
ALTER TABLE dispatch_defaults ALTER COLUMN role DROP DEFAULT;
ALTER TABLE dispatch_defaults DROP CONSTRAINT dispatch_defaults_pkey;
ALTER TABLE dispatch_defaults ADD PRIMARY KEY (scope_kind, scope_ref, workload_class, role);
145 changes: 145 additions & 0 deletions crucible-controller/src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10609,6 +10609,7 @@ async fn config_providers_lists_enabled_providers_and_the_defaults(pool: PgPool)
scope_kind: crate::playbooks::providers::DefaultScope::Platform,
scope_ref: String::new(),
workload_class: crate::playbooks::providers::WorkloadClass::Autoresearch,
role: crate::playbooks::providers::ModelRole::Agent,
provider_id: "plat-openai".to_string(),
model: Some("gpt-5.6-sol".to_string()),
fallback_provider_id: None,
Expand Down Expand Up @@ -11443,6 +11444,150 @@ async fn an_admin_sets_a_dispatch_default_with_a_fallback(pool: PgPool) -> Resul
Ok(())
}

/// A decision default stands beside the agent default of the same scope and class: each is set,
/// listed, and cleared under its own role. Only a provider that serves route decisions may be one,
/// and only for playbooks, the one class that runs route tasks.
#[sqlx::test(migrator = "crucible_controller::MIGRATOR")]
async fn an_admin_sets_a_decision_default_beside_the_agent_one(pool: PgPool) -> Result<()> {
let (db, _d) = db_with(pool);
seed_provider(
db.pool(),
"openai",
crate::playbooks::providers::ProviderKind::OpenAi,
true,
)
.await;
seed_provider(
db.pool(),
"anthropic",
crate::playbooks::providers::ProviderKind::Anthropic,
true,
)
.await;
let app = app_with_admins(db.clone(), vec!["wren".to_string()]);
let put = |body: serde_json::Value| admin_json("PUT", "/api/config/dispatch-defaults", body);

for body in [
serde_json::json!({"scope_kind": "platform", "workload_class": "playbook", "provider": "anthropic"}),
serde_json::json!({"scope_kind": "platform", "workload_class": "playbook", "role": "decision", "provider": "openai"}),
] {
let res = app.clone().oneshot(put(body.clone())?).await?;
assert_eq!(res.status(), StatusCode::OK, "{body}");
}
let res = app
.clone()
.oneshot(
HttpRequest::get("/api/config/providers")
.header("x-auth-request-user", "wren")
.body(Body::empty())?,
)
.await?;
let v = json_of(res).await?;
let roles: Vec<(String, String)> = v["defaults"]
.as_array()
.expect("defaults")
.iter()
.map(|d| (d["role"].to_string(), d["provider"].to_string()))
.collect();
assert_eq!(
roles,
vec![
("\"agent\"".to_string(), "\"anthropic\"".to_string()),
("\"decision\"".to_string(), "\"openai\"".to_string()),
]
);
let served: Vec<(String, serde_json::Value, serde_json::Value)> = v["providers"]
.as_array()
.expect("providers")
.iter()
.map(|p| {
(
p["id"].to_string(),
p["roles"].clone(),
p["harness"].clone(),
)
})
.collect();
assert_eq!(
served,
vec![
(
"\"anthropic\"".to_string(),
serde_json::json!(["agent"]),
serde_json::json!("claude")
),
(
"\"openai\"".to_string(),
serde_json::json!(["agent", "decision"]),
serde_json::json!("codex")
),
]
);
let decision = crate::playbooks::providers::resolve_decision(
db.pool(),
None,
crate::playbooks::providers::WorkloadClass::Playbook,
)
.await?
.expect("the decision default resolves");
assert_eq!(decision.model, "gpt-6-luna");

for (bad, why) in [
(
serde_json::json!({"scope_kind": "platform", "workload_class": "playbook", "role": "decision", "provider": "anthropic"}),
"cannot serve the decision role",
),
(
serde_json::json!({"scope_kind": "platform", "workload_class": "autoresearch", "role": "decision", "provider": "openai"}),
"runs no route task",
),
(
serde_json::json!({"scope_kind": "platform", "workload_class": "playbook", "role": "decision", "provider": "openai", "fallback_provider": "anthropic"}),
"fallback_provider \"anthropic\" cannot serve the decision role",
),
] {
let res = app.clone().oneshot(put(bad.clone())?).await?;
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY, "{bad}");
let body = json_of(res).await?;
let error = body["error"].as_str().unwrap_or_default();
assert!(error.contains(why), "{error} lacks {why}");
}

let delete = |query: &str| {
HttpRequest::delete(format!("/api/config/dispatch-defaults?{query}"))
.header("x-auth-request-user", "wren")
.body(Body::empty())
};
let res = app
.clone()
.oneshot(delete("scope_kind=platform&workload_class=playbook")?)
.await?;
assert_eq!(
res.status(),
StatusCode::NO_CONTENT,
"no role clears the agent's"
);
let left = crate::playbooks::providers::list_defaults(db.pool()).await?;
assert_eq!(
left.iter().map(|d| d.role).collect::<Vec<_>>(),
vec![crate::playbooks::providers::ModelRole::Decision]
);
let res = app
.clone()
.oneshot(delete(
"scope_kind=platform&workload_class=playbook&role=decision",
)?)
.await?;
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let res = app
.oneshot(delete(
"scope_kind=platform&workload_class=playbook&role=judge",
)?)
.await?;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
Ok(())
}

/// A launch that picks a provider pins the pair on its issue row, where every later dispatch reads
/// it, and echoes what it pinned.
#[cfg(feature = "autoresearch")]
Expand Down
10 changes: 7 additions & 3 deletions crucible-controller/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ pub(crate) async fn schema_version(ex: impl PgExecutor<'_>) -> Result<i64> {

#[cfg(test)]
mod tests {
use super::*;
use crate::client::*;
use crate::issues::model::NewIssue;
use crate::model::{ParkedBy, Status};
use crate::runs::model::{NewCandidate, NewRun};
Expand Down Expand Up @@ -444,6 +444,10 @@ mod tests {
"0053_task_repairs.sql",
"4f2444a5c159ac1f47d4bc0972d44e7d17b2cdcdf0e5e60e3aa025ed92a3f726",
),
(
"0054_dispatch_default_role.sql",
"74774c07cce84368ef881707d8fa8d9d85b9608d8e09999f0f975eb1b3507c2c",
),
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
for (name, want) in pinned {
Expand Down Expand Up @@ -939,11 +943,11 @@ mod tests {
sqlx::Postgres::database_exists(&url).await?,
"connect creates the database"
);
assert_eq!(crate::schema_version(&pool).await?, 53);
assert_eq!(crate::schema_version(&pool).await?, 54);

// Idempotent: re-opening an already-migrated DB is a no-op, not an error.
let pool2 = connect(&url).await?;
assert_eq!(crate::schema_version(&pool2).await?, 53);
assert_eq!(crate::schema_version(&pool2).await?, 54);
pool.close().await;
pool2.close().await;
let _ = sqlx::Postgres::drop_database(&url).await;
Expand Down
33 changes: 14 additions & 19 deletions crucible-controller/src/issues/ranker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
//! a Chat Completions or Responses endpoint is registered the way `OPENAI_BASE_URL` is read (with
//! `/v1`), so it is used as given.
//!
//! A provider's key is read the way a pod's is ([`crate::secrets::deliver::provider_delivery`]),
//! through the deployment's secret reader.
//! A provider's key is read the way a pod's is ([`crate::secrets::deliver::model_key`]), through
//! the deployment's secret reader.
//!
//! With nothing registered the ranker keeps the deployment's ambient Vertex: the model in
//! `CONTROLLER_RANKER_MODEL` (a genai model string, default `vertex::claude-sonnet-5`) on ADC, so an
Expand Down Expand Up @@ -341,34 +341,28 @@ fn keyed_route(dispatch: &ResolvedDispatch) -> Result<Option<(AdapterKind, Strin
InferenceProtocol::Responses => {
(AdapterKind::OpenAIResp, custom_base(&endpoint.url))
}
InferenceProtocol::SystemOne | InferenceProtocol::Decisions => bail!(
"custom provider {} speaks {}, which ranks nothing",
provider.id,
endpoint.protocol.as_str()
),
}
}
}))
}

/// The key a provider's registered secret projects under its key variable, read through the same
/// path a pod's delivery takes. Empty for a provider that names no secret (an unauthenticated
/// in-cluster endpoint ignores the header).
/// The key a provider's registered secret holds, read through the same path a pod's delivery
/// takes. Empty for a provider that names no secret (an unauthenticated in-cluster endpoint ignores
/// the header).
async fn provider_key(db: &Db, cfg: &ControllerCfg, dispatch: &ResolvedDispatch) -> Result<String> {
let provider = &dispatch.provider;
let delivery = crate::secrets::deliver::provider_delivery(
let key = crate::secrets::deliver::model_key(
db.pool(),
cfg.secret_provider.as_ref(),
provider,
&dispatch.inference(),
)
.await?
.map_err(|refusal| anyhow::anyhow!("{refusal}"))?;
let Some(var) = provider.api_key_env() else {
return Ok(String::new());
};
let Some((_, secret_key)) = delivery.env.iter().find(|(v, _)| v == var) else {
return Ok(String::new());
};
delivery
.data
.get(secret_key)
.cloned()
.with_context(|| format!("provider {} delivery has no value for {var}", provider.id))
Ok(key.unwrap_or_default())
}

async fn candidate(db: &Db, cfg: &ControllerCfg, dispatch: &ResolvedDispatch) -> Result<Candidate> {
Expand Down Expand Up @@ -970,6 +964,7 @@ mod tests {
scope_kind: crate::playbooks::providers::DefaultScope::Platform,
scope_ref: String::new(),
workload_class: crate::playbooks::providers::WorkloadClass::Autoresearch,
role: crate::playbooks::providers::ModelRole::Agent,
provider_id: primary.to_string(),
model: None,
fallback_provider_id: fallback.map(str::to_string),
Expand Down
2 changes: 1 addition & 1 deletion crucible-controller/src/issues/reconcile/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ async fn launch_approved_run(
launcher: crate::authz::model::Principals::default(),
revision: crate::secrets::launch::OwnedRevision::UNPINNED,
provider: cfg.secret_provider.clone(),
inference_provider: dispatch.map(|d| d.provider),
inference: dispatch.iter().map(|d| d.inference()).collect(),
exposure: scope.exposure.clone(),
}),
)
Expand Down
2 changes: 1 addition & 1 deletion crucible-controller/src/issues/reconcile/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ pub(super) async fn run_scope_and_transition(
agent: crate::playbooks::providers::AgentSelection::from_resolved(
dispatch.as_ref(),
),
inference_provider: dispatch.map(|d| d.provider),
inference: dispatch.iter().map(|d| d.inference()).collect(),
},
)
.await?;
Expand Down
Loading
Loading