feat: real LLM call via wasi-http (Ollama adapter) — v0.2.0

The orchestrator now calls a configured Ollama-compatible LLM
endpoint over wasi-http and emits the LLM-generated plan back
to the hub. When llm_endpoint is empty, the v0.1.0 deterministic
stub is preserved as fallback (useful for tests and air-gapped
environments).

- module.yaml: bump to 0.2.0; new inputs llm_endpoint,
  llm_model, llm_api_key; net: permissions for OpenAI,
  Anthropic, localhost, 127.0.0.1 (Ollama defaults)
- src/llm.rs: LlmClient trait, OllamaParams, build_user_prompt,
  build_ollama_body, extract_ollama_content, generate_plan;
  9 unit tests for prompt-building and response-parsing
- src/plan.rs: tagged enum with Explain/SaveFlow/RunFlow/
  InstallModule variants; parse_and_validate; build_stub_plan
  (renamed from build_plan); validate(); 6 unit tests
- src/lib.rs: WakiClient implementing LlmClient over the waki
  WASI-0.2 HTTP crate (wasm32-only); empty endpoint → stub
- Cargo.toml: 0.2.0; thiserror dep; waki dep gated on wasm32

17 host-side tests pass. WASM bundle is 386KB (was 74KB; the
delta is wasi-http via waki, expected). The deny path is
exercised by the platform-side integration test in
fai_platform/crates/fai_hub/tests/orchestrator_llm_path.rs.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-04-29 21:27:07 +02:00
parent 3bb8fc9e70
commit 7d07937913
6 changed files with 568 additions and 79 deletions

View file

@ -7,12 +7,12 @@
#![cfg(not(target_arch = "wasm32"))]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use orchestrator_llm::build_plan;
use orchestrator_llm::build_stub_plan;
use serde_json::Value;
#[test]
fn emitted_plan_has_required_top_level_fields() {
let json = build_plan("test goal");
let json = build_stub_plan("test goal");
let parsed: Value = serde_json::from_str(&json).expect("plan must be valid json");
assert!(parsed["schema_version"].is_u64());
assert!(parsed["goal"].is_string());
@ -21,7 +21,7 @@ fn emitted_plan_has_required_top_level_fields() {
#[test]
fn each_step_has_kind_tag() {
let json = build_plan("test goal");
let json = build_stub_plan("test goal");
let parsed: Value = serde_json::from_str(&json).unwrap();
for step in parsed["steps"].as_array().unwrap() {
assert!(step["kind"].is_string(), "step missing kind tag: {step}");
@ -32,7 +32,7 @@ fn each_step_has_kind_tag() {
fn save_flow_step_yaml_parses_as_flow_definition() {
// The save_flow step embeds a YAML flow body. Verify it
// round-trips through serde_yaml.
let json = build_plan("anything");
let json = build_stub_plan("anything");
let parsed: Value = serde_json::from_str(&json).unwrap();
let save_flow_step = parsed["steps"]
.as_array()