feat: probe Ollama model digest, emit as model_digest output (v0.3.1)
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1m36s
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1m36s
Closes Compliance Gap 1 from fai/platform :: docs/advanced/
compliance-gaps.md. The module now probes the LLM endpoint's
/api/show on every successful chat invocation and surfaces
the model's SHA-256 digest as a separate output field.
The probe is strictly best-effort:
- derive_show_url returns None unless the configured endpoint
ends in /api/chat, so non-Ollama deployments (OpenAI,
Anthropic) skip the probe entirely.
- probe_model_digest swallows every failure path (network
error, non-200 status, malformed JSON, missing digest field)
and yields None. The plan generation succeeds either way.
- extract_show_digest looks for digest at top level, then
details.digest, then model_info.digest — the fields shift
across Ollama versions, so the lookup is tolerant.
A new generate_plan_with_identity returns a PlanWithIdentity
struct ({plan_json, model_digest}). The original generate_plan
remains as a #[allow(dead_code)] convenience wrapper that
returns just the plan JSON.
The invoke entry point in lib.rs now emits four outputs:
plan (json) — unchanged
model_endpoint (text) — unchanged
model_name (text) — unchanged
model_digest (text) — NEW; empty for non-Ollama or stub paths
module.yaml documents the new output and bumps the orchestrator
capability version to 0.3.0 (additive minor; downstream flows
that referenced @^0 keep working).
Eight new tests in src/llm.rs cover the new code paths:
- URL transform (/api/chat → /api/show)
- URL transform skipped for non-Ollama endpoints
- Digest extraction at all three documented JSON locations
- Digest extraction graceful failure (missing, empty, bad JSON)
- End-to-end probe success
- End-to-end probe skipped for non-Ollama
- End-to-end probe failure swallowed without breaking plan
Total tests: 26 (was 18). Wasm artifact builds with v1.0
imports baked in. Platform-side integration tests in
fai/platform :: orchestrator_llm_path.rs pass against the
new module unchanged.
Bumps the module to 0.3.1 (patch — output schema additively
extended, behaviour unchanged for existing flows).
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
7cba1ae57a
commit
86eb72154e
4 changed files with 224 additions and 9 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "orchestrator_llm"
|
name = "orchestrator_llm"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
|
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
|
||||||
15
module.yaml
15
module.yaml
|
|
@ -1,11 +1,11 @@
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
name: orchestrator-llm
|
name: orchestrator-llm
|
||||||
version: 0.2.0
|
version: 0.3.1
|
||||||
|
|
||||||
# Capability provided by this module.
|
# Capability provided by this module.
|
||||||
provides:
|
provides:
|
||||||
- capability: orchestrator.plan
|
- capability: orchestrator.plan
|
||||||
version: 0.2.0
|
version: 0.3.0
|
||||||
|
|
||||||
# Inputs the invoke function accepts.
|
# Inputs the invoke function accepts.
|
||||||
inputs:
|
inputs:
|
||||||
|
|
@ -31,6 +31,17 @@ inputs:
|
||||||
outputs:
|
outputs:
|
||||||
# JSON-encoded Plan compatible with `fai_hub::plan::Plan`.
|
# JSON-encoded Plan compatible with `fai_hub::plan::Plan`.
|
||||||
plan: json
|
plan: json
|
||||||
|
# The endpoint the plan was generated against. Empty when the
|
||||||
|
# deterministic stub path (no llm_endpoint) ran.
|
||||||
|
model_endpoint: text
|
||||||
|
# The model name as supplied to the LLM API.
|
||||||
|
model_name: text
|
||||||
|
# SHA-256 digest of the served Ollama model, when reachable.
|
||||||
|
# Empty for cloud providers (OpenAI / Anthropic do not expose
|
||||||
|
# a digest API) and for the deterministic stub path. Together
|
||||||
|
# with model_endpoint and model_name this answers the audit
|
||||||
|
# question "which exact model produced this plan?"
|
||||||
|
model_digest: text
|
||||||
|
|
||||||
# Permissions required.
|
# Permissions required.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
12
src/lib.rs
12
src/lib.rs
|
|
@ -45,8 +45,8 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||||
.and_then(payload_text)
|
.and_then(payload_text)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let plan_json = if llm_endpoint.is_empty() {
|
let (plan_json, model_digest) = if llm_endpoint.is_empty() {
|
||||||
build_stub_plan(goal)
|
(build_stub_plan(goal), None)
|
||||||
} else {
|
} else {
|
||||||
let client = make_client();
|
let client = make_client();
|
||||||
let params = crate::llm::OllamaParams {
|
let params = crate::llm::OllamaParams {
|
||||||
|
|
@ -57,14 +57,16 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||||
available_capabilities: &available_capabilities,
|
available_capabilities: &available_capabilities,
|
||||||
store_capabilities: &store_capabilities,
|
store_capabilities: &store_capabilities,
|
||||||
};
|
};
|
||||||
crate::llm::generate_plan(&client, ¶ms)
|
let result = crate::llm::generate_plan_with_identity(&client, ¶ms)
|
||||||
.map_err(|e| ModuleError::internal(e.to_string()))?
|
.map_err(|e| ModuleError::internal(e.to_string()))?;
|
||||||
|
(result.plan_json, result.model_digest)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Outputs::new()
|
Ok(Outputs::new()
|
||||||
.with_json_str("plan", plan_json)
|
.with_json_str("plan", plan_json)
|
||||||
.with_text("model_endpoint", llm_endpoint)
|
.with_text("model_endpoint", llm_endpoint)
|
||||||
.with_text("model_name", llm_model))
|
.with_text("model_name", llm_model)
|
||||||
|
.with_text("model_digest", model_digest.unwrap_or_default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn payload_text(p: &Payload) -> Option<String> {
|
fn payload_text(p: &Payload) -> Option<String> {
|
||||||
|
|
|
||||||
204
src/llm.rs
204
src/llm.rs
|
|
@ -127,12 +127,43 @@ pub fn extract_ollama_content(body: &str) -> Result<String, LlmError> {
|
||||||
.ok_or_else(|| LlmError::Decode("missing message.content".into()))
|
.ok_or_else(|| LlmError::Decode("missing message.content".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan plus model identity captured at invocation time, for
|
||||||
|
/// audit logging.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlanWithIdentity {
|
||||||
|
/// The plan JSON the LLM produced.
|
||||||
|
pub plan_json: String,
|
||||||
|
/// SHA-256 digest of the served model, when reachable. `None`
|
||||||
|
/// for non-Ollama endpoints (OpenAI / Anthropic do not expose
|
||||||
|
/// a per-model digest API) or when the `/api/show` probe failed
|
||||||
|
/// for any reason. The audit trail records this verbatim;
|
||||||
|
/// `None` becomes an empty string in the module output.
|
||||||
|
pub model_digest: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate a plan via the configured LLM endpoint. Returns the
|
/// Generate a plan via the configured LLM endpoint. Returns the
|
||||||
/// validated plan JSON ready to be emitted as the module output.
|
/// validated plan JSON ready to be emitted as the module output.
|
||||||
|
///
|
||||||
|
/// Convenience wrapper kept for callers that do not care about
|
||||||
|
/// the model digest. New code should call `generate_plan_with_identity`.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn generate_plan<C: LlmClient>(
|
pub fn generate_plan<C: LlmClient>(
|
||||||
client: &C,
|
client: &C,
|
||||||
p: &OllamaParams,
|
p: &OllamaParams,
|
||||||
) -> Result<String, OrchestratorError> {
|
) -> Result<String, OrchestratorError> {
|
||||||
|
generate_plan_with_identity(client, p).map(|r| r.plan_json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a plan AND probe the LLM endpoint for the model's
|
||||||
|
/// SHA-256 digest. The digest probe is best-effort: failures do
|
||||||
|
/// not propagate, the digest is simply absent from the result.
|
||||||
|
/// This gives compliance-grade audit on Ollama deployments while
|
||||||
|
/// staying compatible with cloud providers that do not expose
|
||||||
|
/// digests.
|
||||||
|
pub fn generate_plan_with_identity<C: LlmClient>(
|
||||||
|
client: &C,
|
||||||
|
p: &OllamaParams,
|
||||||
|
) -> Result<PlanWithIdentity, OrchestratorError> {
|
||||||
if p.endpoint.is_empty() {
|
if p.endpoint.is_empty() {
|
||||||
return Err(OrchestratorError::MissingEndpoint);
|
return Err(OrchestratorError::MissingEndpoint);
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +177,59 @@ pub fn generate_plan<C: LlmClient>(
|
||||||
.map_err(OrchestratorError::Llm)?;
|
.map_err(OrchestratorError::Llm)?;
|
||||||
let plan_json = extract_ollama_content(&response_body).map_err(OrchestratorError::Llm)?;
|
let plan_json = extract_ollama_content(&response_body).map_err(OrchestratorError::Llm)?;
|
||||||
crate::plan::parse_and_validate(&plan_json).map_err(OrchestratorError::Plan)?;
|
crate::plan::parse_and_validate(&plan_json).map_err(OrchestratorError::Plan)?;
|
||||||
Ok(plan_json)
|
|
||||||
|
let model_digest = probe_model_digest(client, p);
|
||||||
|
Ok(PlanWithIdentity {
|
||||||
|
plan_json,
|
||||||
|
model_digest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort probe of Ollama's `/api/show` for the model digest.
|
||||||
|
/// Any failure (non-Ollama endpoint, network error, parse failure)
|
||||||
|
/// returns `None` — never propagates as an error to the caller.
|
||||||
|
fn probe_model_digest<C: LlmClient>(client: &C, p: &OllamaParams) -> Option<String> {
|
||||||
|
let show_url = derive_show_url(p.endpoint)?;
|
||||||
|
let body = serde_json::to_string(&serde_json::json!({ "name": p.model })).ok()?;
|
||||||
|
let response = client.post_json(&show_url, &body, p.api_key).ok()?;
|
||||||
|
extract_show_digest(&response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an Ollama `/api/chat` URL into the matching
|
||||||
|
/// `/api/show` URL by suffix substitution. Returns `None` if the
|
||||||
|
/// endpoint does not end in `/api/chat` — that is the signal that
|
||||||
|
/// the endpoint is not Ollama-shaped, so a probe makes no sense.
|
||||||
|
pub fn derive_show_url(chat_endpoint: &str) -> Option<String> {
|
||||||
|
if chat_endpoint.ends_with("/api/chat") {
|
||||||
|
let head_len = chat_endpoint.len() - "/api/chat".len();
|
||||||
|
let mut url = String::with_capacity(head_len + "/api/show".len());
|
||||||
|
url.push_str(&chat_endpoint[..head_len]);
|
||||||
|
url.push_str("/api/show");
|
||||||
|
Some(url)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the SHA-256 digest from a `/api/show` response body.
|
||||||
|
/// Looks for the field at top level, then under `details.digest`,
|
||||||
|
/// then under `model_info.digest`. Returns `None` if no candidate
|
||||||
|
/// resolves to a non-empty string.
|
||||||
|
pub fn extract_show_digest(body: &str) -> Option<String> {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||||
|
let candidates = [
|
||||||
|
v.get("digest"),
|
||||||
|
v.get("details").and_then(|d| d.get("digest")),
|
||||||
|
v.get("model_info").and_then(|d| d.get("digest")),
|
||||||
|
];
|
||||||
|
for cand in candidates {
|
||||||
|
if let Some(s) = cand.and_then(|v| v.as_str()) {
|
||||||
|
if !s.is_empty() {
|
||||||
|
return Some(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|
@ -351,4 +434,123 @@ mod tests {
|
||||||
Err(OrchestratorError::Llm(LlmError::MissingInput("llm_model")))
|
Err(OrchestratorError::Llm(LlmError::MissingInput("llm_model")))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Compliance Gap 1: model digest probe ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_show_url_swaps_chat_suffix() {
|
||||||
|
assert_eq!(
|
||||||
|
derive_show_url("http://localhost:11434/api/chat").as_deref(),
|
||||||
|
Some("http://localhost:11434/api/show")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
derive_show_url("https://example.com/v1/api/chat").as_deref(),
|
||||||
|
Some("https://example.com/v1/api/show")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_show_url_returns_none_for_non_ollama_endpoints() {
|
||||||
|
assert!(derive_show_url("https://api.openai.com/v1/chat/completions").is_none());
|
||||||
|
assert!(derive_show_url("https://api.anthropic.com/v1/messages").is_none());
|
||||||
|
assert!(derive_show_url("http://localhost:11434/api/chats").is_none());
|
||||||
|
assert!(derive_show_url("/api/chat-something").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_show_digest_finds_top_level_field() {
|
||||||
|
let body = r#"{"digest":"sha256:abcdef","details":{"format":"gguf"}}"#;
|
||||||
|
assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:abcdef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_show_digest_falls_back_to_nested_locations() {
|
||||||
|
let nested_details = r#"{"details":{"digest":"sha256:nested-details"}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_show_digest(nested_details).as_deref(),
|
||||||
|
Some("sha256:nested-details")
|
||||||
|
);
|
||||||
|
let nested_info = r#"{"model_info":{"digest":"sha256:nested-info"}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_show_digest(nested_info).as_deref(),
|
||||||
|
Some("sha256:nested-info")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_show_digest_returns_none_when_absent_or_empty() {
|
||||||
|
assert!(extract_show_digest(r#"{"modelfile":"..."}"#).is_none());
|
||||||
|
assert!(extract_show_digest(r#"{"digest":""}"#).is_none());
|
||||||
|
assert!(extract_show_digest("not even json").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_plan_with_identity_records_digest_when_show_responds() {
|
||||||
|
let canned_plan =
|
||||||
|
r#"{"schema_version":1,"goal":"x","steps":[{"kind":"explain","text":"ok"}]}"#;
|
||||||
|
let canned_chat = format!(
|
||||||
|
r#"{{"message":{{"content":{}}},"done":true}}"#,
|
||||||
|
serde_json::to_string(canned_plan).unwrap(),
|
||||||
|
);
|
||||||
|
let canned_show = r#"{"digest":"sha256:deadbeef"}"#.to_string();
|
||||||
|
// Mock pops from the end — so push show first, chat second.
|
||||||
|
let client = MockClient::new(vec![Ok(canned_show), Ok(canned_chat)]);
|
||||||
|
let p = OllamaParams {
|
||||||
|
endpoint: "http://localhost:11434/api/chat",
|
||||||
|
model: "qwen",
|
||||||
|
api_key: "",
|
||||||
|
goal: "x",
|
||||||
|
available_capabilities: "",
|
||||||
|
store_capabilities: "",
|
||||||
|
};
|
||||||
|
let result = generate_plan_with_identity(&client, &p).unwrap();
|
||||||
|
assert_eq!(result.model_digest.as_deref(), Some("sha256:deadbeef"));
|
||||||
|
assert!(!result.plan_json.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_plan_with_identity_returns_none_digest_for_non_ollama_endpoint() {
|
||||||
|
// Endpoint does not end in /api/chat: probe is skipped
|
||||||
|
// entirely. Only one mock response is consumed.
|
||||||
|
let canned_plan =
|
||||||
|
r#"{"schema_version":1,"goal":"x","steps":[{"kind":"explain","text":"ok"}]}"#;
|
||||||
|
let canned_chat = format!(
|
||||||
|
r#"{{"message":{{"content":{}}}}}"#,
|
||||||
|
serde_json::to_string(canned_plan).unwrap(),
|
||||||
|
);
|
||||||
|
let client = MockClient::new(vec![Ok(canned_chat)]);
|
||||||
|
let p = OllamaParams {
|
||||||
|
endpoint: "https://api.openai.com/v1/chat/completions",
|
||||||
|
model: "gpt",
|
||||||
|
api_key: "k",
|
||||||
|
goal: "x",
|
||||||
|
available_capabilities: "",
|
||||||
|
store_capabilities: "",
|
||||||
|
};
|
||||||
|
let result = generate_plan_with_identity(&client, &p).unwrap();
|
||||||
|
assert_eq!(result.model_digest, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_plan_with_identity_swallows_show_failures() {
|
||||||
|
// The /api/show probe fails (e.g. 500). The plan still
|
||||||
|
// succeeds with `model_digest = None`.
|
||||||
|
let canned_plan =
|
||||||
|
r#"{"schema_version":1,"goal":"x","steps":[{"kind":"explain","text":"ok"}]}"#;
|
||||||
|
let canned_chat = format!(
|
||||||
|
r#"{{"message":{{"content":{}}}}}"#,
|
||||||
|
serde_json::to_string(canned_plan).unwrap(),
|
||||||
|
);
|
||||||
|
let client = MockClient::new(vec![Err(LlmError::Status(500)), Ok(canned_chat)]);
|
||||||
|
let p = OllamaParams {
|
||||||
|
endpoint: "http://localhost:11434/api/chat",
|
||||||
|
model: "qwen",
|
||||||
|
api_key: "",
|
||||||
|
goal: "x",
|
||||||
|
available_capabilities: "",
|
||||||
|
store_capabilities: "",
|
||||||
|
};
|
||||||
|
let result = generate_plan_with_identity(&client, &p).unwrap();
|
||||||
|
assert_eq!(result.model_digest, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue