diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 56ea4e9..2eee8b6 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -10,16 +10,16 @@ env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" RUST_TOOLCHAIN: "1.86" - CARGO_NET_GIT_FETCH_WITH_CLI: "true" jobs: ci: name: Linux x86_64 (Forgejo) runs-on: ubuntu-latest steps: - # Manual external-URL checkout — see fai/platform CI for - # background. Lays this repo at $GITHUB_WORKSPACE. - - name: Checkout text-extract via external URL + # See fai/platform docs/architecture/ci.md for why we + # check out via the external URL instead of using + # actions/checkout. + - name: Checkout via external URL run: | set -eu mkdir -p "$GITHUB_WORKSPACE" @@ -30,23 +30,6 @@ jobs: git fetch --depth=1 origin "$GITHUB_SHA" git checkout -q FETCH_HEAD - # The chain-module-sdk dependency is a git dep against a repo - # that lives in another Forgejo org. The repo-scoped - # GITHUB_TOKEN cannot read it, and the Forgejo instance has - # REQUIRE_SIGNIN_VIEW=true so anonymous read is also denied. - # MODULE_SDK_PAT is a stored secret with read access to - # fai/module-sdk; we inject it into any outgoing - # https://git.flemming.ws/ URL via insteadOf so cargo's git - # fetch (driven by CARGO_NET_GIT_FETCH_WITH_CLI) authenticates - # transparently. - - name: Configure git URL rewrite for SDK fetch - env: - SDK_PAT: ${{ secrets.MODULE_SDK_PAT }} - run: | - git config --global \ - "url.https://x-access-token:${SDK_PAT}@git.flemming.ws/.insteadOf" \ - "https://git.flemming.ws/" - - name: Install system dependencies run: | apt-get update -qq @@ -73,13 +56,13 @@ jobs: run: cargo fmt --all -- --check - name: Cargo clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: Cargo build (host) - run: cargo build --all-targets + run: cargo build --workspace --all-targets - name: Cargo test (host) - run: cargo test --all-targets + run: cargo test --workspace --all-targets - name: Cargo build (wasm32-wasip2) run: cargo build --release --target wasm32-wasip2 diff --git a/.forgejo/workflows/sign.yml b/.forgejo/workflows/sign.yml deleted file mode 100644 index 25b768e..0000000 --- a/.forgejo/workflows/sign.yml +++ /dev/null @@ -1,223 +0,0 @@ -# F∆I module bundle signing workflow (cosign key-pinning model). -# -# Drop-in template for any module repo under chain-modules/. -# Triggers on tag push (vX.Y.Z) — builds the module, packs the -# .fai bundle, signs the bundle bytes with the Flemming.AI -# signing key (ECDSA P-256), and attaches bundle + .sig sidecar -# to the matching Forgejo Release. -# -# The hub-side verifier ships in 0.12.0+: when an operator sets -# `require_signatures: true`, install downloads the .sig sidecar -# from `.sig` and verifies it against the pinned store -# public key (built-in for `bundled` / `official`, per-store -# config for everything else). -# -# Adopt by copying this file to `.forgejo/workflows/sign.yml` in -# the module repo and replacing the MODULE_NAME placeholder. -# Add the signing key to Forgejo secrets as FAI_SIGNING_KEY (PEM, -# unencrypted, ECDSA P-256). Rotation is a 5-minute key-roll + -# binary re-release; see infra/cosign/README.md. - -name: sign-bundle - -on: - push: - tags: - - 'v*.*.*' - -env: - CARGO_TERM_COLOR: always - RUST_TOOLCHAIN: "1.86" - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - -jobs: - sign: - runs-on: ubuntu-latest - permissions: - contents: write # attach signature to release - - env: - # The canonical module name as written in module.yaml. - # Used in the bundle filename + wasm path. - MODULE_NAME: orchestrator-llm - - steps: - # See fai/platform CI for background on the manual external - # checkout: the DinD runner cannot resolve forgejo:3000, so - # we clone via the external URL with $GITHUB_TOKEN. - - name: Checkout via external URL - run: | - set -eu - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init -q - git remote add origin \ - "https://x-access-token:${GITHUB_TOKEN}@git.flemming.ai/${GITHUB_REPOSITORY}.git" - git fetch --depth=1 origin "$GITHUB_SHA" - git checkout -q FETCH_HEAD - - # The chain-module-sdk dependency lives in another Forgejo org - # behind REQUIRE_SIGNIN_VIEW. MODULE_SDK_PAT (org-level - # secret, read-only on fai/module-sdk) authenticates cargo's - # git fetch via insteadOf. - - name: Configure git URL rewrite for SDK fetch - env: - SDK_PAT: ${{ secrets.MODULE_SDK_PAT }} - run: | - git config --global \ - "url.https://x-access-token:${SDK_PAT}@git.flemming.ai/.insteadOf" \ - "https://git.flemming.ai/" - - - name: Install system dependencies - run: | - apt-get update -qq - apt-get install -y --no-install-recommends \ - curl \ - ca-certificates \ - build-essential \ - pkg-config \ - libssl-dev \ - git \ - jq \ - openssl - - - name: Install Rust toolchain - run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y \ - --profile minimal \ - --default-toolchain "$RUST_TOOLCHAIN" \ - --target wasm32-wasip2 - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Build module (wasm32-wasip2) - run: cargo build --release --target wasm32-wasip2 - - - name: Install fai CLI - # Pinned to the production channel; signing requires the - # `fai pack` command. Override FAI_VERSION when bundle - # format compatibility matters. - run: | - curl -fsSL https://get.chain.flemming.ai | sh -s -- --no-bootstrap - # The installer drops fai at $HOME/.local/bin - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$PATH" - fai --version - - - name: Stage bundle inputs - # The wasm artefact is named after the Cargo package - # (underscored), which may differ from the module name - # in module.yaml (dashed). Resolve it from Cargo.toml so - # the template stays agnostic to the project's naming - # convention. - run: | - set -eu - CARGO_NAME=$(grep -E '^name *= *"' Cargo.toml \ - | head -1 | sed 's/.*"\(.*\)".*/\1/') - mkdir -p staging - cp module.yaml staging/ - cp "target/wasm32-wasip2/release/${CARGO_NAME}.wasm" staging/module.wasm - if [ -f sbom.cdx.json ]; then cp sbom.cdx.json staging/; fi - if [ -f MODULE.md ]; then cp MODULE.md staging/; fi - if [ -f MODULE.de.md ]; then cp MODULE.de.md staging/; fi - - - name: Pack bundle - id: pack - run: | - export PATH="$HOME/.local/bin:$PATH" - BUNDLE="${{ env.MODULE_NAME }}-${{ github.ref_name }}.fai" - fai pack staging --output "$BUNDLE" - echo "bundle=$BUNDLE" >> "$GITHUB_OUTPUT" - ls -la "$BUNDLE" - - - name: Sign bundle (Flemming.AI signing key) - env: - FAI_SIGNING_KEY_PEM: ${{ secrets.FAI_SIGNING_KEY }} - run: | - if [ -z "${FAI_SIGNING_KEY_PEM:-}" ]; then - cat >&2 <<'EOF' - - FAI_SIGNING_KEY secret is empty or undefined. - - This workflow signs module bundles with the Flemming.AI - module-signing key (ECDSA P-256, PKCS#8 PEM, unencrypted). - The matching public key is pinned in the fai-hub binary at - infra/cosign/official.pub — hubs that require_signatures - will reject anything not signed with this key. - - To configure: - 1. Forgejo -> chain-modules org -> Settings -> Secrets - 2. Add secret FAI_SIGNING_KEY (org-level recommended) - 3. Paste the unencrypted PEM (BEGIN PRIVATE KEY ...) - The encrypted source-of-truth lives on Stefan's Mac at - ~/.fai-secrets/flemming-ai-signing.key (passphrase in - mSecure under "Ch∆In — Module Signing Key (Private)"). - - See fai/platform infra/cosign/OPERATOR-HANDOFF.md for the - full setup runbook and rotation process. - EOF - exit 1 - fi - umask 077 - echo "$FAI_SIGNING_KEY_PEM" > /tmp/signing-key.pem - openssl dgst -sha256 \ - -sign /tmp/signing-key.pem \ - -out "${{ steps.pack.outputs.bundle }}.sig.raw" \ - "${{ steps.pack.outputs.bundle }}" - base64 < "${{ steps.pack.outputs.bundle }}.sig.raw" \ - > "${{ steps.pack.outputs.bundle }}.sig" - rm "${{ steps.pack.outputs.bundle }}.sig.raw" - shred -u /tmp/signing-key.pem || rm -f /tmp/signing-key.pem - ls -la "${{ steps.pack.outputs.bundle }}.sig" - - - name: Round-trip verify - # Sanity-check: the freshly-signed bundle must verify - # against the well-known public key before we publish it. - run: | - curl -fsSL https://git.flemming.ai/fai/platform/raw/branch/main/infra/cosign/official.pub \ - -o /tmp/official.pub - openssl dgst -sha256 \ - -verify /tmp/official.pub \ - -signature <(base64 -d < "${{ steps.pack.outputs.bundle }}.sig") \ - "${{ steps.pack.outputs.bundle }}" - - - name: Attach bundle + signature to Forgejo Release - run: | - set -eu - BUNDLE="${{ steps.pack.outputs.bundle }}" - SIG="$BUNDLE.sig" - REL_JSON=$(curl -sS \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - "https://git.flemming.ai/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}") - REL_ID=$(echo "$REL_JSON" | jq -r '.id // empty') - if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then - REL_ID=$(curl -sS -X POST \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - -H "Content-Type: application/json" \ - "https://git.flemming.ai/api/v1/repos/${GITHUB_REPOSITORY}/releases" \ - -d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"draft\":false,\"prerelease\":false,\"body\":\"Auto-generated by sign-bundle workflow\"}" \ - | jq -r '.id') - fi - upload_one() { - local asset_path="$1" asset - asset=$(basename "$asset_path") - EXISTING=$(curl -sS \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - "https://git.flemming.ai/api/v1/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets" \ - | jq -r ".[] | select(.name==\"${asset}\") | .id") - if [ -n "$EXISTING" ]; then - curl -sS -X DELETE \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - "https://git.flemming.ai/api/v1/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets/${EXISTING}" \ - || true - fi - curl -sS -X POST \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - -F "attachment=@${asset_path}" \ - "https://git.flemming.ai/api/v1/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets?name=${asset}" \ - > /dev/null - echo "uploaded $asset" - } - upload_one "$BUNDLE" - upload_one "$SIG" - echo "release ${GITHUB_REF_NAME} ready: https://git.flemming.ai/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" diff --git a/Cargo.toml b/Cargo.toml index 5f7f6c0..689f150 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,19 +5,19 @@ [package] name = "orchestrator_llm" -version = "0.3.2" +version = "0.3.0" edition = "2024" -authors = ["Dr. Stefan Flemming "] +authors = ["Dr. Stefan Flemming "] license = "Apache-2.0" publish = false description = "F∆I orchestrator module — turns natural-language goals into structured execution plans" -repository = "https://git.flemming.ws/chain-modules/orchestrator-llm" +repository = "https://git.flemming.ws/fai-modules/orchestrator-llm" [lib] crate-type = ["cdylib", "rlib"] [dependencies] -chain-module-sdk = { git = "https://git.flemming.ai/fai/chain-module-sdk-rust.git", branch = "main" } +fai-module-sdk = { git = "https://git.flemming.ws/fai/module-sdk.git", branch = "main" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/LICENSE b/LICENSE index cdde5b2..9536d06 100644 --- a/LICENSE +++ b/LICENSE @@ -58,7 +58,7 @@ APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2026 Flemming.AI +Copyright 2026 fai-modules Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/module.wasm b/module.wasm deleted file mode 100644 index 84fafc4..0000000 Binary files a/module.wasm and /dev/null differ diff --git a/module.yaml b/module.yaml index c176aa8..8f78c92 100644 --- a/module.yaml +++ b/module.yaml @@ -1,111 +1,44 @@ -schema_version: 3 -provider: chain +schema_version: 1 name: orchestrator-llm -version: 0.3.1 +version: 0.2.0 +# Capability provided by this module. provides: - capability: orchestrator.plan - version: 0.3.0 - -# Capabilities the plans this module emits typically reference. -# Advisory only — Studio surfaces "you'll also want X" when the -# operator installs orchestrator-llm; `fai admin doctor` flags -# unmet entries; the hub does NOT block install. -consumes: - - capability: llm.chat - version: "^0" - why: "orchestrator-emitted plans dispatch llm.chat steps for downstream LLM-driven work; a flow that runs the plan unmodified needs an llm.chat provider installed." + version: 0.2.0 +# Inputs the invoke function accepts. inputs: - goal: - type: text - description: - en: Operator-stated goal in natural language — what the resulting flow should accomplish. - de: Ziel in natürlicher Sprache — was der erzeugte Flow erreichen soll. - llm_endpoint: - type: text - description: - en: | - LLM HTTP endpoint, e.g. "http://localhost:11434/api/chat" - for Ollama, "https://api.openai.com/v1/chat/completions", - or "https://api.anthropic.com/v1/messages". - de: | - LLM-HTTP-Endpunkt, z. B. "http://localhost:11434/api/chat" - für Ollama, "https://api.openai.com/v1/chat/completions" - oder "https://api.anthropic.com/v1/messages". - llm_model: - type: text - description: - en: Model identifier, e.g. "qwen2.5:14b" for Ollama or "gpt-4o-mini". - de: Modell-Identifier, z. B. "qwen2.5:14b" (Ollama) oder "gpt-4o-mini". - llm_api_key: - type: text - description: - en: Optional bearer token for cloud providers. Empty = no Authorization header. - de: Optionaler Bearer-Token für Cloud-Provider. Leer = kein Authorization-Header. - available_capabilities: - type: text - description: - en: | - Optional JSON-encoded list of installed capabilities. - Omit or pass an empty string to plan without capability - context. - de: | - Optional JSON-codierte Liste der installierten - Capabilities. Leer = ohne Capability-Kontext planen. - store_capabilities: - type: text - description: - en: | - Optional JSON-encoded list of all known store - capabilities (installed + planned + alpha). Used when - the orchestrator needs to suggest installations. - de: | - Optional JSON-codierte Liste aller bekannten Store- - Capabilities (installiert + geplant + alpha). Nötig, - wenn der Orchestrator Installationen vorschlagen soll. + goal: text + # LLM HTTP endpoint, e.g. "http://localhost:11434/api/chat" + # for Ollama, "https://api.openai.com/v1/chat/completions", + # or "https://api.anthropic.com/v1/messages". + llm_endpoint: text + # Model identifier, e.g. "qwen2.5:14b" for Ollama or "gpt-4o-mini". + llm_model: text + # Optional bearer token for cloud providers. Empty string means + # no Authorization header is sent. + llm_api_key: text + # Optional JSON-encoded list of installed capabilities. If + # omitted or empty, the module plans without capability context. + available_capabilities: text + # Optional JSON-encoded list of all known store capabilities + # (installed + planned + alpha). Used when the orchestrator + # needs to suggest installations. + store_capabilities: text +# Outputs produced. outputs: - plan: - type: json - description: - en: JSON-encoded Plan compatible with `fai_hub::plan::Plan`. - de: JSON-codierter Plan, kompatibel mit `fai_hub::plan::Plan`. - model_endpoint: - type: text - description: - en: | - The endpoint the plan was generated against. Empty when - the deterministic stub path (no llm_endpoint) ran. - de: | - Endpunkt, gegen den der Plan erzeugt wurde. Leer beim - deterministischen Stub-Pfad (ohne llm_endpoint). - model_name: - type: text - description: - en: The model name as supplied to the LLM API. - de: An die LLM-API übergebener Modellname. - model_digest: - type: text - description: - en: | - 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?" - de: | - SHA-256-Digest des bedienten Ollama-Modells, wenn - erreichbar. Leer für Cloud-Provider (OpenAI / Anthropic - bieten keine Digest-API) und für den deterministischen - Stub-Pfad. Zusammen mit model_endpoint + model_name - beantwortet das die Audit-Frage "welches genaue Modell - hat diesen Plan erzeugt?" + # JSON-encoded Plan compatible with `fai_hub::plan::Plan`. + plan: json # Permissions required. # # The module makes outbound HTTP to the configured LLM endpoint. +# Default declarations cover the common providers (cloud OpenAI, +# Anthropic, local Ollama on loopback). Operators with different +# LLM endpoints add a host-level entry via operator config +# (Phase 1+) — for Phase 0.5 they fork module.yaml. permissions: - "net: api.openai.com" - "net: api.anthropic.com" diff --git a/src/lib.rs b/src/lib.rs index adc4e2a..7770e0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! orchestrator-llm — turns natural-language goals into Plan JSON. //! //! v0.3.0 migrates from a hand-written wit_bindgen scaffold to the -//! `#[fai_module]` macro from `chain-module-sdk`. The behavior is +//! `#[fai_module]` macro from `fai-module-sdk`. The behavior is //! unchanged from v0.2.1: a configured Ollama-compatible endpoint //! drives a real plan when `llm_endpoint` is set, otherwise the //! deterministic stub from v0.1.0 takes over. @@ -19,7 +19,7 @@ mod plan; pub use plan::{Plan, PlanStep, build_stub_plan, parse_and_validate, validate}; -use chain_module_sdk::prelude::*; +use fai_module_sdk::prelude::*; #[fai_module] pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { @@ -45,8 +45,8 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { .and_then(payload_text) .unwrap_or_default(); - let (plan_json, model_digest) = if llm_endpoint.is_empty() { - (build_stub_plan(goal), None) + let plan_json = if llm_endpoint.is_empty() { + build_stub_plan(goal) } else { let client = make_client(); let params = crate::llm::OllamaParams { @@ -57,16 +57,14 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { available_capabilities: &available_capabilities, store_capabilities: &store_capabilities, }; - let result = crate::llm::generate_plan_with_identity(&client, ¶ms) - .map_err(|e| ModuleError::internal(e.to_string()))?; - (result.plan_json, result.model_digest) + crate::llm::generate_plan(&client, ¶ms) + .map_err(|e| ModuleError::internal(e.to_string()))? }; Ok(Outputs::new() .with_json_str("plan", plan_json) .with_text("model_endpoint", llm_endpoint) - .with_text("model_name", llm_model) - .with_text("model_digest", model_digest.unwrap_or_default())) + .with_text("model_name", llm_model)) } fn payload_text(p: &Payload) -> Option { diff --git a/src/llm.rs b/src/llm.rs index 55a5f58..df6fd88 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -127,43 +127,12 @@ pub fn extract_ollama_content(body: &str) -> Result { .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, -} - /// Generate a plan via the configured LLM endpoint. Returns the /// 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( client: &C, p: &OllamaParams, ) -> Result { - 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( - client: &C, - p: &OllamaParams, -) -> Result { if p.endpoint.is_empty() { return Err(OrchestratorError::MissingEndpoint); } @@ -177,59 +146,7 @@ pub fn generate_plan_with_identity( .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)?; - - 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(client: &C, p: &OllamaParams) -> Option { - 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 { - 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 { - 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 + Ok(plan_json) } #[derive(Debug, thiserror::Error)] @@ -434,123 +351,4 @@ mod tests { 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); - } }