mirror of
https://github.com/storytold/craft-ai-integrations.git
synced 2026-11-02 18:06:49 +00:00
docs(adopting): guides for using, declaring and serving AI tools (#93)
* docs(adopting): guides for using, declaring and serving AI tools, with every code block run as a test * docs(adopting): address review: keyed register example, cancel that may be unsupported, indented fences, guide tests in the README * docs(adopting): cancel may be unsupported in the provider table and app guide; a job may end straight from Queued --------- Co-authored-by: scratch <s@s>
This commit is contained in:
@@ -35,7 +35,7 @@ Layout and rules mirror craft-libs: edition 2024, `MIT OR Apache-2.0`, no `unsaf
|
||||
| `craft-ai-comfy-env` | Finds an existing ComfyUI Desktop install, inventories its model files and node packs, downloads missing model files into it. |
|
||||
| `craft-ai-egui` | Theme-neutral egui widgets: status, model picker, consent prompt, job progress, connection panel, history. Needs Rust 1.95. |
|
||||
|
||||
Building on this layer (add a service, a tool, or use one from an app): [docs/ADOPT-A-TOOL.md](docs/ADOPT-A-TOOL.md).
|
||||
Building on this layer (use a tool in your app, declare a new tool, or bring a service or model): start at [docs/adopting](docs/adopting/README.md). The short version is [docs/ADOPT-A-TOOL.md](docs/ADOPT-A-TOOL.md).
|
||||
|
||||
Which crate owns what, and what stays in each app: [docs/LAYOUT.md](docs/LAYOUT.md). The shared-tool
|
||||
contract and its limits: [docs/PLATFORM-CONTRACT.md](docs/PLATFORM-CONTRACT.md). Custom connections,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Runs every Rust block in `docs/adopting/use-in-your-app.md`.
|
||||
//!
|
||||
//! Each test is one block. `adopting_drift` fails if the guide and the block differ.
|
||||
|
||||
// A block shows only the variables it needs, so the rest of the scene stays unused.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
#[path = "support/adopting_scene.rs"]
|
||||
mod adopting_scene;
|
||||
|
||||
use adopting_scene::{Scene, TestResult, scripted_client, tighten_request};
|
||||
|
||||
// Compiles only: it would touch the person's real settings if it ran.
|
||||
#[allow(dead_code)]
|
||||
fn standard_client() -> TestResult {
|
||||
// snippet: standard_client
|
||||
use craft_ai_client::AiClient;
|
||||
use craft_ai_core::AppId;
|
||||
|
||||
// Reads the shared connections file the launcher writes, and keys from the environment
|
||||
// and then the OS keychain.
|
||||
let client = AiClient::standard(AppId::new("wordcraft")?)?;
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_role() -> TestResult {
|
||||
// snippet: catalogue_role
|
||||
use craft_ai_features::builtin;
|
||||
|
||||
let select_subject = builtin()
|
||||
.into_iter()
|
||||
.find(|feature| feature.id == "select-subject")
|
||||
.and_then(|feature| feature.role())
|
||||
.ok_or("the catalogue has no such tool")?;
|
||||
assert_eq!(select_subject.capability.as_str(), "image.segment");
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: status
|
||||
use craft_ai_client::RoleStatus;
|
||||
|
||||
match client.status(&role) {
|
||||
RoleStatus::Ready(ready) => {
|
||||
// Enable the tool. You can show which model will do the work.
|
||||
assert_eq!(ready.model_label, "Tidy 1");
|
||||
}
|
||||
RoleStatus::Unavailable { reason } => {
|
||||
// Grey the tool out and show `reason` exactly as it is.
|
||||
println!("{reason}");
|
||||
}
|
||||
}
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: request
|
||||
use craft_ai_core::{InputRole, Payload, Request};
|
||||
|
||||
let request = Request::new(role.capability.clone())
|
||||
.with_input(InputRole::Prompt, Payload::text("Tighten this paragraph."))
|
||||
.with_param("tone", serde_json::json!("plain"));
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: plan
|
||||
let plan = client.plan(&role, request)?;
|
||||
println!("{} will use {}", role.label, plan.connection_label());
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(true)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: consent
|
||||
use craft_ai_core::{ConsentLedger, ConsentTerm, ErrorKind};
|
||||
|
||||
let plan = client.plan(&role, request)?;
|
||||
let mut saved = ConsentLedger::default(); // in a real app: load it, see below
|
||||
|
||||
// Without consent the job does not start, and no key is read.
|
||||
let refused = client.start(&plan, &saved).expect_err("must refuse");
|
||||
assert_eq!(refused.kind, ErrorKind::ConsentRequired);
|
||||
|
||||
if plan.needs_consent(&saved) {
|
||||
let d = plan.disclosure();
|
||||
println!("Send {} item(s) to {}?", d.items.len(), d.destination);
|
||||
// The person answers with one of ConsentTerm::ALL, shortest first.
|
||||
let now = 1_800_000_000; // in a real app: the current unix time in seconds
|
||||
let ledger_for_this_job = saved.agree(d, ConsentTerm::ThisTime, now);
|
||||
assert!(client.start(&plan, &ledger_for_this_job).is_ok());
|
||||
// "This time" is not remembered: `saved` is unchanged.
|
||||
assert!(plan.needs_consent(&saved));
|
||||
}
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: job
|
||||
use std::time::Duration;
|
||||
|
||||
use craft_ai_core::{ConsentLedger, Event, JobState};
|
||||
|
||||
let plan = client.plan(&role, request)?;
|
||||
let job = client.start(&plan, &ConsentLedger::default())?;
|
||||
|
||||
let final_state = loop {
|
||||
let snapshot = job.poll()?;
|
||||
for event in &snapshot.events {
|
||||
match event {
|
||||
Event::TextDelta { text } => print!("{text}"),
|
||||
Event::Output(output) => println!("result: {:?}", output.payload),
|
||||
Event::Notice { message } => println!("note: {message}"),
|
||||
Event::ToolCall { .. } => {}
|
||||
}
|
||||
}
|
||||
if snapshot.state.is_terminal() {
|
||||
break snapshot.state;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5)); // in an app: poll from your own timer
|
||||
};
|
||||
assert_eq!(final_state, JobState::Succeeded);
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_model() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: missing_model
|
||||
use craft_ai_client::{AiClient, ToolDesk};
|
||||
use craft_ai_core::AppId;
|
||||
|
||||
let desk = ToolDesk::new(
|
||||
AiClient::new(AppId::new("notes")?, shared.clone(), keys.clone()),
|
||||
shared.clone(),
|
||||
None,
|
||||
);
|
||||
let popup = desk.missing_model(&role, &craft_ai_features::builtin())?;
|
||||
// `Some` means the tool cannot run in this app; `None` means it can.
|
||||
assert!(popup.is_some()); // the "notes" app ships no adapter for "tidy"
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_apps() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
let client = scripted_client(&shared, &keys)?;
|
||||
let request = tighten_request(&role);
|
||||
// snippet: two_apps
|
||||
use craft_ai_client::{AiClient, RoleStatus};
|
||||
use craft_ai_core::AppId;
|
||||
|
||||
// `client` ships an adapter for the chosen service, so the suite default is ready with no setup.
|
||||
assert!(matches!(client.status(&role), RoleStatus::Ready(_)));
|
||||
|
||||
// Another app reads the same store but ships no adapter, so it is unavailable, and says so.
|
||||
// `shared` and `keys` are the temporary store and key store this test built.
|
||||
let second = AiClient::new(AppId::new("lightcraft")?, shared.clone(), keys.clone());
|
||||
assert!(matches!(
|
||||
second.status(&role),
|
||||
RoleStatus::Unavailable { .. }
|
||||
));
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Keeps the adoption guides and the code that is tested identical.
|
||||
//!
|
||||
//! Every ```rust block in `docs/adopting` must sit under a `<!-- snippet: NAME -->` line, and the
|
||||
//! same code (after removing its indent) must sit between `// snippet: NAME` and
|
||||
//! `// end snippet` in `adopting_app_guide.rs` or `adopting_tool_guides.rs`, where it runs. So a
|
||||
//! reader can trust that what a guide shows has been run. Edit both together, and format the test
|
||||
//! file with rustfmt first, because the guide shows the formatted code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const GUIDES: &[(&str, &str)] = &[
|
||||
(
|
||||
"README.md",
|
||||
include_str!("../../../docs/adopting/README.md"),
|
||||
),
|
||||
(
|
||||
"use-in-your-app.md",
|
||||
include_str!("../../../docs/adopting/use-in-your-app.md"),
|
||||
),
|
||||
(
|
||||
"declare-a-tool.md",
|
||||
include_str!("../../../docs/adopting/declare-a-tool.md"),
|
||||
),
|
||||
(
|
||||
"bring-a-service.md",
|
||||
include_str!("../../../docs/adopting/bring-a-service.md"),
|
||||
),
|
||||
(
|
||||
"checklist.md",
|
||||
include_str!("../../../docs/adopting/checklist.md"),
|
||||
),
|
||||
(
|
||||
"troubleshooting.md",
|
||||
include_str!("../../../docs/adopting/troubleshooting.md"),
|
||||
),
|
||||
];
|
||||
|
||||
const TESTS: &[(&str, &str)] = &[
|
||||
(
|
||||
"adopting_app_guide.rs",
|
||||
include_str!("adopting_app_guide.rs"),
|
||||
),
|
||||
(
|
||||
"adopting_tool_guides.rs",
|
||||
include_str!("adopting_tool_guides.rs"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Lines of `text` with line endings and trailing spaces removed, so a CRLF checkout compares equal.
|
||||
fn lines(text: &str) -> Vec<String> {
|
||||
text.lines().map(|l| l.trim_end().to_owned()).collect()
|
||||
}
|
||||
|
||||
/// Removes the indent shared by every non-blank line.
|
||||
fn dedent(block: &[String]) -> String {
|
||||
let cut = block
|
||||
.iter()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| l.len() - l.trim_start().len())
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let cut_line = |l: &String| l.get(cut..).unwrap_or("").to_owned();
|
||||
block.iter().map(cut_line).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// A code fence line: its marker (`` ` `` or `~`), its length and the text after it. Markdown allows
|
||||
/// the fence to be indented, as it is under a list item.
|
||||
fn fence(line: &str) -> Option<(char, usize, &str)> {
|
||||
let text = line.trim_start();
|
||||
let marker = text.chars().next().filter(|c| matches!(c, '`' | '~'))?;
|
||||
let len = text.chars().take_while(|&c| c == marker).count();
|
||||
(len >= 3).then(|| (marker, len, text.get(len..).unwrap_or("").trim()))
|
||||
}
|
||||
|
||||
fn opens_rust(line: &str) -> bool {
|
||||
fence(line).is_some_and(|(_, _, info)| info.starts_with("rust"))
|
||||
}
|
||||
|
||||
/// The named blocks in a guide. Panics, naming the file, on a Rust block with no name above it.
|
||||
fn guide_snippets(file: &str, text: &str) -> Vec<(String, String)> {
|
||||
let all = lines(text);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < all.len() {
|
||||
if opens_rust(&all[i]) {
|
||||
panic!(
|
||||
"{file} line {}: a Rust block needs a `<!-- snippet: NAME -->` line above it",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
if let Some(name) = all[i]
|
||||
.trim_start()
|
||||
.strip_prefix("<!-- snippet: ")
|
||||
.and_then(|r| r.strip_suffix(" -->"))
|
||||
{
|
||||
assert!(
|
||||
all.get(i + 1).is_some_and(|l| opens_rust(l)),
|
||||
"{file}: snippet {name} is not followed by a Rust block"
|
||||
);
|
||||
let (marker, len, _) = fence(&all[i + 1]).unwrap_or(('`', 3, ""));
|
||||
let start = i + 2;
|
||||
// The closing fence uses the opening marker, is at least as long, and has no text after it.
|
||||
let end = all[start..]
|
||||
.iter()
|
||||
.position(|l| {
|
||||
fence(l).is_some_and(|(m, n, info)| m == marker && n >= len && info.is_empty())
|
||||
})
|
||||
.map(|n| start + n)
|
||||
.unwrap_or_else(|| panic!("{file}: snippet {name} is never closed"));
|
||||
out.push((name.to_owned(), dedent(&all[start..end])));
|
||||
i = end;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tested blocks in a test file.
|
||||
fn test_snippets(text: &str) -> Vec<(String, String)> {
|
||||
let all = lines(text);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < all.len() {
|
||||
if let Some(name) = all[i].trim().strip_prefix("// snippet: ") {
|
||||
let start = i + 1;
|
||||
let end = all[start..]
|
||||
.iter()
|
||||
.position(|l| l.trim() == "// end snippet")
|
||||
.map(|n| start + n)
|
||||
.unwrap_or_else(|| panic!("snippet {name} has no `// end snippet`"));
|
||||
out.push((name.to_owned(), dedent(&all[start..end])));
|
||||
i = end;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect(
|
||||
sources: &[(&str, &str)],
|
||||
read: fn(&str, &str) -> Vec<(String, String)>,
|
||||
) -> BTreeMap<String, (String, String)> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (file, text) in sources {
|
||||
for (name, code) in read(file, text) {
|
||||
let clash = map.insert(name.clone(), (file.to_string(), code));
|
||||
assert!(clash.is_none(), "snippet name {name} is used twice");
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_guide_block_is_the_code_that_runs() {
|
||||
let guides = collect(GUIDES, guide_snippets);
|
||||
let tests = collect(TESTS, |_, text| test_snippets(text));
|
||||
assert!(!guides.is_empty(), "no snippets found in the guides");
|
||||
|
||||
for (name, (file, shown)) in &guides {
|
||||
let (test_file, run) = tests
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("{file}: snippet {name} has no test that runs it"));
|
||||
assert_eq!(
|
||||
shown, run,
|
||||
"{file}: snippet {name} differs from the code in {test_file}"
|
||||
);
|
||||
}
|
||||
for (name, (test_file, _)) in &tests {
|
||||
assert!(
|
||||
guides.contains_key(name),
|
||||
"{test_file}: snippet {name} is not shown in any guide"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_comparison_notices_a_changed_block() {
|
||||
// Proves the check can fail: the same name with different code must not compare equal.
|
||||
let guide = guide_snippets("g.md", "<!-- snippet: a -->\n```rust\nlet x = 1;\n```\n");
|
||||
let run = test_snippets("fn a() {\n // snippet: a\n let x = 2;\n // end snippet\n}\n");
|
||||
assert_eq!(guide[0].0, run[0].0);
|
||||
assert_ne!(guide[0].1, run[0].1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_under_a_list_item_is_still_found() {
|
||||
let guide = "- step\n\n <!-- snippet: a -->\n ```rust\n let x = 1;\n ```\n";
|
||||
let found = guide_snippets("g.md", guide);
|
||||
assert_eq!(found, vec![("a".to_owned(), "let x = 1;".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "needs a `<!-- snippet: NAME -->` line")]
|
||||
fn an_indented_rust_block_with_no_name_is_refused() {
|
||||
guide_snippets("g.md", "- step\n\n ```rust\n let x = 1;\n ```\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_longer_or_other_marker_fence_does_not_close_early() {
|
||||
let guide = "<!-- snippet: a -->\n````rust\nlet s = 1;\n```\nlet t = 2;\n````\n";
|
||||
let found = guide_snippets("g.md", guide);
|
||||
assert_eq!(found[0].1, "let s = 1;\n```\nlet t = 2;");
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Runs every Rust block in `docs/adopting/declare-a-tool.md` and
|
||||
//! `docs/adopting/bring-a-service.md`.
|
||||
//!
|
||||
//! Each test is one block. `adopting_drift` fails if the guide and the block differ.
|
||||
|
||||
// A block shows only the variables it needs, so the rest of the scene stays unused.
|
||||
#![allow(unused_variables)]
|
||||
|
||||
#[path = "support/adopting_scene.rs"]
|
||||
mod adopting_scene;
|
||||
|
||||
use adopting_scene::{Scene, TestResult};
|
||||
|
||||
#[test]
|
||||
fn well_known() -> TestResult {
|
||||
// snippet: well_known
|
||||
use craft_ai_core::capability::well_known;
|
||||
|
||||
let known: Vec<String> = well_known().iter().map(|c| c.to_string()).collect();
|
||||
assert!(known.contains(&"image.inpaint".to_string()));
|
||||
assert!(known.contains(&"image.segment".to_string()));
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declare_and_choose() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
// snippet: declare_and_choose
|
||||
use craft_ai_core::{
|
||||
CapabilityId, CapabilitySpec, ConnectionId, InputRole, ModelId, ModelInfo,
|
||||
};
|
||||
use craft_ai_core::{Role, RoleId};
|
||||
|
||||
// `launcher` is a `ToolDesk`, and `shared` is the connections file. See the tests for the scene.
|
||||
// 1. The declaration.
|
||||
let describe = Role {
|
||||
id: RoleId::new("describe-photo")?,
|
||||
label: "Describe photograph".into(),
|
||||
capability: CapabilityId::parse("image.describe")?,
|
||||
inputs: vec![InputRole::Source],
|
||||
};
|
||||
launcher.register_tool(describe.clone())?;
|
||||
|
||||
// 2. A model that can do it, offered by a connection the person already has.
|
||||
let vision = ModelInfo::new(ModelId::new("vision-1")?, "Vision 1").with(
|
||||
CapabilitySpec::new(describe.capability.clone())
|
||||
.accepting(InputRole::Source, 1)
|
||||
.requiring(InputRole::Source),
|
||||
);
|
||||
let service = serde_json::from_value(serde_json::json!({
|
||||
"id": "vision-service", "label": "Vision (on this computer)", "provider": "vision",
|
||||
"locality": "local", "auth": {"kind": "none"}, "billing": "free",
|
||||
"models": [vision.clone()],
|
||||
}))?;
|
||||
shared.update(|store| store.connections.push(service))?;
|
||||
|
||||
// 3. The suite-wide choice. It is checked before anything is written.
|
||||
launcher.choose(&describe, &ConnectionId::new("vision-service")?, &vision.id)?;
|
||||
assert_eq!(launcher.custom_tools()?.len(), 2); // "tighten-text" from the scene, and this one
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_is_a_contract() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
// snippet: id_is_a_contract
|
||||
use craft_ai_client::{AiClient, RoleStatus};
|
||||
use craft_ai_core::{AppId, CapabilityId, InputRole, Role, RoleId};
|
||||
|
||||
let honest = Role {
|
||||
id: RoleId::new("shorten")?,
|
||||
label: "Shorten".into(),
|
||||
capability: CapabilityId::parse("text.complete")?,
|
||||
inputs: vec![InputRole::Prompt],
|
||||
};
|
||||
launcher.register_tool(honest.clone())?;
|
||||
|
||||
// Another app reuses the id for something else. It is refused, not quietly accepted.
|
||||
let imposter = Role {
|
||||
capability: CapabilityId::parse("image.generate")?,
|
||||
..honest
|
||||
};
|
||||
let app = AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone());
|
||||
match app.status(&imposter) {
|
||||
RoleStatus::Unavailable { reason } => assert!(reason.contains("does not match")),
|
||||
RoleStatus::Ready(_) => panic!("a changed contract must not resolve"),
|
||||
}
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_request() -> TestResult {
|
||||
// snippet: describe_request
|
||||
use craft_ai_core::{CapabilityId, InputRole, Payload, Request};
|
||||
|
||||
let request = Request::new(CapabilityId::parse("image.describe")?).with_input(
|
||||
InputRole::Source,
|
||||
Payload::inline("image/png", vec![0x89, b'P', b'N', b'G'])?,
|
||||
);
|
||||
assert_eq!(request.count(&InputRole::Source), 1);
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shouter_provider() -> TestResult {
|
||||
// snippet: shouter_provider
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use craft_ai_core::payload::Limits;
|
||||
use craft_ai_core::{
|
||||
AiError, CapabilityId, CapabilitySpec, ErrorKind, Event, InputRole, JobId, JobSnapshot,
|
||||
JobState, ModelId, ModelInfo, Output, Payload, Provider, Request,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
Queued,
|
||||
Running,
|
||||
Done,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
phase: Phase,
|
||||
answer: String,
|
||||
}
|
||||
|
||||
struct Shouter {
|
||||
model: ModelInfo,
|
||||
jobs: Mutex<HashMap<String, Job>>,
|
||||
next: AtomicU32,
|
||||
}
|
||||
|
||||
impl Shouter {
|
||||
fn new() -> Result<Self, AiError> {
|
||||
let capability = CapabilityId::parse("text.complete").map_err(bad)?;
|
||||
let spec = CapabilitySpec::new(capability)
|
||||
.accepting(InputRole::Prompt, 1)
|
||||
.requiring(InputRole::Prompt);
|
||||
let id = ModelId::new("shout-1").map_err(bad)?;
|
||||
Ok(Self {
|
||||
model: ModelInfo::new(id, "Shout 1").with(spec),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
next: AtomicU32::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Job>>, AiError> {
|
||||
self.jobs
|
||||
.lock()
|
||||
.map_err(|_| AiError::new(ErrorKind::Internal, "the job list is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(e: impl std::fmt::Display) -> AiError {
|
||||
AiError::new(ErrorKind::Internal, e.to_string())
|
||||
}
|
||||
|
||||
fn unknown() -> AiError {
|
||||
AiError::invalid("that job does not exist")
|
||||
}
|
||||
|
||||
impl Provider for Shouter {
|
||||
fn models(&self) -> Result<Vec<ModelInfo>, AiError> {
|
||||
Ok(vec![self.model.clone()])
|
||||
}
|
||||
|
||||
fn submit(&self, model: &ModelId, request: &Request) -> Result<JobId, AiError> {
|
||||
// Refuse first. Nothing has been sent anywhere yet.
|
||||
if *model != self.model.id {
|
||||
return Err(AiError::unsupported("this service has no such model"));
|
||||
}
|
||||
let spec = self.model.spec_for(&request.capability).ok_or_else(|| {
|
||||
AiError::unsupported(format!("this model cannot do {}", request.capability))
|
||||
})?;
|
||||
request.validate(spec, &Limits::default())?;
|
||||
let answer = match request.inputs.first().map(|i| &i.payload) {
|
||||
Some(Payload::Text { text }) => text.to_uppercase(),
|
||||
_ => return Err(AiError::invalid("a text prompt is needed")),
|
||||
};
|
||||
let name = format!("job-{}", self.next.fetch_add(1, Ordering::SeqCst));
|
||||
self.lock()?.insert(
|
||||
name.clone(),
|
||||
Job {
|
||||
phase: Phase::Queued,
|
||||
answer,
|
||||
},
|
||||
);
|
||||
JobId::new(&name).map_err(bad)
|
||||
}
|
||||
|
||||
fn poll(&self, job: &JobId) -> Result<JobSnapshot, AiError> {
|
||||
let mut jobs = self.lock()?;
|
||||
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
|
||||
Ok(match entry.phase {
|
||||
Phase::Queued => {
|
||||
entry.phase = Phase::Running;
|
||||
JobSnapshot::new(JobState::Running { percent: Some(50) })
|
||||
}
|
||||
Phase::Running => {
|
||||
entry.phase = Phase::Done;
|
||||
let mut done = JobSnapshot::new(JobState::Succeeded);
|
||||
done.events.push(Event::Output(Output {
|
||||
payload: Payload::text(entry.answer.clone()),
|
||||
label: None,
|
||||
}));
|
||||
done // the answer is delivered once
|
||||
}
|
||||
Phase::Done => JobSnapshot::new(JobState::Succeeded),
|
||||
Phase::Cancelled => JobSnapshot::new(JobState::Cancelled),
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&self, job: &JobId) -> Result<(), AiError> {
|
||||
let mut jobs = self.lock()?;
|
||||
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
|
||||
if matches!(entry.phase, Phase::Queued | Phase::Running) {
|
||||
entry.phase = Phase::Cancelled;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Prove it with the checks every provider must pass.
|
||||
use craft_ai_core::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job};
|
||||
|
||||
let provider = Shouter::new()?;
|
||||
let model = provider.model.id.clone();
|
||||
let capability = CapabilityId::parse("text.complete")?;
|
||||
let good = Request::new(capability.clone()).with_input(InputRole::Prompt, Payload::text("hi"));
|
||||
let no_prompt = Request::new(capability);
|
||||
|
||||
run_lifecycle(&provider, &model, &good)?;
|
||||
run_cancel(&provider, &model, &good)?;
|
||||
run_unknown_job(&provider)?;
|
||||
run_rejects(&provider, &model, &no_prompt, ErrorKind::InvalidRequest)?;
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_adapter() -> TestResult {
|
||||
let Scene {
|
||||
_dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
} = Scene::new(false)?;
|
||||
// snippet: register_adapter
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ai_client::{AiClient, ProviderFactory};
|
||||
use craft_ai_core::mock::{MockProvider, Outcome, Script};
|
||||
use craft_ai_core::{AppId, Provider};
|
||||
|
||||
let mut factory = ProviderFactory::standard();
|
||||
factory.register("acme", |connection, key| {
|
||||
// `key` is already read from the key store. The adapter never touches a keychain.
|
||||
// A real adapter sends `key` with each request. This stand-in has nowhere to send it.
|
||||
let _ = key;
|
||||
let script = Script {
|
||||
running_polls: 1,
|
||||
text_chunks: vec![],
|
||||
outcome: Outcome::Succeed(vec![]),
|
||||
};
|
||||
Ok(Arc::new(MockProvider::new(connection.models.clone(), script)) as Arc<dyn Provider>)
|
||||
});
|
||||
assert!(factory.knows("acme"));
|
||||
assert!(!ProviderFactory::standard().knows("acme"));
|
||||
let app =
|
||||
AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone()).with_factory(factory);
|
||||
// end snippet
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The world every adoption guide block runs in, with no network and no real settings.
|
||||
//!
|
||||
//! The launcher has already set things up: one tool ("Tighten text"), one connection ("Tidy", either
|
||||
//! on this computer or online with a saved key) and the person's choice of its model. An app then
|
||||
//! builds a client that ships a scripted adapter for the "tidy" service. Guide blocks start from
|
||||
//! here, so they can show only the lines a reader needs.
|
||||
|
||||
// Each guide test file uses a different part of this module.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ai_client::{AiClient, ProviderFactory, ToolDesk};
|
||||
use craft_ai_core::mock::{MockProvider, Outcome, Script};
|
||||
use craft_ai_core::{
|
||||
AppId, CapabilityId, CapabilitySpec, Connection, ConnectionId, InputRole, ModelId, ModelInfo,
|
||||
Output, Payload, Provider, Request, Role, RoleId, SecretRef, SecretString,
|
||||
};
|
||||
use craft_ai_native::secrets::{MemoryStore, SecretStore};
|
||||
use craft_ai_native::shared::SharedStore;
|
||||
|
||||
/// What a guide test returns, so a block can use `?` as an app would.
|
||||
pub type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
/// The launcher's side of the story, already done.
|
||||
pub struct Scene {
|
||||
/// Keeps the temporary folder alive until the test ends.
|
||||
pub _dir: tempfile::TempDir,
|
||||
pub shared: SharedStore,
|
||||
pub keys: Arc<MemoryStore>,
|
||||
pub role: Role,
|
||||
pub model: ModelInfo,
|
||||
pub launcher: ToolDesk,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// A temporary store with the tool declared and its model chosen. `online` makes the
|
||||
/// connection a metered remote service with a saved key, so using it needs consent.
|
||||
pub fn new(online: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let shared = SharedStore::new(dir.path().join("connections.json"));
|
||||
let keys = Arc::new(MemoryStore::new());
|
||||
let role = Role {
|
||||
id: RoleId::new("tighten-text")?,
|
||||
label: "Tighten text".into(),
|
||||
capability: CapabilityId::parse("text.complete")?,
|
||||
inputs: vec![InputRole::Prompt],
|
||||
};
|
||||
let model = ModelInfo::new(ModelId::new("tidy-1")?, "Tidy 1").with(
|
||||
CapabilitySpec::new(role.capability.clone())
|
||||
.accepting(InputRole::Prompt, 1)
|
||||
.requiring(InputRole::Prompt),
|
||||
);
|
||||
let connection = if online {
|
||||
keys.set(
|
||||
&SecretRef::new("tidy-key")?,
|
||||
&SecretString::new("not-a-real-key"),
|
||||
)?;
|
||||
serde_json::json!({
|
||||
"id": "tidy", "label": "Tidy (online)", "provider": "tidy",
|
||||
"endpoint": "https://tidy.example.com/v1", "locality": "remote",
|
||||
"auth": {"kind": "api_key", "secret": "tidy-key"},
|
||||
"billing": "metered", "models": [model.clone()],
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"id": "tidy", "label": "Tidy (on this computer)", "provider": "tidy",
|
||||
"locality": "local", "auth": {"kind": "none"},
|
||||
"billing": "free", "models": [model.clone()],
|
||||
})
|
||||
};
|
||||
let connection: Connection = serde_json::from_value(connection)?;
|
||||
shared.update(|store| store.connections.push(connection))?;
|
||||
let launcher = ToolDesk::new(
|
||||
AiClient::new(AppId::new("launcher")?, shared.clone(), keys.clone()),
|
||||
shared.clone(),
|
||||
None,
|
||||
);
|
||||
launcher.register_tool(role.clone())?;
|
||||
launcher.choose(&role, &ConnectionId::new("tidy")?, &model.id)?;
|
||||
Ok(Self {
|
||||
_dir: dir,
|
||||
shared,
|
||||
keys,
|
||||
role,
|
||||
model,
|
||||
launcher,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's client. This app ships an adapter for the "tidy" service (a scripted stand-in that
|
||||
/// answers "Shorter."), so the launcher's choice is ready here with no setup.
|
||||
pub fn scripted_client(
|
||||
shared: &SharedStore,
|
||||
keys: &Arc<MemoryStore>,
|
||||
) -> Result<AiClient, Box<dyn std::error::Error>> {
|
||||
let mut factory = ProviderFactory::standard();
|
||||
factory.register("tidy", |connection, _key| {
|
||||
let script = Script {
|
||||
running_polls: 1,
|
||||
text_chunks: vec![],
|
||||
outcome: Outcome::Succeed(vec![Output {
|
||||
payload: Payload::text("Shorter."),
|
||||
label: None,
|
||||
}]),
|
||||
};
|
||||
Ok(Arc::new(MockProvider::new(connection.models.clone(), script)) as Arc<dyn Provider>)
|
||||
});
|
||||
Ok(AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone()).with_factory(factory))
|
||||
}
|
||||
|
||||
/// A request for the tool: one prompt.
|
||||
pub fn tighten_request(role: &Role) -> Request {
|
||||
Request::new(role.capability.clone())
|
||||
.with_input(InputRole::Prompt, Payload::text("Tighten this paragraph."))
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# Add a service or a tool, and use it from an app
|
||||
|
||||
**The full guides are in [docs/adopting](adopting/README.md):** use a tool in an app, declare a new tool, bring a service or model, a checklist and troubleshooting. This page is the short version.
|
||||
|
||||
For someone building on this layer: you ship a service, a kind of tool, or an app that uses them.
|
||||
You should not need to edit this repository or anyone else's app. This page is the path; the
|
||||
runnable version of every step is `crates/ai-client/tests/third_party_adoption.rs`, which CI runs,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Adopting the Craft AI layer
|
||||
|
||||
You want an AI tool in your app, and you want it to work the way the rest of the suite does:
|
||||
a person sets up a model once, and your tool uses it.
|
||||
|
||||
This section is for **developers**. It covers adding a tool to an app, declaring a new kind of
|
||||
tool, and bringing a service or model that can run one. You do not need to edit this repository,
|
||||
the launcher or anyone else's app.
|
||||
|
||||
## The idea in one minute
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[Person] -->|sets up once| L[Launcher or your Preferences]
|
||||
L -->|writes| S[(Shared store<br/>connections, models,<br/>choices)]
|
||||
A1[Your app] -->|reads| S
|
||||
A2[Another app] -->|reads| S
|
||||
A1 -->|status, plan, start| C[AiClient]
|
||||
C -->|your adapter| M[Model or service]
|
||||
```
|
||||
|
||||
Five things, in the order you meet them:
|
||||
|
||||
| Word | Meaning |
|
||||
|---|---|
|
||||
| **Tool** (a `Role`) | A job your app offers, such as "Select subject". It has an id, one capability and the inputs it sends. |
|
||||
| **Connection** | A place that can run models: a hosted service, a server on this computer, or the built-in engine. It holds the models it offers. Keys live in the keychain, not here. |
|
||||
| **Binding** | The person's choice of connection and model for one tool. A suite default, with an optional override per app. |
|
||||
| **Adapter** (a `Provider`) | The code in your app that talks to a connection. An app that has no adapter for a connection says so plainly. |
|
||||
| **Job** | One run. You start it, poll it, and can cancel it. |
|
||||
|
||||
Your app owns the interface and the result. The shared layer owns everything between.
|
||||
|
||||
| The shared layer does | Your app does |
|
||||
|---|---|
|
||||
| Stores connections, models, choices, consent and history | Draws the tool: a prompt bar, a button, a click on the image |
|
||||
| Reads keys, only at the moment a job starts | Builds the request from its own document |
|
||||
| Checks the model can take your inputs | Shows the disclosure and asks for consent |
|
||||
| Refuses to send data without consent | Applies the result, with its own undo |
|
||||
| Runs and tracks the job | Offers cancel, progress and readable errors |
|
||||
|
||||
## Pick your path
|
||||
|
||||
| I want to... | Read | Time |
|
||||
|---|---|---|
|
||||
| Add a tool that already exists (Select subject, Remove objects) to my app | [Use AI tools in your app](use-in-your-app.md) | 30 minutes to a first run |
|
||||
| Offer a kind of tool the suite does not have yet | [Declare a new tool](declare-a-tool.md) | 15 minutes |
|
||||
| Make my service, API, server or model runnable by the tools | [Bring a service or model](bring-a-service.md) | 1 hour for a `Provider` |
|
||||
| Check I did it properly before I ship | [Checklist](checklist.md) | 10 minutes |
|
||||
| Work out why it says "Unavailable" | [Troubleshooting](troubleshooting.md) | 5 minutes |
|
||||
|
||||
Most people need the first guide and the checklist.
|
||||
|
||||
## See it run first
|
||||
|
||||
Every page here is checked by the compiler. The whole story, with two independent apps and a
|
||||
third one that cannot run the service, is one test:
|
||||
|
||||
```sh
|
||||
cargo test -p craft-ai-client --test third_party_adoption -j 8
|
||||
cargo test -p craft-ai-client --doc -j 8
|
||||
cargo test -p craft-ai-client --test adopting_app_guide --test adopting_tool_guides --test adopting_drift -j 8
|
||||
cargo run -p craft-ai-client --example custom_tool -j 8
|
||||
```
|
||||
|
||||
None of them touches the network or your real connections. They use a temporary folder and a
|
||||
scripted stand-in service. If a page and the code disagree, the code is right and the page has
|
||||
a bug. Please fix the page.
|
||||
|
||||
## What you get, and what you do not
|
||||
|
||||
You get: one place where keys, models and choices live; consent that is scoped to your app, the
|
||||
connection, the destination and the kind of data; a job model that works for a two-second edit
|
||||
and a two-minute video; a log; and shared checks that any adapter can run.
|
||||
|
||||
You do not get, today:
|
||||
|
||||
- A background service. The runtime is **in the app's own process**. Your app links
|
||||
`craft-ai-client` and runs its adapters itself. A shared daemon is a separate discussion.
|
||||
- Dynamic code loading. A tool is a declaration, and an adapter is code you compile into your app.
|
||||
- Automatic model installation. Setting up a model is the launcher's Set up AI window, or your
|
||||
own screen built from the shared pieces, and it always asks first.
|
||||
- A stable API. The contract is accepted in `craft-libs`, but this workspace still carries
|
||||
newer additions that are under review ([CORE-SYNC](../CORE-SYNC.md)). Pin a revision.
|
||||
|
||||
Read [STATUS](../STATUS.md) before you rely on a provider. It lists what ran against a scripted
|
||||
server, what ran for real on one machine, and what has not run at all. Your own docs should use the
|
||||
same levels, and never claim more.
|
||||
|
||||
## Ground rules
|
||||
|
||||
These come from the people who use the suite. The checks enforce most of them.
|
||||
|
||||
1. **One model choice, everywhere.** Never hard-code a model or an address. Read the binding.
|
||||
2. **No silent substitution.** If the chosen model cannot run, say so. Do not run a different one.
|
||||
3. **Nothing leaves the computer without a yes.** The layer enforces consent. Do not route around it.
|
||||
4. **Keys never travel.** Not through environment variables you set, command lines, logs or errors.
|
||||
5. **Plain words.** Everything a person reads avoids engine names, hardware words and developer terms.
|
||||
6. **Every app works with no AI.** An unset tool is greyed out with a reason, never broken.
|
||||
7. **Say what you ran.** Configured, verified and executable are three different claims.
|
||||
|
||||
## Where things live
|
||||
|
||||
| Folder | What is in it |
|
||||
|---|---|
|
||||
| `crates/ai-core` | The data model and the `Provider` trait. No I/O. Builds for wasm. |
|
||||
| `crates/ai-client` | `AiClient`, `ToolDesk` and `ProviderFactory`. What an app holds. |
|
||||
| `crates/ai-features` | The catalogue of tools and how each can be set up. |
|
||||
| `crates/ai-native` | The shared store file, key stores, consent and history files. |
|
||||
| `crates/ai-egui` | Optional theme-neutral widgets. |
|
||||
| `docs/` | These guides, the [layout](../LAYOUT.md), the [platform contract](../PLATFORM-CONTRACT.md) and [custom connections and sources](../custom-connections-and-sources.md). |
|
||||
|
||||
## Getting help and giving back
|
||||
|
||||
Open an issue on `storytold/craft-ai-integrations`. Say which guide you followed, which step was
|
||||
unclear and what you expected. A step that needed something the page did not say is a bug in the
|
||||
page. Small fixes to these pages are welcome as pull requests. See [CONTRIBUTING](../../CONTRIBUTING.md).
|
||||
@@ -0,0 +1,267 @@
|
||||
# Bring a service or a model
|
||||
|
||||
Use this page when you want something to **run** a tool: a hosted API, a server on the person's
|
||||
computer, or a model file. Pick the shortest route that works.
|
||||
|
||||
| You have | Write | Rust? |
|
||||
|---|---|---|
|
||||
| A hosted API with plain request and reply (or submit and poll) | One settings file for the declarative adapter | No |
|
||||
| A hosted or local API with another shape | A `Provider`, registered by name | Yes |
|
||||
| A model file that should run inside the app, on this computer | A `ModelRunner` for a built-in engine | Yes |
|
||||
| A ComfyUI workflow | Import it in Set up AI (see the [status page](../STATUS.md) for which tools this covers) | No |
|
||||
|
||||
Every Rust block here is a real test (`cargo test -p craft-ai-client --test adopting_tool_guides`), and
|
||||
`--test adopting_drift` fails if a block differs from the tested code.
|
||||
|
||||
You never edit an app. An app that does not ship your adapter reports your service as
|
||||
unavailable, in plain words, and asks for no consent and reads no key.
|
||||
|
||||
## Route 1: a settings file
|
||||
|
||||
If the API is "send JSON, get JSON" or "submit, then poll", describe it as data. No code runs
|
||||
from the file. Read [the design](../design/declarative-adapter.md) for the format and its limits.
|
||||
It does not cover signed requests, token refresh, file uploads or streaming.
|
||||
|
||||
## Route 2: write a `Provider`
|
||||
|
||||
A `Provider` has five methods:
|
||||
|
||||
| Method | Must |
|
||||
|---|---|
|
||||
| `models()` | List the models, each with the capabilities and inputs it really supports. This is also the "test connection" call. |
|
||||
| `estimate(model, request)` | Optional. A price estimate when you can give one. |
|
||||
| `submit(model, request)` | Check the request against the model's spec and refuse **before sending anything** with `Unsupported` or `InvalidRequest`. Then start the job and return its id. |
|
||||
| `poll(job)` | Return the state and only the events that are new since the last poll. A finished state must stay the same. |
|
||||
| `cancel(job)` | Stop the job. If you cannot stop an unfinished job, return `Unsupported`. Calling it twice, or on a finished job, is not an error. An unknown job is. |
|
||||
|
||||
The trait is synchronous on purpose. Do the slow work on your own thread and let `poll` read the
|
||||
state. Here is a complete one, small enough to read in a minute. It "shouts" a prompt back:
|
||||
|
||||
<!-- snippet: shouter_provider -->
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use craft_ai_core::payload::Limits;
|
||||
use craft_ai_core::{
|
||||
AiError, CapabilityId, CapabilitySpec, ErrorKind, Event, InputRole, JobId, JobSnapshot,
|
||||
JobState, ModelId, ModelInfo, Output, Payload, Provider, Request,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
Queued,
|
||||
Running,
|
||||
Done,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
phase: Phase,
|
||||
answer: String,
|
||||
}
|
||||
|
||||
struct Shouter {
|
||||
model: ModelInfo,
|
||||
jobs: Mutex<HashMap<String, Job>>,
|
||||
next: AtomicU32,
|
||||
}
|
||||
|
||||
impl Shouter {
|
||||
fn new() -> Result<Self, AiError> {
|
||||
let capability = CapabilityId::parse("text.complete").map_err(bad)?;
|
||||
let spec = CapabilitySpec::new(capability)
|
||||
.accepting(InputRole::Prompt, 1)
|
||||
.requiring(InputRole::Prompt);
|
||||
let id = ModelId::new("shout-1").map_err(bad)?;
|
||||
Ok(Self {
|
||||
model: ModelInfo::new(id, "Shout 1").with(spec),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
next: AtomicU32::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Job>>, AiError> {
|
||||
self.jobs
|
||||
.lock()
|
||||
.map_err(|_| AiError::new(ErrorKind::Internal, "the job list is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(e: impl std::fmt::Display) -> AiError {
|
||||
AiError::new(ErrorKind::Internal, e.to_string())
|
||||
}
|
||||
|
||||
fn unknown() -> AiError {
|
||||
AiError::invalid("that job does not exist")
|
||||
}
|
||||
|
||||
impl Provider for Shouter {
|
||||
fn models(&self) -> Result<Vec<ModelInfo>, AiError> {
|
||||
Ok(vec![self.model.clone()])
|
||||
}
|
||||
|
||||
fn submit(&self, model: &ModelId, request: &Request) -> Result<JobId, AiError> {
|
||||
// Refuse first. Nothing has been sent anywhere yet.
|
||||
if *model != self.model.id {
|
||||
return Err(AiError::unsupported("this service has no such model"));
|
||||
}
|
||||
let spec = self.model.spec_for(&request.capability).ok_or_else(|| {
|
||||
AiError::unsupported(format!("this model cannot do {}", request.capability))
|
||||
})?;
|
||||
request.validate(spec, &Limits::default())?;
|
||||
let answer = match request.inputs.first().map(|i| &i.payload) {
|
||||
Some(Payload::Text { text }) => text.to_uppercase(),
|
||||
_ => return Err(AiError::invalid("a text prompt is needed")),
|
||||
};
|
||||
let name = format!("job-{}", self.next.fetch_add(1, Ordering::SeqCst));
|
||||
self.lock()?.insert(
|
||||
name.clone(),
|
||||
Job {
|
||||
phase: Phase::Queued,
|
||||
answer,
|
||||
},
|
||||
);
|
||||
JobId::new(&name).map_err(bad)
|
||||
}
|
||||
|
||||
fn poll(&self, job: &JobId) -> Result<JobSnapshot, AiError> {
|
||||
let mut jobs = self.lock()?;
|
||||
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
|
||||
Ok(match entry.phase {
|
||||
Phase::Queued => {
|
||||
entry.phase = Phase::Running;
|
||||
JobSnapshot::new(JobState::Running { percent: Some(50) })
|
||||
}
|
||||
Phase::Running => {
|
||||
entry.phase = Phase::Done;
|
||||
let mut done = JobSnapshot::new(JobState::Succeeded);
|
||||
done.events.push(Event::Output(Output {
|
||||
payload: Payload::text(entry.answer.clone()),
|
||||
label: None,
|
||||
}));
|
||||
done // the answer is delivered once
|
||||
}
|
||||
Phase::Done => JobSnapshot::new(JobState::Succeeded),
|
||||
Phase::Cancelled => JobSnapshot::new(JobState::Cancelled),
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&self, job: &JobId) -> Result<(), AiError> {
|
||||
let mut jobs = self.lock()?;
|
||||
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
|
||||
if matches!(entry.phase, Phase::Queued | Phase::Running) {
|
||||
entry.phase = Phase::Cancelled;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Prove it with the checks every provider must pass.
|
||||
use craft_ai_core::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job};
|
||||
|
||||
let provider = Shouter::new()?;
|
||||
let model = provider.model.id.clone();
|
||||
let capability = CapabilityId::parse("text.complete")?;
|
||||
let good = Request::new(capability.clone()).with_input(InputRole::Prompt, Payload::text("hi"));
|
||||
let no_prompt = Request::new(capability);
|
||||
|
||||
run_lifecycle(&provider, &model, &good)?;
|
||||
run_cancel(&provider, &model, &good)?;
|
||||
run_unknown_job(&provider)?;
|
||||
run_rejects(&provider, &model, &no_prompt, ErrorKind::InvalidRequest)?;
|
||||
```
|
||||
|
||||
Rules your provider must keep (the checks above test most of them):
|
||||
|
||||
- **Refuse before sending.** A request the model cannot take is rejected in `submit`, with nothing
|
||||
on the wire.
|
||||
- **Keep time moving forward.** A job starts `Queued` and may become `Running` before it ends. It
|
||||
may also end straight from `Queued`. Progress never goes down. A finished job stays finished.
|
||||
- **Deliver events once.** Each result appears in exactly one poll.
|
||||
- **Never put a key in an error, a log line or an event.** Use `AiError::redacted(kind, message,
|
||||
&[&secret])` when a reply might echo the key back.
|
||||
- **Use the platform's HTTP setting.** Build agents through `craft-ai-http`. A test in this
|
||||
repository fails if a crate builds one without it.
|
||||
- **Say which kind of failure it is.** `Auth`, `RateLimited` (with `with_retry_after`), `Quota`,
|
||||
`Network` and `Provider` each lead to a different thing on screen.
|
||||
|
||||
### Register it
|
||||
|
||||
The name you register is the `provider` field of a connection. Registering is the whole of
|
||||
adding an adapter to an app:
|
||||
|
||||
<!-- snippet: register_adapter -->
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ai_client::{AiClient, ProviderFactory};
|
||||
use craft_ai_core::mock::{MockProvider, Outcome, Script};
|
||||
use craft_ai_core::{AppId, Provider};
|
||||
|
||||
let mut factory = ProviderFactory::standard();
|
||||
factory.register("acme", |connection, key| {
|
||||
// `key` is already read from the key store. The adapter never touches a keychain.
|
||||
// A real adapter sends `key` with each request. This stand-in has nowhere to send it.
|
||||
let _ = key;
|
||||
let script = Script {
|
||||
running_polls: 1,
|
||||
text_chunks: vec![],
|
||||
outcome: Outcome::Succeed(vec![]),
|
||||
};
|
||||
Ok(Arc::new(MockProvider::new(connection.models.clone(), script)) as Arc<dyn Provider>)
|
||||
});
|
||||
assert!(factory.knows("acme"));
|
||||
assert!(!ProviderFactory::standard().knows("acme"));
|
||||
let app =
|
||||
AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone()).with_factory(factory);
|
||||
```
|
||||
|
||||
### Describe the connection
|
||||
|
||||
A person adds the connection in Set up AI. By hand, or from code, it is data:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme",
|
||||
"label": "Acme",
|
||||
"provider": "acme",
|
||||
"endpoint": "https://api.acme.example/v1",
|
||||
"locality": "remote",
|
||||
"auth": { "kind": "api_key", "secret": "acme-key" },
|
||||
"billing": "metered",
|
||||
"models": [ { "id": "acme-vision-1", "label": "Acme Vision" } ]
|
||||
}
|
||||
```
|
||||
|
||||
- `locality: "remote"` means anything sent needs the person's agreement first. Use `"local"`
|
||||
only for something that stays on this computer.
|
||||
- `auth.secret` names a key in the key store. It is a name, not the key. Keys never go in this
|
||||
file, in an environment variable you set for the person, or on a command line.
|
||||
- Remote endpoints must use HTTPS. Plain HTTP is accepted only for a loopback address.
|
||||
|
||||
## Route 3: a model that runs inside the app
|
||||
|
||||
For a model file that should run on this computer with nothing to install, write a
|
||||
`ModelRunner` for a built-in engine. The job queue, threads, Cancel, progress, loading the
|
||||
weights once and turning a crash into a failed job are already done. See
|
||||
[Add a model to a built-in engine](../ADD-AN-ENGINE.md). Its code is compiled in that crate's tests,
|
||||
like this page.
|
||||
|
||||
If the person's model should come from somewhere else (their own ComfyUI, a different engine),
|
||||
that is another connection for the same tool. No app changes.
|
||||
|
||||
## Be honest about what you ran
|
||||
|
||||
A scripted test shows your adapter follows the contract. It does not show the real service
|
||||
works. When you publish an adapter, say which of these you did:
|
||||
|
||||
- ran the conformance checks against your adapter;
|
||||
- ran it against a scripted server on this computer;
|
||||
- ran it against the real service, once, with a stated model and input;
|
||||
- ran it from inside an app and checked what landed in the document.
|
||||
|
||||
Do not describe one as another. The repository's [status page](../STATUS.md) uses these levels.
|
||||
|
||||
Next: the [checklist](checklist.md).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Checklist
|
||||
|
||||
Use the section that matches what you built. Tick a box only when you ran the thing and saw it.
|
||||
"It compiles" and "the test passes" are not the same as "the tool works in the app".
|
||||
|
||||
## An app that uses a tool
|
||||
|
||||
### Design
|
||||
|
||||
- [ ] The tool's role comes from the catalogue (`Feature::role()`), or is a declared tool whose id
|
||||
was agreed with the other maintainers.
|
||||
- [ ] The app id is the lower-case name the launcher uses. It never changes.
|
||||
- [ ] The app starts and every other feature works with no connection at all.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- [ ] `status` is called each time the tool is about to be used, not once at start-up.
|
||||
- [ ] `Unavailable` greys the tool out and shows the reason as given. It is not drawn as an error.
|
||||
- [ ] The missing-model popup (`ToolDesk::missing_model`) opens from a greyed-out tool, and
|
||||
choosing a model there goes through `ToolDesk::choose`.
|
||||
- [ ] `plan` runs before any consent prompt. Nobody is asked to agree to a request the app cannot run.
|
||||
- [ ] The consent prompt shows `plan.disclosure()` and offers `ConsentTerm::ALL`.
|
||||
- [ ] "This time" leaves nothing on disk. Longer terms are saved only when `term.remembers()`.
|
||||
- [ ] `start` and `poll` run off the interface thread.
|
||||
- [ ] Progress is shown. Cancel is offered while the job is queued and while it runs. If `cancel`
|
||||
returns an error (`Unsupported` when targeted cancel is off for a ComfyUI server), the job
|
||||
keeps running: the app waits for any end state, not only `Cancelled`, discards the result,
|
||||
and releases the input once the job has ended.
|
||||
- [ ] Every `ErrorKind` has a path on screen (see the table in the app guide). `Cancelled` shows nothing.
|
||||
- [ ] The result is applied through the app's own undo, and survives save and reopen.
|
||||
|
||||
### Safety
|
||||
|
||||
- [ ] The app never reads, stores, prints or logs a key.
|
||||
- [ ] No model id, provider name or address is hard-coded. A broken binding is an error, never
|
||||
a reason to use a default.
|
||||
- [ ] No text a person can read contains engine names or hardware words.
|
||||
- [ ] `keep_prompts_in_history` is off, unless the person turned it on.
|
||||
- [ ] Tests set `CRAFT_AI_DIR` (or use a temporary store). None of them touches real settings.
|
||||
|
||||
### Tests to write
|
||||
|
||||
Each of these catches a real class of bug. Use a scripted adapter and a temporary store.
|
||||
|
||||
- [ ] **Unbound:** no model chosen, so `status` is `Unavailable`, and nothing is sent.
|
||||
- [ ] **Bound:** the suite default is used, and the adapter receives the right request.
|
||||
- [ ] **A second app id** reads the same default without any setup.
|
||||
- [ ] **An app override** beats the default, and `clear_binding` restores the default.
|
||||
- [ ] **No adapter:** the app that does not ship the adapter is `Unavailable`, `plan` is
|
||||
`Unsupported`, no key is read and the other app's adapter saw nothing.
|
||||
- [ ] **A remote connection** gives `ConsentRequired` and builds no adapter until agreed.
|
||||
- [ ] **A removed connection** is an error. It is not "unbound".
|
||||
- [ ] **Cancel** ends in `Cancelled` and does not disturb another job.
|
||||
- [ ] **Old store file:** a file from before custom tools existed still loads.
|
||||
|
||||
Watch each behavioural test fail against a stub first, so you know it can.
|
||||
|
||||
### Proof
|
||||
|
||||
- [ ] A real model was run through **your own tool** and the stored result checked: its size,
|
||||
its content, and that it is still there after save and reopen.
|
||||
- [ ] You say which of these you did: scripted test, local server, real service, inside the app.
|
||||
Never describe one as another.
|
||||
|
||||
## A new tool
|
||||
|
||||
- [ ] The capability is the narrowest honest one (`image.describe`, not `image.analyse`).
|
||||
- [ ] `inputs` lists exactly what the tool sends, and no more.
|
||||
- [ ] The id is registered (`register_tool`) if more than one app will use it.
|
||||
- [ ] Two apps that declare the same id agree on its capability and inputs. A mismatch is refused.
|
||||
- [ ] At least one model was chosen for it with `ToolDesk::choose`, and `choose` refused a model that
|
||||
could not do the job.
|
||||
- [ ] The result shape is written down (text, media type, or JSON) for the next person.
|
||||
- [ ] If it should be easy to set up, a catalogue entry has size, SHA-256, source and licence for
|
||||
every file, and a statement that licences are disclosed and not checked.
|
||||
|
||||
## A service or a model
|
||||
|
||||
- [ ] `models()` lists only what the service really does. Capabilities and inputs are not guessed
|
||||
from a model's name.
|
||||
- [ ] A request the model cannot take is refused in `submit`, before anything is sent.
|
||||
- [ ] The conformance checks pass: `run_lifecycle`, `run_cancel`, `run_unknown_job`, `run_rejects`.
|
||||
- [ ] Remote means `locality: remote`, and the endpoint is HTTPS (or loopback).
|
||||
- [ ] The key arrives through the connection's secret name. It is never in an error, a log or an event.
|
||||
- [ ] HTTP agents come from `craft-ai-http`.
|
||||
- [ ] Errors use the right `ErrorKind`, and `RateLimited` carries `retry_after_secs` when known.
|
||||
- [ ] You tested a failure: a wrong key, a full quota, a dropped connection, a cancelled job.
|
||||
- [ ] The docs for your adapter say what ran: conformance, scripted server, the real service, or
|
||||
inside an app.
|
||||
|
||||
## Before you open a pull request here
|
||||
|
||||
- [ ] Targeted cargo only, one process at a time: `cargo test -p <crate> -j 8`. No workspace builds.
|
||||
- [ ] `cargo fmt --all --check` and `cargo clippy -p <crate> --all-targets --locked -j 8 -- -D warnings`.
|
||||
- [ ] No file over 500 lines. No `unwrap`, `expect` or `panic` in shipped code.
|
||||
- [ ] No personal folder paths in anything you commit.
|
||||
- [ ] The pull request says plainly what was run and what was not.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Declare a new tool
|
||||
|
||||
Use this page when the suite has no tool for what you want. Examples: "describe a photograph",
|
||||
"explain this CAD part", "clean up this audio". You declare the tool once. After that, any
|
||||
model that can do the job can be chosen for it, and every app that declares the same tool
|
||||
shares the person's choice.
|
||||
|
||||
You do not need to edit this repository, the launcher or anyone else's app.
|
||||
|
||||
Every Rust block here is a real test (`cargo test -p craft-ai-client --test adopting_tool_guides`).
|
||||
`--test adopting_drift` fails if a block differs from the tested code.
|
||||
|
||||
## What a tool is
|
||||
|
||||
A tool is a **declaration, not code**. It is a `Role`:
|
||||
|
||||
| Field | What it is | Rules |
|
||||
|---|---|---|
|
||||
| `id` | The tool's name across the whole suite, for example `describe-photo`. | Letters, digits and `- _ . : /`. Up to 128 bytes. Agree it with the other maintainers. |
|
||||
| `label` | What a person sees. | Plain words. Each app may translate it. |
|
||||
| `capability` | The one thing a model must be able to do. | `<modality>.<task>`, such as `image.describe`. |
|
||||
| `inputs` | What your tool sends. | Any of `prompt`, `system`, `source`, `mask`, `reference`, `context`. |
|
||||
|
||||
Two rules decide which models a person can choose for your tool:
|
||||
|
||||
1. The model must offer your **capability**.
|
||||
2. The model must **accept every input you list**, and must **require none you cannot supply**.
|
||||
|
||||
So `inputs` is a promise about what your tool sends. List only what you really send. A model
|
||||
that needs a mask is not offered for a tool that never sends one.
|
||||
|
||||
## Choose the capability
|
||||
|
||||
A capability id is `<modality>.<task>`. The set is open, so `cad.explain` is valid with no
|
||||
library change. Before you invent one, look at the list the suite already shares, so models and apps agree
|
||||
on spelling:
|
||||
|
||||
<!-- snippet: well_known -->
|
||||
```rust
|
||||
use craft_ai_core::capability::well_known;
|
||||
|
||||
let known: Vec<String> = well_known().iter().map(|c| c.to_string()).collect();
|
||||
assert!(known.contains(&"image.inpaint".to_string()));
|
||||
assert!(known.contains(&"image.segment".to_string()));
|
||||
```
|
||||
|
||||
Modalities are `text`, `image`, `video`, `audio`, `document`, `tabular`, `vector` and `mesh`. A
|
||||
new modality is allowed. Tasks are lower-case tokens with no dots.
|
||||
|
||||
Choose the narrowest honest capability. `image.describe` is better than `image.analyse`: a
|
||||
person looking for a model wants to know it can do exactly this.
|
||||
|
||||
## Declare it and choose a model
|
||||
|
||||
You do this once, from any process that shares the connections file: the launcher, your app's
|
||||
Preferences, or a setup script.
|
||||
|
||||
<!-- snippet: declare_and_choose -->
|
||||
```rust
|
||||
use craft_ai_core::{
|
||||
CapabilityId, CapabilitySpec, ConnectionId, InputRole, ModelId, ModelInfo,
|
||||
};
|
||||
use craft_ai_core::{Role, RoleId};
|
||||
|
||||
// `launcher` is a `ToolDesk`, and `shared` is the connections file. See the tests for the scene.
|
||||
// 1. The declaration.
|
||||
let describe = Role {
|
||||
id: RoleId::new("describe-photo")?,
|
||||
label: "Describe photograph".into(),
|
||||
capability: CapabilityId::parse("image.describe")?,
|
||||
inputs: vec![InputRole::Source],
|
||||
};
|
||||
launcher.register_tool(describe.clone())?;
|
||||
|
||||
// 2. A model that can do it, offered by a connection the person already has.
|
||||
let vision = ModelInfo::new(ModelId::new("vision-1")?, "Vision 1").with(
|
||||
CapabilitySpec::new(describe.capability.clone())
|
||||
.accepting(InputRole::Source, 1)
|
||||
.requiring(InputRole::Source),
|
||||
);
|
||||
let service = serde_json::from_value(serde_json::json!({
|
||||
"id": "vision-service", "label": "Vision (on this computer)", "provider": "vision",
|
||||
"locality": "local", "auth": {"kind": "none"}, "billing": "free",
|
||||
"models": [vision.clone()],
|
||||
}))?;
|
||||
shared.update(|store| store.connections.push(service))?;
|
||||
|
||||
// 3. The suite-wide choice. It is checked before anything is written.
|
||||
launcher.choose(&describe, &ConnectionId::new("vision-service")?, &vision.id)?;
|
||||
assert_eq!(launcher.custom_tools()?.len(), 2); // "tighten-text" from the scene, and this one
|
||||
```
|
||||
|
||||
`register_tool` refuses a name that a built-in tool already uses and a name that is already
|
||||
taken. `choose` refuses, and writes nothing, when the model cannot do the job.
|
||||
|
||||
Declaring is optional for a tool that only your app uses: any app can build a `Role` in code and
|
||||
call `status` and `plan`. Register it when more than one app should share the choice, because
|
||||
registering is what protects the contract (next section).
|
||||
|
||||
## An id is a contract
|
||||
|
||||
Once a tool is registered, its id means one capability with one set of inputs, everywhere.
|
||||
An app may change the label. It may **not** change what the id means.
|
||||
|
||||
<!-- snippet: id_is_a_contract -->
|
||||
```rust
|
||||
use craft_ai_client::{AiClient, RoleStatus};
|
||||
use craft_ai_core::{AppId, CapabilityId, InputRole, Role, RoleId};
|
||||
|
||||
let honest = Role {
|
||||
id: RoleId::new("shorten")?,
|
||||
label: "Shorten".into(),
|
||||
capability: CapabilityId::parse("text.complete")?,
|
||||
inputs: vec![InputRole::Prompt],
|
||||
};
|
||||
launcher.register_tool(honest.clone())?;
|
||||
|
||||
// Another app reuses the id for something else. It is refused, not quietly accepted.
|
||||
let imposter = Role {
|
||||
capability: CapabilityId::parse("image.generate")?,
|
||||
..honest
|
||||
};
|
||||
let app = AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone());
|
||||
match app.status(&imposter) {
|
||||
RoleStatus::Unavailable { reason } => assert!(reason.contains("does not match")),
|
||||
RoleStatus::Ready(_) => panic!("a changed contract must not resolve"),
|
||||
}
|
||||
```
|
||||
|
||||
The same check runs in `choices`, `picker`, `bind`, `plan` and `choose`. If you need a different
|
||||
tool, use a different id.
|
||||
|
||||
## Send the right data
|
||||
|
||||
Build the request from the inputs you declared. Use `Payload::text` for words,
|
||||
`Payload::inline` for bytes you hold, and `Payload::handle` for a file too big to copy.
|
||||
|
||||
<!-- snippet: describe_request -->
|
||||
```rust
|
||||
use craft_ai_core::{CapabilityId, InputRole, Payload, Request};
|
||||
|
||||
let request = Request::new(CapabilityId::parse("image.describe")?).with_input(
|
||||
InputRole::Source,
|
||||
Payload::inline("image/png", vec![0x89, b'P', b'N', b'G'])?,
|
||||
);
|
||||
assert_eq!(request.count(&InputRole::Source), 1);
|
||||
```
|
||||
|
||||
Answers come back as `Output` events. Choose the shape once and write it down for the next
|
||||
person: plain text for words, an image or audio payload for media, and `application/json`
|
||||
for structured results.
|
||||
|
||||
If many models and many apps will do the same job, give it a fixed contract like selection has
|
||||
(`craft_ai_core::segment`): one request shape, one result shape, and checks on everything read
|
||||
back. That way a click, a box and "nothing there" mean the same on every route.
|
||||
|
||||
## Let people set it up
|
||||
|
||||
A declared tool shows up in the launcher's tool list and in the shared picker. To make it
|
||||
easy to run on a computer, add a catalogue entry in `craft-ai-features`: model files with size,
|
||||
SHA-256, source and licence, and the engines that can run them. That is a pull request to this
|
||||
repository. It is not needed for the tool to work. A person can always add a connection and
|
||||
model by hand.
|
||||
|
||||
Licences are **disclosure, not clearance**. List every part a recipe runs, with the terms its
|
||||
publisher states and a link. Say plainly that nothing was checked and that it is not legal advice.
|
||||
|
||||
## Remove it
|
||||
|
||||
`ToolDesk::remove_tool(&id)` deletes the declaration and every suite and per-app choice that
|
||||
named it. Built-in tools cannot be removed.
|
||||
|
||||
## What a declaration does not do
|
||||
|
||||
- It does not install code. A tool is only runnable when some app ships an adapter for the
|
||||
chosen connection's provider, and the app that does not says so plainly.
|
||||
- It does not add a menu, a button or a panel. The interface is the app's.
|
||||
- It does not pick a model for the person. A model is chosen only when they agree.
|
||||
|
||||
Next: [Bring a service or model](bring-a-service.md) to make something that can run it.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Troubleshooting
|
||||
|
||||
Find the words on your screen. Each row says what the layer meant and what to do.
|
||||
|
||||
## The tool is greyed out
|
||||
|
||||
| What `status` says | What happened | Fix |
|
||||
|---|---|---|
|
||||
| `no model is chosen for 'select-subject'` | Nothing is bound for that tool, in the suite or for this app. | Open the popup (`ToolDesk::missing_model`) or Set up AI, and choose a model. This is normal on a fresh install. |
|
||||
| `This app can't run the chosen model yet. Choose another in Set up AI.` | The chosen connection's provider name has no adapter registered in this app's `ProviderFactory`. | Register the adapter, or choose a model this app can run. Check the provider name matches exactly (it is not case sensitive). |
|
||||
| `connection <id> is switched off` | The connection exists but is disabled. | Switch it on in Set up AI. |
|
||||
| `connection <id> no longer exists` | The binding points at a deleted connection. | Choose again. `ToolDesk::clear` removes a stale default. |
|
||||
| `connection <id> does not offer model <m>` | The model was removed from the connection. | Choose another model. |
|
||||
| `model <m> cannot do <capability>` | The bound model does not offer the tool's capability. | Choose a model that does. |
|
||||
| `model <m> does not accept a '<input>' input` | Your tool sends an input the model cannot take. | Choose another model, or stop sending that input if the tool does not need it. |
|
||||
| `tool <id> does not match its shared definition` | Another app registered this id with a different capability or inputs. | Use the registered meaning, or pick a new id. |
|
||||
| `no key is saved for connection "<label>"; add it in the ArtCraft Launcher` | The connection needs a key and the key store has none. | Add the key in the launcher or your Preferences. |
|
||||
| `connections file: ...` or `no place to keep the connections file on this system` | The shared store could not be read, or the OS gave no config folder. | Check the folder, or set `CRAFT_AI_DIR`. |
|
||||
|
||||
The "model cannot take your input" cases are a feature. They keep a model from being offered for a
|
||||
tool it cannot serve. Do not work around them by sending less than the tool needs.
|
||||
|
||||
## `plan` or `start` fails
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `ConsentRequired`: `sending to <host> needs your agreement first` | The connection is remote and the ledger does not cover this app, destination, capability and kind of data. | Show the disclosure, call `ledger.agree(...)`, and start with the ledger it returns. |
|
||||
| Consent asked again and again | The term was "this time", the term lapsed, the ledger was not saved, or the destination or capability changed. | Save the ledger when `term.remembers()`. Keep it in `client.consent_file()`. |
|
||||
| `Unsupported`: `this model does not accept a '<input>' input` | The request carries an input the model's spec lacks. | Send only declared inputs. |
|
||||
| `Unsupported`: `this model accepts at most N '<input>' input(s)` | Too many of one input. | Send fewer, or choose another model. |
|
||||
| `InvalidRequest`: `a '<input>' input is required` | A required input is missing. | Send it. |
|
||||
| `InvalidRequest`: `this request is for <a>, but "<tool>" needs <b>` | The request's capability differs from the tool's. | Build the request from `role.capability`. |
|
||||
| `Unsupported`: `this model returns at most N output(s)` | `with_outputs` asked for too many. | Ask for fewer. |
|
||||
| `Unsupported`: `this model cannot stream` | `streaming()` on a model that does not stream. | Remove it. |
|
||||
| `InvalidRequest` on a payload | A payload is too big (32 MiB inline by default) or has a bad media type. | Use `Payload::handle` for large data. Media types are lower-case `type/subtype`. |
|
||||
|
||||
## The job misbehaves
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Events seem to vanish | Each poll returns only new events. | Collect them in every loop. Do not poll just to read the state. |
|
||||
| `the job took too long` | `Job::wait` hit its timeout. It tried to cancel the job, which may not work (see "Cancel does nothing"). | Use your own polling and a longer limit, or a different model. |
|
||||
| The interface freezes on Start | `start` ran on the interface thread. | Move `start` and `poll` to a worker. |
|
||||
| Cancel does nothing | Targeted cancel is off by default for some ComfyUI servers, because an older server may treat it as a global interrupt. | Expect the job to finish, and discard its result. |
|
||||
| The result is empty but the job succeeded | The tool has a fixed result shape (selection does) and no match is a valid answer. | Read it with the contract: `segment::read_found` says whether anything matched. |
|
||||
| `Auth` after the key is saved | The wrong key name, or the environment variable shadows the keychain. | Variables named `CRAFT_AI_SECRET_...` are read first. Unset the one in the way. |
|
||||
| `RateLimited` | The service asked you to slow down. | Wait `retry_after_secs` when present. |
|
||||
|
||||
## My tests
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| A test changes my real settings | The test used `AiClient::standard`. | Use `AiClient::new` with a temporary `SharedStore`, or set `CRAFT_AI_DIR` for that test only. |
|
||||
| `status` is `Ready` but the real tool does nothing | Ready means the route resolves. It does not mean a model ran. | Run the model through your tool and check the result in the document. |
|
||||
| Two apps in a test disagree | Each client has its own `ProviderFactory`. An app without the adapter is `Unavailable`. | That is the point. Register the adapter in each client that should run it. |
|
||||
| `register_tool` says the name is in use | A built-in tool, or an earlier tool, has that id. | Choose another id, or use the existing tool. |
|
||||
|
||||
If nothing here matches, run `client.status(&role)` and read the reason first. It is written to be
|
||||
the answer.
|
||||
@@ -0,0 +1,349 @@
|
||||
# Use AI tools in your app
|
||||
|
||||
This guide takes an app from "no AI" to "a tool that runs, asks first when it must, can be
|
||||
cancelled, and fails in words a person can read". You write the tool's interface. The shared
|
||||
layer does the rest: connections, keys, which model does what, consent, jobs and the log.
|
||||
|
||||
Every Rust block on this page is a real test. `cargo test -p craft-ai-client --test adopting_app_guide`
|
||||
runs each one against a scripted stand-in service, and `--test adopting_drift` fails if a block here
|
||||
differs from the tested code. Nothing touches the network or your real connections.
|
||||
|
||||
In the examples, `client` is the client from step 2 and `role` is the tool from step 3. A block
|
||||
that needs more says so.
|
||||
|
||||
**You need:** Rust 1.90 or newer, and a native (desktop) app. `craft-ai-core` also builds for
|
||||
wasm, but the client, the stores and the adapters do not.
|
||||
|
||||
## 1. Add the dependency
|
||||
|
||||
The crates are not on crates.io. Pin a revision of `main` and never use a path dependency in
|
||||
anything you ship:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
craft-ai-client = { git = "https://github.com/storytold/craft-ai-integrations", rev = "<a commit on main>" }
|
||||
craft-ai-core = { git = "https://github.com/storytold/craft-ai-integrations", rev = "<same commit>" }
|
||||
craft-ai-features = { git = "https://github.com/storytold/craft-ai-integrations", rev = "<same commit>" }
|
||||
```
|
||||
|
||||
Use one revision for all of them. `craft-ai-core` is also in `storytold/craft-libs`, and the local
|
||||
copy here is ahead of it (see [CORE-SYNC](../CORE-SYNC.md)), so do not mix the two.
|
||||
|
||||
## 2. Make one client
|
||||
|
||||
One `AiClient` per app. It knows your app's id, the shared connections file, the key stores and
|
||||
the adapters your app ships.
|
||||
|
||||
<!-- snippet: standard_client -->
|
||||
```rust
|
||||
use craft_ai_client::AiClient;
|
||||
use craft_ai_core::AppId;
|
||||
|
||||
// Reads the shared connections file the launcher writes, and keys from the environment
|
||||
// and then the OS keychain.
|
||||
let client = AiClient::standard(AppId::new("wordcraft")?)?;
|
||||
```
|
||||
|
||||
Pick your app id once and keep it. It is how consent is scoped and how a person gives your app
|
||||
its own choice of model. Use the lower-case name that the launcher uses.
|
||||
|
||||
Every app works with no connection at all. Your app must start, and its other features must work,
|
||||
when nobody has set up AI.
|
||||
|
||||
`CRAFT_AI_DIR` moves the whole folder (connections, consent, history). Point it at a temporary
|
||||
folder in your own tests so they never read or change a real person's settings.
|
||||
|
||||
## 3. Say which tool you are offering
|
||||
|
||||
A tool is a `Role`: an id, a label, one capability, and the inputs the tool sends. The
|
||||
capability and the inputs decide which models can ever be chosen for it.
|
||||
|
||||
For a tool the suite already knows, take the role from the catalogue. Then you inherit the
|
||||
person's choice and the setup recipes for free:
|
||||
|
||||
<!-- snippet: catalogue_role -->
|
||||
```rust
|
||||
use craft_ai_features::builtin;
|
||||
|
||||
let select_subject = builtin()
|
||||
.into_iter()
|
||||
.find(|feature| feature.id == "select-subject")
|
||||
.and_then(|feature| feature.role())
|
||||
.ok_or("the catalogue has no such tool")?;
|
||||
assert_eq!(select_subject.capability.as_str(), "image.segment");
|
||||
```
|
||||
|
||||
For a tool nobody has declared yet, write the `Role` yourself. That is the subject of
|
||||
[Declare a new tool](declare-a-tool.md). Do not invent an id for a tool the catalogue already has.
|
||||
|
||||
## 4. Ask `status`, and draw what it says
|
||||
|
||||
`status` answers one question: can this tool run right now, in this app? It reads no key and
|
||||
sends nothing.
|
||||
|
||||
<!-- snippet: status -->
|
||||
```rust
|
||||
use craft_ai_client::RoleStatus;
|
||||
|
||||
match client.status(&role) {
|
||||
RoleStatus::Ready(ready) => {
|
||||
// Enable the tool. You can show which model will do the work.
|
||||
assert_eq!(ready.model_label, "Tidy 1");
|
||||
}
|
||||
RoleStatus::Unavailable { reason } => {
|
||||
// Grey the tool out and show `reason` exactly as it is.
|
||||
println!("{reason}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Draw the three states the same way in every place:
|
||||
|
||||
| What you see | What to show |
|
||||
|---|---|
|
||||
| `Ready` | The tool, switched on. Optionally the model and whether it is free, metered, local or online. |
|
||||
| `Unavailable`, nothing chosen | The tool, switched off, with the reason and a way to fix it (see step 9). Never call this an error. |
|
||||
| `Unavailable`, anything else | The same. The reason names the problem: a missing key, a switched-off connection, a model that cannot take your inputs, or "This app can't run the chosen model yet". |
|
||||
|
||||
`reason` is written for a person. Show it as it is. Do not add engine names, hardware words or
|
||||
developer words to it, and do not translate it into something vaguer.
|
||||
|
||||
Call `status` again whenever the tool is about to be used, not once at start-up. A person can
|
||||
change their choice in the launcher while your app is open.
|
||||
|
||||
## 5. Build the request
|
||||
|
||||
A `Request` names a capability and carries typed inputs. The type matters: a mask is not just
|
||||
another picture.
|
||||
|
||||
<!-- snippet: request -->
|
||||
```rust
|
||||
use craft_ai_core::{InputRole, Payload, Request};
|
||||
|
||||
let request = Request::new(role.capability.clone())
|
||||
.with_input(InputRole::Prompt, Payload::text("Tighten this paragraph."))
|
||||
.with_param("tone", serde_json::json!("plain"));
|
||||
```
|
||||
|
||||
- **Inputs** are `prompt`, `system`, `source`, `mask`, `reference` and `context`. Send only the
|
||||
ones your `Role` lists. A model that cannot take them is never offered for your tool.
|
||||
- **Payloads** are `Payload::text`, `Payload::inline(media_type, bytes)` (up to 32 MiB by
|
||||
default) or `Payload::handle(uri, media_type, size)` for something too big to copy.
|
||||
- **Params** are named values. The consent prompt shows their names, never their values.
|
||||
- Some tools have a fixed shape that every model and every app agrees on. Selection is one:
|
||||
use `craft_ai_core::segment` (`with_clicks`, `with_area`, `read_found`, `mask`) and never invent
|
||||
your own parameters or result labels for it.
|
||||
|
||||
## 6. Plan
|
||||
|
||||
`plan` checks the request against the chosen model and builds the disclosure: what would leave
|
||||
this computer, and where it would go. It sends nothing and reads no key.
|
||||
|
||||
<!-- snippet: plan -->
|
||||
```rust
|
||||
let plan = client.plan(&role, request)?;
|
||||
println!("{} will use {}", role.label, plan.connection_label());
|
||||
```
|
||||
|
||||
`plan` is where most mistakes show up, before anyone is asked anything:
|
||||
|
||||
| `ErrorKind` | Meaning | Do |
|
||||
|---|---|---|
|
||||
| `Unsupported` | This app cannot run the choice, or the model cannot take that input or that many of it. | Show the message. Offer step 9. |
|
||||
| `InvalidRequest` | The request is wrong: a missing required input, an oversized payload. | This is your bug, or the person gave bad input. Fix or say so. |
|
||||
|
||||
## 7. Consent
|
||||
|
||||
A local model needs no consent. Anything that leaves the computer does, and the layer enforces
|
||||
it. `start` refuses with `ConsentRequired` unless the ledger covers this app, this connection,
|
||||
this destination, this capability and each kind of data in the plan.
|
||||
|
||||
<!-- snippet: consent -->
|
||||
```rust
|
||||
use craft_ai_core::{ConsentLedger, ConsentTerm, ErrorKind};
|
||||
|
||||
let plan = client.plan(&role, request)?;
|
||||
let mut saved = ConsentLedger::default(); // in a real app: load it, see below
|
||||
|
||||
// Without consent the job does not start, and no key is read.
|
||||
let refused = client.start(&plan, &saved).expect_err("must refuse");
|
||||
assert_eq!(refused.kind, ErrorKind::ConsentRequired);
|
||||
|
||||
if plan.needs_consent(&saved) {
|
||||
let d = plan.disclosure();
|
||||
println!("Send {} item(s) to {}?", d.items.len(), d.destination);
|
||||
// The person answers with one of ConsentTerm::ALL, shortest first.
|
||||
let now = 1_800_000_000; // in a real app: the current unix time in seconds
|
||||
let ledger_for_this_job = saved.agree(d, ConsentTerm::ThisTime, now);
|
||||
assert!(client.start(&plan, &ledger_for_this_job).is_ok());
|
||||
// "This time" is not remembered: `saved` is unchanged.
|
||||
assert!(plan.needs_consent(&saved));
|
||||
}
|
||||
```
|
||||
|
||||
The rules for your prompt:
|
||||
|
||||
- Show `plan.disclosure()`: the destination, and what is sent (the kind of each input and its
|
||||
size). Nothing else about the request needs to be shown.
|
||||
- Offer the four terms in `ConsentTerm::ALL`: this time, one hour, one day, until turned off. The
|
||||
words are yours, so you can translate them. `ConsentTerm::id()` is the stable name.
|
||||
- Start the job with the ledger that `agree` returns. Save your ledger only when
|
||||
`term.remembers()` is true. "This time" must leave nothing on disk.
|
||||
- Keep the ledger in `client.consent_file()` (`load_or_empty`, `save`). It is per app, so a yes in
|
||||
one app never covers another.
|
||||
- Do not build your own "are you sure" for online services. A second prompt with different
|
||||
wording is a bug in the person's eyes.
|
||||
|
||||
## 8. Start, watch, cancel
|
||||
|
||||
`start` checks consent again, reads the key (only now), builds the adapter and submits the
|
||||
job. It returns a `Job` you poll.
|
||||
|
||||
<!-- snippet: job -->
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
use craft_ai_core::{ConsentLedger, Event, JobState};
|
||||
|
||||
let plan = client.plan(&role, request)?;
|
||||
let job = client.start(&plan, &ConsentLedger::default())?;
|
||||
|
||||
let final_state = loop {
|
||||
let snapshot = job.poll()?;
|
||||
for event in &snapshot.events {
|
||||
match event {
|
||||
Event::TextDelta { text } => print!("{text}"),
|
||||
Event::Output(output) => println!("result: {:?}", output.payload),
|
||||
Event::Notice { message } => println!("note: {message}"),
|
||||
Event::ToolCall { .. } => {}
|
||||
}
|
||||
}
|
||||
if snapshot.state.is_terminal() {
|
||||
break snapshot.state;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5)); // in an app: poll from your own timer
|
||||
};
|
||||
assert_eq!(final_state, JobState::Succeeded);
|
||||
```
|
||||
|
||||
- **Keep it off the interface thread.** Adapters are synchronous. `start` can do real work
|
||||
before it returns, such as reading a key, building the adapter or calling a service, so call it and `poll` from a
|
||||
worker or your own job system. `Job::wait(interval, timeout)` exists for scripts and tests.
|
||||
- **States:** `Queued`, `Running { percent }` (percent is optional), then one of `Succeeded`,
|
||||
`Failed { error }` or `Cancelled`. A finished state never changes.
|
||||
- **Events** arrive once. Each poll returns only what is new since the last poll, so collect them
|
||||
as you go. Results are `Event::Output`.
|
||||
- **Cancel** with `job.cancel()`. It can be called twice and on a finished job. A connection that
|
||||
cannot stop an unfinished job returns `Unsupported`: ComfyUI does so unless the app turned on
|
||||
`with_targeted_interrupt(true)`, because an older server may treat the interrupt as global. When
|
||||
cancel succeeds, wait for the `Cancelled` state before you let go of any picture the job was
|
||||
reading. When it does not, wait for any end state and discard the result.
|
||||
- **Apply the result yourself.** The layer returns bytes or text. Writing it into a document,
|
||||
with undo, is your app's job.
|
||||
|
||||
## 9. When the tool cannot run
|
||||
|
||||
There are two honest answers, and the layer gives you the data for both. You draw them in your
|
||||
own theme.
|
||||
|
||||
**No model chosen, or the choice stopped working.** Ask for the missing-model popup data. It
|
||||
holds the reason and a picker with the real ways to fix it:
|
||||
|
||||
<!-- snippet: missing_model -->
|
||||
```rust
|
||||
use craft_ai_client::{AiClient, ToolDesk};
|
||||
use craft_ai_core::AppId;
|
||||
|
||||
let desk = ToolDesk::new(
|
||||
AiClient::new(AppId::new("notes")?, shared.clone(), keys.clone()),
|
||||
shared.clone(),
|
||||
None,
|
||||
);
|
||||
let popup = desk.missing_model(&role, &craft_ai_features::builtin())?;
|
||||
// `Some` means the tool cannot run in this app; `None` means it can.
|
||||
assert!(popup.is_some()); // the "notes" app ships no adapter for "tidy"
|
||||
```
|
||||
|
||||
Choosing a model in your popup goes through `ToolDesk::choose`, which sets the default for the
|
||||
whole suite. Refusal is atomic: if the model cannot do the job, nothing is written.
|
||||
|
||||
**The person wants a different model only in your app.** `client.bind(&role, &connection, &model)`
|
||||
sets an override for your app and `client.clear_binding(&role.id)` removes it. An override
|
||||
beats the suite default. A broken override is an error. It never falls back to something else,
|
||||
because that could send data somewhere the person did not choose.
|
||||
|
||||
Setting up a model (downloads, keys, a local server) belongs to the launcher's Set up AI window or
|
||||
to your own Preferences, built from the same shared pieces. Do not build a second setup flow. If you
|
||||
have no setup screen, your popup links to the launcher.
|
||||
|
||||
## 10. Errors, in one table
|
||||
|
||||
A failed job carries an `AiError { kind, message }`. The message is safe to show.
|
||||
|
||||
| `ErrorKind` | Show | Then |
|
||||
|---|---|---|
|
||||
| `ConsentRequired` | Your consent prompt | Retry with the agreed ledger. |
|
||||
| `Auth` | The message (it names the connection) | Send the person to Set up AI to fix the key. |
|
||||
| `RateLimited` | "Try again shortly" | Wait `retry_after_secs` if present, then retry. |
|
||||
| `Quota` | The message | Do not retry. It needs money or time. |
|
||||
| `Network` | The message | Retry is reasonable. `error.is_retryable()` says so. |
|
||||
| `Provider` | The message | One retry at most. Keep the log line. |
|
||||
| `Unsupported` | The message | Offer step 9. |
|
||||
| `InvalidRequest` | The message | Your bug, or bad input. |
|
||||
| `Cancelled` | Nothing | The person did this. Not an error. |
|
||||
| `Internal` | A short apology and the message | File a bug. The layer reports these and never panics. |
|
||||
|
||||
## 11. History and privacy
|
||||
|
||||
`AiClient::standard` keeps a shared log of jobs: which app, which connection, what kind of data,
|
||||
how long, what it cost. It does **not** keep the text of prompts unless you call
|
||||
`keep_prompts_in_history(true)`. Leave that off. `client.history()` reads it for every app and
|
||||
`clear_history()` empties it. `without_history()` stops logging.
|
||||
|
||||
## 12. Widgets, if you want them
|
||||
|
||||
If your app is egui and has no chrome of its own, `craft-ai-egui` has theme-neutral status,
|
||||
picker, consent prompt, progress, connection panel and history widgets. Hosts with their own
|
||||
look should draw their own views over the same data. The data and the rules are shared. The
|
||||
look is yours.
|
||||
|
||||
## 13. Test your integration with no network
|
||||
|
||||
Build a world with no network: a temporary store, an in-memory key store, the launcher's choice
|
||||
written with `ToolDesk::choose`, and a scripted adapter registered on a `ProviderFactory`. The
|
||||
test file `crates/ai-client/tests/support/adopting_scene.rs` does exactly this, and every block on
|
||||
this page runs on top of it.
|
||||
|
||||
<!-- snippet: two_apps -->
|
||||
```rust
|
||||
use craft_ai_client::{AiClient, RoleStatus};
|
||||
use craft_ai_core::AppId;
|
||||
|
||||
// `client` ships an adapter for the chosen service, so the suite default is ready with no setup.
|
||||
assert!(matches!(client.status(&role), RoleStatus::Ready(_)));
|
||||
|
||||
// Another app reads the same store but ships no adapter, so it is unavailable, and says so.
|
||||
// `shared` and `keys` are the temporary store and key store this test built.
|
||||
let second = AiClient::new(AppId::new("lightcraft")?, shared.clone(), keys.clone());
|
||||
assert!(matches!(
|
||||
second.status(&role),
|
||||
RoleStatus::Unavailable { .. }
|
||||
));
|
||||
```
|
||||
|
||||
The tests an adopting app should have are in the [checklist](checklist.md). A Ready badge
|
||||
proves a route resolves. It does not prove the tool works. Run the real model through your own
|
||||
tool and check what lands in the document.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not store, read or print a key. The layer reads it inside `start` and nowhere else.
|
||||
- Do not hard-code a model id, a provider or an address. Read the choice.
|
||||
- Do not fall back to a different model when the chosen one fails.
|
||||
- Do not show engine or hardware words (Candle, Burn, CUDA, VRAM, wgpu) to a person.
|
||||
- Do not call a tool "ready" because files are downloaded or a key is saved. It is ready when
|
||||
`status` says so and a real run produced the right result.
|
||||
- Do not write a toolbar, a prompt bar or a model list into a shared crate. Those are the app's.
|
||||
|
||||
Next: [Declare a new tool](declare-a-tool.md), [Bring a service or model](bring-a-service.md),
|
||||
or go to the [checklist](checklist.md).
|
||||
Reference in New Issue
Block a user