Compare commits

...

13 commits
v0.3.0 ... main

Author SHA1 Message Date
6575869097 chore: org rename fai-modules/fai-plugins -> chain-modules/chain-plugins + LICENSE holder
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1s
Signed-off-by: flemming-it <sf@flemming.it>
2026-06-16 18:25:38 +02:00
90f9431096 docs/ci: chain-*-sdk references (renamed SDK)
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1s
Signed-off-by: flemming-it <sf@flemming.it>
2026-06-16 11:32:24 +02:00
7d52e36606 refactor: depend on renamed chain-module-sdk
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
Signed-off-by: flemming-it <sf@flemming.it>
2026-06-16 11:28:15 +02:00
807cb254e0 chore: contact email platform@ -> chain@flemming.ai
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
Signed-off-by: flemming-it <sf@flemming.it>
2026-06-16 10:16:31 +02:00
5f2544b6e8 docs: old product name F∆I Platform -> Ch∆In
Some checks are pending
CI / Linux x86_64 (Forgejo) (push) Waiting to run
The product is Ch∆In; the old 'F∆I Platform' name survived in module
docs/manifests. Generic 'platform' terms left untouched.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-16 10:15:27 +02:00
b58ede3dd4 refactor: rename to chain:platform WIT + provider chain
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
Tracks the platform's fai->chain rename: capability provider chain,
WIT ABI chain:platform (binding paths/accessors), SDK pin bumped.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-15 22:54:54 +02:00
fcf74f179f feat: schema_version 3 with DE/EN field descriptions
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1s
Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 22:46:17 +02:00
a9b315a1a0 feat: declare consumes: llm.chat@^0 (advisory)
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
Plans this module emits typically dispatch llm.chat steps
for downstream LLM-driven work. A flow that runs an
unmodified plan needs an llm.chat provider installed; the
hub now surfaces this as a 'consumes-advisory' warning at
install time so operators see the dependency hint without
the install being blocked.

See fai/platform commit e5ba892 for the platform-side
consumes: schema.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 21:05:38 +02:00
d5cb4c0318 ci: sign release bundles with FAI_SIGNING_KEY
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1s
Drop-in from fai/platform .forgejo/workflow-templates/
sign-bundle-keypair.yml. Triggers on v*.*.* tag push: builds
wasm32-wasip2, fetches the fai CLI from get.fai.flemming.ai,
packs the bundle, ECDSA P-256 signs it against the org-level
Forgejo secret FAI_SIGNING_KEY, round-trip-verifies against
infra/cosign/official.pub on fai/platform main, and attaches
bundle + .sig to the Forgejo Release via the API.

Signed-off-by: flemming-it <sf@flemming.it>
2026-05-28 23:14:29 +02:00
8ce52c775e chore(module): schema_version 2 + explicit provider
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
Brings module.yaml in line with F∆I 0.12.0's capability naming
convention. The legacy schema_version 1 (with implicit provider)
still loads in 0.12.0 via the grace-period default, but this
bump makes the publisher identity explicit and unblocks
storage-layout migration via 'fai migrate'.

See fai/platform docs/reference/capability-namespaces.yaml +
docs/operations/migration-to-0.12.md.

Signed-off-by: flemming-it <sf@flemming.it>
2026-05-28 11:48:25 +02:00
25d8e6570e chore: SDK URL → git.flemming.ai/fai/module-sdk-rust
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
The fai-module-sdk repo was renamed from `fai/module-sdk` to
`fai/module-sdk-rust` to align with the three SDK families
convention (base + language suffix). Pin updated accordingly.
The old git.flemming.ws URL is also retired in this pass.

Signed-off-by: flemming-it <sf@flemming.it>
2026-05-26 13:16:53 +02:00
f411e7abf0 ci: switch to SDK-aware workflow with MODULE_SDK_PAT auth
All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 2m1s
The previous workflow was a copy of the pre-SDK template — it
did the own-repo checkout but had no provision for fetching
fai-module-sdk from git.flemming.ws/fai/module-sdk. After the
v0.3.0 SDK migration, every CI run failed at the cargo fetch
stage because the runner had no credential for the cross-org
private repo.

Adopt the same pattern that text-extract and llm-chat already
use: a MODULE_SDK_PAT actions secret, CARGO_NET_GIT_FETCH_WITH_CLI=true,
and a 'git config url.X.insteadOf Y' that injects the token
into any outgoing git.flemming.ws URL transparently.

Bumps the module to 0.3.2 (patch — CI infrastructure only,
behaviour unchanged).

Signed-off-by: flemming-it <sf@flemming.it>
2026-05-03 23:25:27 +02:00
86eb72154e 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
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>
2026-05-03 23:06:27 +02:00
8 changed files with 560 additions and 49 deletions

View file

@ -10,16 +10,16 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings" RUSTFLAGS: "-D warnings"
RUST_TOOLCHAIN: "1.86" RUST_TOOLCHAIN: "1.86"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs: jobs:
ci: ci:
name: Linux x86_64 (Forgejo) name: Linux x86_64 (Forgejo)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# See fai/platform docs/architecture/ci.md for why we # Manual external-URL checkout — see fai/platform CI for
# check out via the external URL instead of using # background. Lays this repo at $GITHUB_WORKSPACE.
# actions/checkout. - name: Checkout text-extract via external URL
- name: Checkout via external URL
run: | run: |
set -eu set -eu
mkdir -p "$GITHUB_WORKSPACE" mkdir -p "$GITHUB_WORKSPACE"
@ -30,6 +30,23 @@ jobs:
git fetch --depth=1 origin "$GITHUB_SHA" git fetch --depth=1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD 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 - name: Install system dependencies
run: | run: |
apt-get update -qq apt-get update -qq
@ -56,13 +73,13 @@ jobs:
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Cargo clippy - name: Cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --all-targets -- -D warnings
- name: Cargo build (host) - name: Cargo build (host)
run: cargo build --workspace --all-targets run: cargo build --all-targets
- name: Cargo test (host) - name: Cargo test (host)
run: cargo test --workspace --all-targets run: cargo test --all-targets
- name: Cargo build (wasm32-wasip2) - name: Cargo build (wasm32-wasip2)
run: cargo build --release --target wasm32-wasip2 run: cargo build --release --target wasm32-wasip2

223
.forgejo/workflows/sign.yml Normal file
View 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}"

View file

@ -5,19 +5,19 @@
[package] [package]
name = "orchestrator_llm" name = "orchestrator_llm"
version = "0.3.0" version = "0.3.2"
edition = "2024" edition = "2024"
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"] authors = ["Dr. Stefan Flemming <chain@flemming.ai>"]
license = "Apache-2.0" license = "Apache-2.0"
publish = false publish = false
description = "F∆I orchestrator module — turns natural-language goals into structured execution plans" 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] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"

View file

@ -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. 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

BIN
module.wasm Normal file

Binary file not shown.

View file

@ -1,44 +1,111 @@
schema_version: 1 schema_version: 3
provider: chain
name: orchestrator-llm name: orchestrator-llm
version: 0.2.0 version: 0.3.1
# Capability provided by this module.
provides: provides:
- capability: orchestrator.plan - 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: inputs:
goal: text goal:
# LLM HTTP endpoint, e.g. "http://localhost:11434/api/chat" type: text
# for Ollama, "https://api.openai.com/v1/chat/completions", description:
# or "https://api.anthropic.com/v1/messages". en: Operator-stated goal in natural language — what the resulting flow should accomplish.
llm_endpoint: text de: Ziel in natürlicher Sprache — was der erzeugte Flow erreichen soll.
# Model identifier, e.g. "qwen2.5:14b" for Ollama or "gpt-4o-mini". llm_endpoint:
llm_model: text type: text
# Optional bearer token for cloud providers. Empty string means description:
# no Authorization header is sent. en: |
llm_api_key: text LLM HTTP endpoint, e.g. "http://localhost:11434/api/chat"
# Optional JSON-encoded list of installed capabilities. If for Ollama, "https://api.openai.com/v1/chat/completions",
# omitted or empty, the module plans without capability context. or "https://api.anthropic.com/v1/messages".
available_capabilities: text de: |
# Optional JSON-encoded list of all known store capabilities LLM-HTTP-Endpunkt, z. B. "http://localhost:11434/api/chat"
# (installed + planned + alpha). Used when the orchestrator für Ollama, "https://api.openai.com/v1/chat/completions"
# needs to suggest installations. oder "https://api.anthropic.com/v1/messages".
store_capabilities: text 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: outputs:
# JSON-encoded Plan compatible with `fai_hub::plan::Plan`. plan:
plan: json 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. # Permissions required.
# #
# The module makes outbound HTTP to the configured LLM endpoint. # 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: permissions:
- "net: api.openai.com" - "net: api.openai.com"
- "net: api.anthropic.com" - "net: api.anthropic.com"

View file

@ -1,7 +1,7 @@
//! orchestrator-llm — turns natural-language goals into Plan JSON. //! orchestrator-llm — turns natural-language goals into Plan JSON.
//! //!
//! v0.3.0 migrates from a hand-written wit_bindgen scaffold to the //! 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 //! unchanged from v0.2.1: a configured Ollama-compatible endpoint
//! drives a real plan when `llm_endpoint` is set, otherwise the //! drives a real plan when `llm_endpoint` is set, otherwise the
//! deterministic stub from v0.1.0 takes over. //! 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}; pub use plan::{Plan, PlanStep, build_stub_plan, parse_and_validate, validate};
use fai_module_sdk::prelude::*; use chain_module_sdk::prelude::*;
#[fai_module] #[fai_module]
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> { 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) .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, &params) let result = crate::llm::generate_plan_with_identity(&client, &params)
.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> {

View file

@ -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);
}
} }