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
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/prism-challenge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ prism-final = { path = "../prism-final" }
prism-intake = { path = "../prism-intake" }
prism-lium = { path = "../prism-lium" }
prism-lium-payer = { path = "../prism-lium-payer" }
prism-verda = { path = "../prism-verda" }
prism-orphan = { path = "../prism-orphan" }
prism-pipeline = { path = "../prism-pipeline" }
prism-playground = { path = "../prism-playground" }
Expand Down
54 changes: 27 additions & 27 deletions crates/prism-challenge/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,15 @@ async fn post_submission(
headers: axum::http::HeaderMap,
body: bytes::Bytes,
) -> Response {
let miner_lium_key = headers
.get(prism_lium_payer::LIUM_API_KEY_HEADER)
.or_else(|| headers.get("X-Lium-Api-Key"))
.and_then(|v| v.to_str().ok())
.and_then(prism_lium_payer::normalize_lium_api_key);
if let Ok(v) = serde_json::from_slice::<Value>(body.as_ref()) {
if let Some(msg) = prism_lium_payer::miner_image_override_error(&v) {
return json_err(StatusCode::BAD_REQUEST, "miner_image_override", msg);
}
}
let payer_creds = match prism_lium_payer::creds_from_headers(&headers) {
Ok(c) => c,
Err(e) => return json_err(StatusCode::BAD_REQUEST, e.code(), e.message()),
};
let mut req = match parse_submission_body(&headers, body.as_ref()) {
Ok(r) => r,
Err(e) => return json_err(StatusCode::BAD_REQUEST, "invalid_submission", &e),
Expand Down Expand Up @@ -272,21 +276,20 @@ async fn post_submission(
Ok(b) => b,
Err(e) => return json_err(StatusCode::BAD_REQUEST, "tree", &e),
};
// Live Lium: miners fund their own pod via X-Lium-Api-Key (not persisted).
if prism_lium_payer::require_miner_lium(st.backend_mode) && miner_lium_key.is_none() && !exists
{
// Live: miners fund Lium or Verda (not persisted in Postgres).
if prism_lium_payer::require_miner_lium(st.backend_mode) && payer_creds.is_none() && !exists {
return json_err(
StatusCode::BAD_REQUEST,
"missing_lium_api_key",
"live Prism eval requires header X-Lium-Api-Key (miner-funded Lium account)",
"live Prism eval requires X-Lium-Api-Key or Verda BYOK headers",
);
}
let row = queued_row(&st, &req, id.clone(), tree_blob);
// Idempotent no-op duplicate accepted (same id → 200 OK {status:"already-queued"}).
match st.store.insert_queued(&row).await {
Ok(()) => {
if let (Some(vault), Some(key)) = (&st.payer_vault, miner_lium_key.as_ref()) {
vault.insert(id.clone(), key.clone());
if let (Some(vault), Some(creds)) = (&st.payer_vault, payer_creds.as_ref()) {
vault.insert_creds(id.clone(), creds);
}
// Registration finalizes only after the row is queued so intake
// failures never consume the miner's single slot.
Expand All @@ -307,8 +310,8 @@ async fn post_submission(
(StatusCode::ACCEPTED, Json(json!({"submission_id": id, "status": "accepted", "note": lium_rent_pool::CAPACITY_POLICY}))).into_response()
}
Err(StoreError::Backend(e)) if e.contains("duplicate") || e.contains("unique") => {
if let (Some(vault), Some(key)) = (&st.payer_vault, miner_lium_key.as_ref()) {
vault.insert(id.clone(), key.clone());
if let (Some(vault), Some(creds)) = (&st.payer_vault, payer_creds.as_ref()) {
vault.insert_creds(id.clone(), creds);
}
(
StatusCode::OK,
Expand Down Expand Up @@ -445,23 +448,20 @@ async fn post_retry(
}
// Prefer the request header, else reuse the sealed BYOK vault entry for
// this submission_id (auto-/admin-retry must not drop miner Lium keys).
let header_lium_key = headers
.get(prism_lium_payer::LIUM_API_KEY_HEADER)
.or_else(|| headers.get("X-Lium-Api-Key"))
.and_then(|v| v.to_str().ok())
.and_then(prism_lium_payer::normalize_lium_api_key);
let miner_lium_key = header_lium_key
.clone()
.or_else(|| st.payer_vault.as_ref().and_then(|v| v.get(&id)));
let header_creds = prism_lium_payer::creds_from_headers(&headers)
.ok()
.flatten();
let miner_creds =
header_creds.or_else(|| st.payer_vault.as_ref().and_then(|v| v.get_creds(&id)));
if infra
&& row.metrics_json.is_none()
&& prism_lium_payer::require_miner_lium(st.backend_mode)
&& miner_lium_key.is_none()
&& miner_creds.is_none()
{
return json_err(
StatusCode::BAD_REQUEST,
"missing_lium_api_key",
"live Prism retry requires X-Lium-Api-Key (or a sealed payer vault entry) when another GPU run is needed",
"live Prism retry requires X-Lium-Api-Key or Verda BYOK (or a sealed payer vault entry)",
);
}
if row.retry_count >= st.retry_max && !infra {
Expand All @@ -479,10 +479,8 @@ async fn post_retry(
}
match st.store.reset_for_retry(&id, true).await {
Ok(_) => {
if let (Some(vault), Some(key)) = (&st.payer_vault, miner_lium_key) {
// Re-seal so TTL covers the new attempt even when the header
// was omitted and we reused the vault entry.
vault.insert(id.clone(), key);
if let (Some(vault), Some(creds)) = (&st.payer_vault, miner_creds.as_ref()) {
vault.insert_creds(id.clone(), creds);
}
(
StatusCode::ACCEPTED,
Expand Down Expand Up @@ -523,6 +521,7 @@ async fn get_status(State(st): State<Arc<AppState>>) -> Response {
"recent_terminal": done_24h,
"recipe_pin": prism_recipe::recipe_pin_hex(),
"lium_capacity_note": lium_rent_pool::CAPACITY_POLICY,
"verda_capacity_note": prism_verda::CAPACITY_POLICY,
}))
.into_response()
}
Expand Down Expand Up @@ -1069,6 +1068,7 @@ mod tests {
assert_eq!(s, StatusCode::OK);
assert_eq!(v["backend"], "sim");
assert_eq!(v["lium_capacity_note"], lium_rent_pool::CAPACITY_POLICY);
assert_eq!(v["verda_capacity_note"], prism_verda::CAPACITY_POLICY);
let (s, v) = call(
app.clone(),
Request::get("/v1/recipe").body(Body::empty()).unwrap(),
Expand Down
3 changes: 3 additions & 0 deletions crates/prism-lium-payer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ publish = false

[dependencies]
chacha20poly1305 = "0.10"
http = "1"
prism-lium = { path = "../prism-lium" }
prism-recipe = { path = "../prism-recipe" }
prism-verda = { path = "../prism-verda" }
rand = "0.8"
serde_json = "1"
sha2 = "0.10"
tracing = "0.1"

Expand Down
180 changes: 180 additions & 0 deletions crates/prism-lium-payer/src/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! Intake header → payer creds (Lium XOR Verda unless `X-Compute-Provider`).

use serde_json::Value;

use super::ProviderCreds;

/// `X-Compute-Provider` when both Lium and Verda headers are complete.
pub const COMPUTE_PROVIDER_HEADER: &str = "x-compute-provider";
/// Verda OAuth client id.
pub const VERDA_CLIENT_ID_HEADER: &str = "x-verda-client-id";
/// Verda OAuth client secret.
pub const VERDA_CLIENT_SECRET_HEADER: &str = "x-verda-client-secret";
/// Verda inference / tasks token.
pub const VERDA_INFERENCE_KEY_HEADER: &str = "x-verda-inference-key";
/// Alias for the inference token (`X-Verda-Api-Key`).
pub const VERDA_API_KEY_HEADER: &str = "x-verda-api-key";

/// Intake payer failure (HTTP 400).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntakePayerError {
/// Both providers complete, no `X-Compute-Provider`.
Ambiguous,
/// Partial Verda triplet.
MissingVerda,
}

impl IntakePayerError {
/// Stable API error code.
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::Ambiguous => "ambiguous_compute_provider",
Self::MissingVerda => "missing_verda_credentials",
}
}

/// Miner-facing message.
#[must_use]
pub const fn message(&self) -> &'static str {
match self {
Self::Ambiguous => {
"both Lium and Verda credentials present — set X-Compute-Provider: lium or verda"
}
Self::MissingVerda => {
"Verda BYOK needs X-Verda-Client-Id, X-Verda-Client-Secret, and X-Verda-Inference-Key"
}
}
}
}

fn nz(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|t| !t.is_empty())
}

fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok())
}

/// Resolve miner payer from HTTP headers (case-insensitive names).
///
/// # Errors
/// Ambiguous dual complete creds, or incomplete Verda triplet.
pub fn creds_from_headers(
headers: &http::HeaderMap,
) -> Result<Option<ProviderCreds>, IntakePayerError> {
let inference = header_str(headers, VERDA_INFERENCE_KEY_HEADER)
.or_else(|| header_str(headers, VERDA_API_KEY_HEADER))
.or_else(|| header_str(headers, "X-Verda-Inference-Key"))
.or_else(|| header_str(headers, "X-Verda-Api-Key"));
creds_from_parts(
header_str(headers, COMPUTE_PROVIDER_HEADER)
.or_else(|| header_str(headers, "X-Compute-Provider")),
header_str(headers, super::LIUM_API_KEY_HEADER)
.or_else(|| header_str(headers, "X-Lium-Api-Key")),
header_str(headers, VERDA_CLIENT_ID_HEADER)
.or_else(|| header_str(headers, "X-Verda-Client-Id")),
header_str(headers, VERDA_CLIENT_SECRET_HEADER)
.or_else(|| header_str(headers, "X-Verda-Client-Secret")),
inference,
)
}

/// Resolve miner payer from already-extracted header values.
///
/// # Errors
/// Ambiguous dual complete creds, or incomplete Verda triplet.
pub fn creds_from_parts(
provider: Option<&str>,
lium: Option<&str>,
verda_id: Option<&str>,
verda_sec: Option<&str>,
verda_inf: Option<&str>,
) -> Result<Option<ProviderCreds>, IntakePayerError> {
let lium = nz(lium).and_then(super::normalize_lium_api_key);
let id = nz(verda_id);
let sec = nz(verda_sec);
let inf = nz(verda_inf);
let verda_any = id.is_some() || sec.is_some() || inf.is_some();
let verda = match (id, sec, inf) {
(Some(id), Some(sec), Some(inf)) => Some(ProviderCreds::Verda {
id: id.to_owned(),
sec: sec.to_owned(),
inf: inf.to_owned(),
}),
_ if verda_any => return Err(IntakePayerError::MissingVerda),
_ => None,
};
let want = nz(provider).map(str::to_ascii_lowercase);
if want.as_deref() == Some("lium") {
if let Some(k) = lium {
return Ok(Some(ProviderCreds::Lium(k)));
}
}
if want.as_deref() == Some("verda") {
return verda.ok_or(IntakePayerError::MissingVerda).map(Some);
}
match (lium, verda) {
(Some(k), None) => Ok(Some(ProviderCreds::Lium(k))),
(None, Some(v)) => Ok(Some(v)),
(None, None) => Ok(None),
(Some(_), Some(_)) => Err(IntakePayerError::Ambiguous),
}
}

/// JSON keys miners must not use to override the operator image/cmd.
#[must_use]
pub fn miner_image_override_error(v: &Value) -> Option<&'static str> {
const BAD: &[&str] = &[
"image",
"docker_image",
"image_digest",
"cmd",
"command",
"entrypoint",
"template",
"template_id",
];
let obj = v.as_object()?;
BAD.iter()
.find(|k| obj.contains_key(**k))
.map(|_| "miners cannot set image, template, cmd, or entrypoint — operator pin only")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn lium_or_verda_or_ambiguous() {
let l = creds_from_parts(None, Some("sk"), None, None, None).unwrap();
assert_eq!(l.unwrap().provider(), "lium");
let v = creds_from_parts(None, None, Some("id"), Some("sec"), Some("inf")).unwrap();
assert_eq!(v.unwrap().provider(), "verda");
assert_eq!(
creds_from_parts(None, Some("sk"), Some("id"), Some("sec"), Some("inf")),
Err(IntakePayerError::Ambiguous)
);
let forced = creds_from_parts(
Some("verda"),
Some("sk"),
Some("id"),
Some("sec"),
Some("inf"),
)
.unwrap();
assert_eq!(forced.unwrap().provider(), "verda");
assert_eq!(
creds_from_parts(None, None, Some("id"), None, Some("inf")),
Err(IntakePayerError::MissingVerda)
);
}

#[test]
fn reject_miner_image_fields() {
let v = serde_json::json!({"image": "evil:latest", "miner_hotkey": "aa"});
assert!(miner_image_override_error(&v).is_some());
let ok = serde_json::json!({"miner_hotkey": "aa", "zip_base64": "e30="});
assert!(miner_image_override_error(&ok).is_none());
}
}
Loading
Loading