Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6575869097 | |||
| 90f9431096 | |||
| 7d52e36606 | |||
| 807cb254e0 | |||
| 5f2544b6e8 | |||
| b58ede3dd4 | |||
| fcf74f179f | |||
| a9b315a1a0 | |||
| d5cb4c0318 | |||
| 8ce52c775e | |||
| 25d8e6570e | |||
| f411e7abf0 | |||
| 86eb72154e |
8 changed files with 560 additions and 49 deletions
|
|
@ -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:
|
||||
# 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
|
||||
# Manual external-URL checkout — see fai/platform CI for
|
||||
# background. Lays this repo at $GITHUB_WORKSPACE.
|
||||
- name: Checkout text-extract via external URL
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
|
|
@ -30,6 +30,23 @@ 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
|
||||
|
|
@ -56,13 +73,13 @@ jobs:
|
|||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Cargo clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: Cargo build (host)
|
||||
run: cargo build --workspace --all-targets
|
||||
run: cargo build --all-targets
|
||||
|
||||
- name: Cargo test (host)
|
||||
run: cargo test --workspace --all-targets
|
||||
run: cargo test --all-targets
|
||||
|
||||
- name: Cargo build (wasm32-wasip2)
|
||||
run: cargo build --release --target wasm32-wasip2
|
||||
|
|
|
|||
223
.forgejo/workflows/sign.yml
Normal file
223
.forgejo/workflows/sign.yml
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# 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 `<wasm_url>.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}"
|
||||
|
|
@ -5,19 +5,19 @@
|
|||
|
||||
[package]
|
||||
name = "orchestrator_llm"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
|
||||
authors = ["Dr. Stefan Flemming <chain@flemming.ai>"]
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
description = "F∆I orchestrator module — turns natural-language goals into structured execution plans"
|
||||
repository = "https://git.flemming.ws/fai-modules/orchestrator-llm"
|
||||
repository = "https://git.flemming.ws/chain-modules/orchestrator-llm"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
fai-module-sdk = { git = "https://git.flemming.ws/fai/module-sdk.git", branch = "main" }
|
||||
chain-module-sdk = { git = "https://git.flemming.ai/fai/chain-module-sdk-rust.git", branch = "main" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
|
|
|||
2
LICENSE
2
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 fai-modules
|
||||
Copyright 2026 Flemming.AI
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
|
|
|||
BIN
module.wasm
Normal file
BIN
module.wasm
Normal file
Binary file not shown.
125
module.yaml
125
module.yaml
|
|
@ -1,44 +1,111 @@
|
|||
schema_version: 1
|
||||
schema_version: 3
|
||||
provider: chain
|
||||
name: orchestrator-llm
|
||||
version: 0.2.0
|
||||
version: 0.3.1
|
||||
|
||||
# Capability provided by this module.
|
||||
provides:
|
||||
- capability: orchestrator.plan
|
||||
version: 0.2.0
|
||||
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."
|
||||
|
||||
# Inputs the invoke function accepts.
|
||||
inputs:
|
||||
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
|
||||
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.
|
||||
|
||||
# Outputs produced.
|
||||
outputs:
|
||||
# JSON-encoded Plan compatible with `fai_hub::plan::Plan`.
|
||||
plan: json
|
||||
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?"
|
||||
|
||||
# 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"
|
||||
|
|
|
|||
16
src/lib.rs
16
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 `fai-module-sdk`. The behavior is
|
||||
//! `#[fai_module]` macro from `chain-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 fai_module_sdk::prelude::*;
|
||||
use chain_module_sdk::prelude::*;
|
||||
|
||||
#[fai_module]
|
||||
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||
|
|
@ -45,8 +45,8 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
|||
.and_then(payload_text)
|
||||
.unwrap_or_default();
|
||||
|
||||
let plan_json = if llm_endpoint.is_empty() {
|
||||
build_stub_plan(goal)
|
||||
let (plan_json, model_digest) = if llm_endpoint.is_empty() {
|
||||
(build_stub_plan(goal), None)
|
||||
} else {
|
||||
let client = make_client();
|
||||
let params = crate::llm::OllamaParams {
|
||||
|
|
@ -57,14 +57,16 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
|||
available_capabilities: &available_capabilities,
|
||||
store_capabilities: &store_capabilities,
|
||||
};
|
||||
crate::llm::generate_plan(&client, ¶ms)
|
||||
.map_err(|e| ModuleError::internal(e.to_string()))?
|
||||
let result = crate::llm::generate_plan_with_identity(&client, ¶ms)
|
||||
.map_err(|e| ModuleError::internal(e.to_string()))?;
|
||||
(result.plan_json, result.model_digest)
|
||||
};
|
||||
|
||||
Ok(Outputs::new()
|
||||
.with_json_str("plan", plan_json)
|
||||
.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> {
|
||||
|
|
|
|||
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()))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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>(
|
||||
client: &C,
|
||||
p: &OllamaParams,
|
||||
) -> 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() {
|
||||
return Err(OrchestratorError::MissingEndpoint);
|
||||
}
|
||||
|
|
@ -146,7 +177,59 @@ pub fn generate_plan<C: LlmClient>(
|
|||
.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)?;
|
||||
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)]
|
||||
|
|
@ -351,4 +434,123 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue