fix(planner): the other four server-side calls were still on the metered key
The test that was supposed to prevent this grepped for the literal `runtime.complete(` and passed while the phase planner (`mission_plan.rs`), both swarm calls, and a second enhance path in `claws.rs` still billed the pay-as-you-go account. They spell the receiver `state.runtime` or wrap the call across lines, so the receiver name was never the thing to match. The test now matches the METHOD, and covers all five files. `complete_or` gains the rule that makes it safe to apply everywhere: a `name:model` spec is an operator's explicit provider choice — the swarm worker model is configured exactly that way — and is passed straight to `Runtime::resolve_provider` untouched. Only a bare name is ambiguous, and a bare name is precisely what resolves to the default provider. Hijacking a chosen Kimi or GLM model onto Anthropic would be the same silent-substitution bug pointed the other way. `validator_preflight` and the evaluator judge keep calling the runtime directly, on purpose: both exist to exercise the CONFIGURED spec. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
deed591da6
commit
ee5a939ce6
@@ -660,9 +660,15 @@ pub(crate) async fn enhance_and_publish(
|
|||||||
let user_prompt = format!(
|
let user_prompt = format!(
|
||||||
"ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
|
"ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
|
||||||
);
|
);
|
||||||
let raw = runtime
|
let raw = crate::subscription::complete_or(
|
||||||
.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true)
|
runtime,
|
||||||
.await?;
|
ENHANCE_SYSTEM,
|
||||||
|
&user_prompt,
|
||||||
|
"claude-opus-4-8",
|
||||||
|
16000,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?;
|
let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?;
|
||||||
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
|
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
|
||||||
let field = |k: &str| {
|
let field = |k: &str| {
|
||||||
|
|||||||
@@ -174,14 +174,19 @@ pub async fn suggest(
|
|||||||
PLANNABLE_KINDS.join(", "),
|
PLANNABLE_KINDS.join(", "),
|
||||||
);
|
);
|
||||||
|
|
||||||
let raw = state
|
let raw = crate::subscription::complete_or(
|
||||||
.runtime
|
&state.runtime,
|
||||||
.complete(PLAN_SYSTEM, &prompt, PLANNER_MODEL, 2000, false)
|
PLAN_SYSTEM,
|
||||||
.await
|
&prompt,
|
||||||
.map_err(|e| {
|
PLANNER_MODEL,
|
||||||
eprintln!("mission {id}: plan proposal failed: {e}");
|
2000,
|
||||||
ApiError::Internal
|
false,
|
||||||
})?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: plan proposal failed: {e}");
|
||||||
|
crate::subscription::as_api_error(&e)
|
||||||
|
})?;
|
||||||
|
|
||||||
let parsed: Value = crate::routes::claws::extract_json(&raw).ok_or_else(|| {
|
let parsed: Value = crate::routes::claws::extract_json(&raw).ok_or_else(|| {
|
||||||
eprintln!("mission {id}: planner returned no JSON: {raw}");
|
eprintln!("mission {id}: planner returned no JSON: {raw}");
|
||||||
|
|||||||
@@ -68,14 +68,27 @@ pub async fn complete_or(
|
|||||||
// web search away from two features while every test still passed.
|
// web search away from two features while every test still passed.
|
||||||
web_search: bool,
|
web_search: bool,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let Some(provider) = provider() else {
|
// A `name:model` spec is an operator's explicit provider choice — the swarm
|
||||||
|
// worker model is literally configured that way (`kimi:kimi-k2.6`), and
|
||||||
|
// `Runtime::resolve_provider` honours it. Forcing that onto Anthropic would
|
||||||
|
// silently run someone's chosen model on the wrong provider, which is the
|
||||||
|
// same class of bug as this module exists to fix, only pointed the other
|
||||||
|
// way. Only a BARE name is ambiguous, and a bare name is what resolves to
|
||||||
|
// the default provider — the metered key.
|
||||||
|
if !is_bare_model_name(model) || provider().is_none() {
|
||||||
return runtime
|
return runtime
|
||||||
.complete(system, user, model, max_tokens, web_search)
|
.complete(system, user, model, max_tokens, web_search)
|
||||||
.await;
|
.await;
|
||||||
};
|
}
|
||||||
|
let provider = provider().expect("checked just above");
|
||||||
complete_with(&provider, system, user, model, max_tokens, web_search).await
|
complete_with(&provider, system, user, model, max_tokens, web_search).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a model string names a model without naming a provider.
|
||||||
|
pub fn is_bare_model_name(model: &str) -> bool {
|
||||||
|
!model.contains(':')
|
||||||
|
}
|
||||||
|
|
||||||
/// How long to wait before each retry. Four attempts, ~30s of patience total.
|
/// How long to wait before each retry. Four attempts, ~30s of patience total.
|
||||||
///
|
///
|
||||||
/// The subscription has no credit wall, but it does have a rate limit, and a
|
/// The subscription has no credit wall, but it does have a rate limit, and a
|
||||||
@@ -205,19 +218,27 @@ mod tests {
|
|||||||
/// spec an operator configured (today `glm:glm-4.7`), and forcing it onto
|
/// spec an operator configured (today `glm:glm-4.7`), and forcing it onto
|
||||||
/// Anthropic would make it prove the wrong thing — it exists to answer "is
|
/// Anthropic would make it prove the wrong thing — it exists to answer "is
|
||||||
/// the configured validator reachable".
|
/// the configured validator reachable".
|
||||||
|
/// The first version of this test grepped for the literal
|
||||||
|
/// `runtime.complete(` and passed while FOUR more call sites — the phase
|
||||||
|
/// planner, both swarm calls, and a second enhance path — still billed the
|
||||||
|
/// metered key. They were spelled `state.runtime` or wrapped across lines,
|
||||||
|
/// so the receiver name was never the thing to look for. Match the METHOD.
|
||||||
#[test]
|
#[test]
|
||||||
fn no_server_side_call_silently_uses_the_metered_key() {
|
fn no_server_side_call_silently_uses_the_metered_key() {
|
||||||
let sources = [
|
let sources = [
|
||||||
("routes/mission_roster.rs", include_str!("routes/mission_roster.rs")),
|
("routes/mission_roster.rs", include_str!("routes/mission_roster.rs")),
|
||||||
|
("routes/mission_plan.rs", include_str!("routes/mission_plan.rs")),
|
||||||
("routes/planner.rs", include_str!("routes/planner.rs")),
|
("routes/planner.rs", include_str!("routes/planner.rs")),
|
||||||
("routes/claws.rs", include_str!("routes/claws.rs")),
|
("routes/claws.rs", include_str!("routes/claws.rs")),
|
||||||
|
("swarm.rs", include_str!("swarm.rs")),
|
||||||
];
|
];
|
||||||
for (name, src) in sources {
|
for (name, src) in sources {
|
||||||
assert!(
|
assert!(
|
||||||
!src.contains("runtime.complete("),
|
!src.contains(".complete("),
|
||||||
"{name} calls Runtime::complete directly — a bare model name there \
|
"{name} calls Runtime::complete directly — a bare model name there \
|
||||||
resolves to the DEFAULT provider, which is the metered API key. \
|
resolves to the DEFAULT provider, which is the metered API key. \
|
||||||
Use `subscription::complete_or`."
|
Use `subscription::complete_or`, which passes a `name:model` \
|
||||||
|
spec through untouched."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// And the exception stays an exception, on purpose.
|
// And the exception stays an exception, on purpose.
|
||||||
@@ -254,6 +275,20 @@ mod tests {
|
|||||||
assert!(!is_transient(&LlmError::Wire("bad json".into())));
|
assert!(!is_transient(&LlmError::Wire("bad json".into())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An operator's explicit provider choice is never hijacked.
|
||||||
|
///
|
||||||
|
/// The swarm worker model is a configured `name:model` spec. Routing that
|
||||||
|
/// onto the subscription would run someone's chosen Kimi or GLM model on
|
||||||
|
/// Anthropic and report success — the same silent-substitution bug as the
|
||||||
|
/// metered key, aimed the other way.
|
||||||
|
#[test]
|
||||||
|
fn a_provider_qualified_spec_is_left_alone() {
|
||||||
|
assert!(is_bare_model_name("claude-opus-4-8"));
|
||||||
|
assert!(is_bare_model_name("claude-haiku-4-5-20251001"));
|
||||||
|
assert!(!is_bare_model_name("kimi:kimi-k2.6"));
|
||||||
|
assert!(!is_bare_model_name("glm:glm-4.7"));
|
||||||
|
}
|
||||||
|
|
||||||
/// A metered key in the OAuth slot must be REFUSED, not used.
|
/// A metered key in the OAuth slot must be REFUSED, not used.
|
||||||
///
|
///
|
||||||
/// Accepting it would authenticate, work, and bill the pay-as-you-go account
|
/// Accepting it would authenticate, work, and bill the pay-as-you-go account
|
||||||
|
|||||||
+25
-11
@@ -137,9 +137,15 @@ pub async fn run_swarm_job(
|
|||||||
"GOAL:\n{goal}\n\nCHECKLIST each task's output must satisfy:\n{}{want}",
|
"GOAL:\n{goal}\n\nCHECKLIST each task's output must satisfy:\n{}{want}",
|
||||||
checklist_lines(&checklist)
|
checklist_lines(&checklist)
|
||||||
);
|
);
|
||||||
let plan_raw = runtime
|
let plan_raw = crate::subscription::complete_or(
|
||||||
.complete(PLAN_SYSTEM, &plan_user, "claude-opus-4-8", 4000, false)
|
runtime,
|
||||||
.await?;
|
PLAN_SYSTEM,
|
||||||
|
&plan_user,
|
||||||
|
"claude-opus-4-8",
|
||||||
|
4000,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let tasks: Vec<String> = extract_json(&plan_raw)
|
let tasks: Vec<String> = extract_json(&plan_raw)
|
||||||
.and_then(|v| {
|
.and_then(|v| {
|
||||||
v.get("tasks").and_then(|t| t.as_array()).map(|a| {
|
v.get("tasks").and_then(|t| t.as_array()).map(|a| {
|
||||||
@@ -177,10 +183,12 @@ pub async fn run_swarm_job(
|
|||||||
let mut still: Vec<(usize, String)> = Vec::new();
|
let mut still: Vec<(usize, String)> = Vec::new();
|
||||||
let mut rejected = 0usize;
|
let mut rejected = 0usize;
|
||||||
for (idx, task) in pending.iter() {
|
for (idx, task) in pending.iter() {
|
||||||
let out = runtime
|
// `worker_model` may be a `name:model` spec the operator chose;
|
||||||
.complete(&wsys, task, &worker_model, 4000, true)
|
// `complete_or` passes those straight through untouched.
|
||||||
.await
|
let out =
|
||||||
.unwrap_or_else(|e| format!("worker error: {e}"));
|
crate::subscription::complete_or(runtime, &wsys, task, &worker_model, 4000, true)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| format!("worker error: {e}"));
|
||||||
records.push(step(
|
records.push(step(
|
||||||
format!("task-{idx}"),
|
format!("task-{idx}"),
|
||||||
format!("worker:{worker_model}"),
|
format!("worker:{worker_model}"),
|
||||||
@@ -190,10 +198,16 @@ pub async fn run_swarm_job(
|
|||||||
ckpt(pool, id, &records, &totals).await;
|
ckpt(pool, id, &records, &totals).await;
|
||||||
|
|
||||||
let vuser = format!("TASK:\n{task}\n\nWORKER OUTPUT:\n{out}");
|
let vuser = format!("TASK:\n{task}\n\nWORKER OUTPUT:\n{out}");
|
||||||
let v_raw = runtime
|
let v_raw = crate::subscription::complete_or(
|
||||||
.complete(&vsys, &vuser, "claude-opus-4-8", 1200, true)
|
runtime,
|
||||||
.await
|
&vsys,
|
||||||
.unwrap_or_default();
|
&vuser,
|
||||||
|
"claude-opus-4-8",
|
||||||
|
1200,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let v = extract_json(&v_raw);
|
let v = extract_json(&v_raw);
|
||||||
let passed = v
|
let passed = v
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
Reference in New Issue
Block a user