From bd175be54f22b596a32aadc96a4bd58dfce25785 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 15 Oct 2026 20:06:57 +0100 Subject: [PATCH] craft-ai-core: rename from craft-ai-model; lapse each kind of consent on its own; keep models found at a new address Consent expiry is now per kind of data: renewing text no longer renews an expired image permission. A connection edit keeps models found by a test at the new address, and drops only the ones saved for the old one. --- .../workflows/{ai-model.yml => ai-core.yml} | 12 ++--- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- crates/{ai-model => ai-core}/Cargo.toml | 2 +- crates/{ai-model => ai-core}/README.md | 2 +- .../{ai-model => ai-core}/src/capability.rs | 0 crates/{ai-model => ai-core}/src/catalog.json | 0 .../{ai-model => ai-core}/src/conformance.rs | 0 .../{ai-model => ai-core}/src/connection.rs | 0 crates/{ai-model => ai-core}/src/consent.rs | 48 +++++++++++++++---- crates/{ai-model => ai-core}/src/draft.rs | 30 ++++++++++-- .../{ai-model => ai-core}/src/draft/custom.rs | 0 crates/{ai-model => ai-core}/src/draft/ids.rs | 0 .../{ai-model => ai-core}/src/draft/tests.rs | 29 +++++++++++ crates/{ai-model => ai-core}/src/endpoint.rs | 0 crates/{ai-model => ai-core}/src/error.rs | 0 crates/{ai-model => ai-core}/src/history.rs | 0 .../src/history/report.rs | 0 .../src/history/report/tests.rs | 0 .../src/history/tests.rs | 0 crates/{ai-model => ai-core}/src/ids.rs | 0 crates/{ai-model => ai-core}/src/job.rs | 0 crates/{ai-model => ai-core}/src/lib.rs | 0 crates/{ai-model => ai-core}/src/mock.rs | 0 crates/{ai-model => ai-core}/src/model.rs | 0 crates/{ai-model => ai-core}/src/payload.rs | 0 crates/{ai-model => ai-core}/src/picks.rs | 0 .../{ai-model => ai-core}/src/picks/tests.rs | 0 crates/{ai-model => ai-core}/src/provider.rs | 0 crates/{ai-model => ai-core}/src/request.rs | 0 crates/{ai-model => ai-core}/src/role.rs | 0 crates/{ai-model => ai-core}/src/secret.rs | 0 crates/{ai-model => ai-core}/src/spec.rs | 0 .../{ai-model => ai-core}/src/spec/check.rs | 0 .../src/spec/template.rs | 0 crates/{ai-model => ai-core}/src/store.rs | 0 .../tests/custom_connection.rs | 6 +-- .../tests/custom_tools.rs | 6 +-- .../tests/mock_conformance.rs | 12 ++--- .../{ai-model => ai-core}/tests/spec_calls.rs | 0 .../{ai-model => ai-core}/tests/spec_rules.rs | 2 +- .../tests/spec_support/mod.rs | 2 +- .../tests/spec_template.rs | 2 +- docs/ai-declarative-adapter.md | 6 +-- docs/ai-shared-layer.md | 6 +-- 46 files changed, 127 insertions(+), 44 deletions(-) rename .github/workflows/{ai-model.yml => ai-core.yml} (56%) rename crates/{ai-model => ai-core}/Cargo.toml (93%) rename crates/{ai-model => ai-core}/README.md (93%) rename crates/{ai-model => ai-core}/src/capability.rs (100%) rename crates/{ai-model => ai-core}/src/catalog.json (100%) rename crates/{ai-model => ai-core}/src/conformance.rs (100%) rename crates/{ai-model => ai-core}/src/connection.rs (100%) rename crates/{ai-model => ai-core}/src/consent.rs (85%) rename crates/{ai-model => ai-core}/src/draft.rs (91%) rename crates/{ai-model => ai-core}/src/draft/custom.rs (100%) rename crates/{ai-model => ai-core}/src/draft/ids.rs (100%) rename crates/{ai-model => ai-core}/src/draft/tests.rs (87%) rename crates/{ai-model => ai-core}/src/endpoint.rs (100%) rename crates/{ai-model => ai-core}/src/error.rs (100%) rename crates/{ai-model => ai-core}/src/history.rs (100%) rename crates/{ai-model => ai-core}/src/history/report.rs (100%) rename crates/{ai-model => ai-core}/src/history/report/tests.rs (100%) rename crates/{ai-model => ai-core}/src/history/tests.rs (100%) rename crates/{ai-model => ai-core}/src/ids.rs (100%) rename crates/{ai-model => ai-core}/src/job.rs (100%) rename crates/{ai-model => ai-core}/src/lib.rs (100%) rename crates/{ai-model => ai-core}/src/mock.rs (100%) rename crates/{ai-model => ai-core}/src/model.rs (100%) rename crates/{ai-model => ai-core}/src/payload.rs (100%) rename crates/{ai-model => ai-core}/src/picks.rs (100%) rename crates/{ai-model => ai-core}/src/picks/tests.rs (100%) rename crates/{ai-model => ai-core}/src/provider.rs (100%) rename crates/{ai-model => ai-core}/src/request.rs (100%) rename crates/{ai-model => ai-core}/src/role.rs (100%) rename crates/{ai-model => ai-core}/src/secret.rs (100%) rename crates/{ai-model => ai-core}/src/spec.rs (100%) rename crates/{ai-model => ai-core}/src/spec/check.rs (100%) rename crates/{ai-model => ai-core}/src/spec/template.rs (100%) rename crates/{ai-model => ai-core}/src/store.rs (100%) rename crates/{ai-model => ai-core}/tests/custom_connection.rs (95%) rename crates/{ai-model => ai-core}/tests/custom_tools.rs (92%) rename crates/{ai-model => ai-core}/tests/mock_conformance.rs (95%) rename crates/{ai-model => ai-core}/tests/spec_calls.rs (100%) rename crates/{ai-model => ai-core}/tests/spec_rules.rs (99%) rename crates/{ai-model => ai-core}/tests/spec_support/mod.rs (98%) rename crates/{ai-model => ai-core}/tests/spec_template.rs (96%) diff --git a/.github/workflows/ai-model.yml b/.github/workflows/ai-core.yml similarity index 56% rename from .github/workflows/ai-model.yml rename to .github/workflows/ai-core.yml index 47f7b061..4733eaee 100644 --- a/.github/workflows/ai-model.yml +++ b/.github/workflows/ai-core.yml @@ -4,15 +4,15 @@ on: workflow_dispatch: push: branches: [main] - paths: ['crates/ai-model/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-model.yml'] + paths: ['crates/ai-core/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-core.yml'] pull_request: - paths: ['crates/ai-model/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-model.yml'] + paths: ['crates/ai-core/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-core.yml'] permissions: contents: read jobs: - ai-model: + ai-core: timeout-minutes: 15 strategy: fail-fast: false @@ -26,7 +26,7 @@ jobs: components: rustfmt, clippy targets: wasm32-unknown-unknown - run: cargo fmt --check - - run: cargo clippy -p craft-ai-model --all-targets --locked -- -D warnings - - run: cargo test -p craft-ai-model --locked + - run: cargo clippy -p craft-ai-core --all-targets --locked -- -D warnings + - run: cargo test -p craft-ai-core --locked # The model promises to compile for the web. - - run: cargo check -p craft-ai-model --target wasm32-unknown-unknown --locked + - run: cargo check -p craft-ai-core --target wasm32-unknown-unknown --locked diff --git a/Cargo.lock b/Cargo.lock index 467fecf9..d883b892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,7 +515,7 @@ dependencies = [ ] [[package]] -name = "craft-ai-model" +name = "craft-ai-core" version = "0.1.0" dependencies = [ "serde", diff --git a/Cargo.toml b/Cargo.toml index 22677cca..0e3763fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` -craft-ai-model = { path = "crates/ai-model" } +craft-ai-core = { path = "crates/ai-core" } craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-print = { path = "crates/print" } diff --git a/README.md b/README.md index 2428b892..b3ff9d2d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ") { let kinds = d.kinds(); - if let Some(r) = self.records.iter_mut().find(|r| r.covers(d)) { + for r in self.records.iter_mut().filter(|r| r.covers(d) && r.expires_at != expires_at) { + r.kinds.retain(|k| !kinds.contains(k)); + } + self.records.retain(|r| !r.kinds.is_empty()); + if let Some(r) = self.records.iter_mut().find(|r| r.covers(d) && r.expires_at == expires_at) { for k in kinds { if !r.kinds.contains(&k) { r.kinds.push(k); } } r.kinds.sort(); - r.expires_at = expires_at; } else { self.records.push(ConsentRecord { app: d.app.clone(), @@ -312,6 +318,32 @@ mod tests { assert!(ledger.needs_consent(&disclosure_for(&upscale_req()), 5_000)); } + fn text_only_req() -> Request { + Request::new(CapabilityId::parse("image.inpaint").unwrap()).with_input(InputRole::Prompt, Payload::text("a cat")) + } + + #[test] + fn renewing_text_does_not_renew_an_expired_image_permission() { + let mut ledger = ConsentLedger::default(); + ledger.grant_until(&disclosure_for(&req()), 1_000); + let text = disclosure_for(&text_only_req()); + assert!(ledger.needs_consent(&text, 2_000)); + ledger.grant_until(&text, 5_000); + assert!(!ledger.needs_consent(&text, 2_000)); + assert!(ledger.needs_consent(&disclosure_for(&req()), 2_000), "image consent lapsed at 1000 and was not renewed"); + } + + #[test] + fn renewing_one_kind_leaves_the_others_on_their_own_expiry() { + let mut ledger = ConsentLedger::default(); + ledger.grant_until(&disclosure_for(&req()), 1_000); + ledger.grant(&disclosure_for(&text_only_req())); + let both = disclosure_for(&req()); + assert!(!ledger.needs_consent(&both, 999)); + assert!(ledger.needs_consent(&both, 1_000)); + assert!(!ledger.needs_consent(&disclosure_for(&text_only_req()), u64::MAX)); + } + #[test] fn a_record_from_before_scoping_loads_but_covers_nothing() { let old = r#"{"records":[{"app":"photocraft","connection":"a","destination":"https://api.example.com","kinds":["text","image"]}]}"#; diff --git a/crates/ai-model/src/draft.rs b/crates/ai-core/src/draft.rs similarity index 91% rename from crates/ai-model/src/draft.rs rename to crates/ai-core/src/draft.rs index ca99dac9..3cd9cbf6 100644 --- a/crates/ai-model/src/draft.rs +++ b/crates/ai-core/src/draft.rs @@ -9,8 +9,8 @@ //! - **A key is checked before it is kept.** One line of visible ASCII, no spaces. A pasted //! newline would otherwise reach an HTTP header. //! - **Editing keeps what was not touched.** Leaving the key box empty keeps the saved key; the -//! models found on a server are dropped when the address changes, because they belong to the -//! old one. +//! models saved with a connection are dropped when the address changes, because they belong to +//! the old one. Models found by a test at the new address are kept. //! - **Messages are for people.** Every [`DraftError`] names the field and says what to do, and //! never contains the key. //! @@ -249,6 +249,11 @@ pub struct ConnectionDraft { /// Editing: the saved key's locator, and whether a key was saved at all. secret: Option, original_endpoint: Option, + /// Editing: the models the connection had when the form opened, which belong to the original + /// address. + loaded_models: Vec, + /// The address `models` were last set for through [`set_models`](Self::set_models). + models_for: Option, } impl Default for ConnectionDraft { @@ -266,6 +271,8 @@ impl Default for ConnectionDraft { models: Vec::new(), secret: None, original_endpoint: None, + loaded_models: Vec::new(), + models_for: None, } } } @@ -313,9 +320,25 @@ impl ConnectionDraft { models: c.models.clone(), secret: c.auth.secret_ref().cloned(), original_endpoint: c.endpoint.clone(), + loaded_models: c.models.clone(), + models_for: None, } } + /// Replaces `models` with what a test found at the address now in the form. They are kept on + /// save unless the address changes again afterwards. + pub fn set_models(&mut self, models: Vec) { + self.models = models; + self.models_for = Some(self.endpoint.trim().to_owned()); + } + + /// False when the address moved since the form opened and `models` are still the old + /// address's: neither found by a test at this address nor different from what was loaded. + fn models_fit(&self, address: &str) -> bool { + let moved = self.original_endpoint.as_deref().is_some_and(|o| o != address); + !moved || self.models_for.as_deref() == Some(address) || (self.models_for.is_none() && self.models != self.loaded_models) + } + /// Where this would send data, as far as the address can tell. `None` while it is not a usable /// address, so a form can show the badge live. pub fn locality(&self) -> Option { @@ -360,7 +383,6 @@ impl ConnectionDraft { None => new_id(label, existing)?, }; let (auth, key) = self.auth_and_key(typed, &id, existing)?; - let moved = self.original_endpoint.as_deref().is_some_and(|o| o != address); let connection = Connection { id, label: label.to_owned(), @@ -369,7 +391,7 @@ impl ConnectionDraft { locality, auth, billing: self.billing, - models: if moved { Vec::new() } else { self.models.clone() }, + models: if self.models_fit(address) { self.models.clone() } else { Vec::new() }, enabled: self.enabled, key_header, }; diff --git a/crates/ai-model/src/draft/custom.rs b/crates/ai-core/src/draft/custom.rs similarity index 100% rename from crates/ai-model/src/draft/custom.rs rename to crates/ai-core/src/draft/custom.rs diff --git a/crates/ai-model/src/draft/ids.rs b/crates/ai-core/src/draft/ids.rs similarity index 100% rename from crates/ai-model/src/draft/ids.rs rename to crates/ai-core/src/draft/ids.rs diff --git a/crates/ai-model/src/draft/tests.rs b/crates/ai-core/src/draft/tests.rs similarity index 87% rename from crates/ai-model/src/draft/tests.rs rename to crates/ai-core/src/draft/tests.rs index 93a21f15..20e26280 100644 --- a/crates/ai-model/src/draft/tests.rs +++ b/crates/ai-core/src/draft/tests.rs @@ -174,6 +174,35 @@ fn models_follow_the_address() { assert!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models.is_empty()); } +#[test] +fn models_found_at_the_new_address_are_kept() { + let saved = saved_openai(); + let found = vec![ModelInfo::new(crate::ids::ModelId::new("llama-x").unwrap(), "Llama X")]; + let mut moved = ConnectionDraft::from_connection(&saved); + moved.endpoint = "https://other.example.com/v1".into(); + moved.models = found.clone(); + assert_eq!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, found); +} + +#[test] +fn a_retest_that_finds_the_same_models_keeps_them() { + let saved = saved_openai(); + let mut moved = ConnectionDraft::from_connection(&saved); + moved.endpoint = "https://other.example.com/v1".into(); + moved.set_models(saved.models.clone()); + assert_eq!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, saved.models); +} + +#[test] +fn models_found_before_the_address_changed_are_dropped() { + let saved = saved_openai(); + let found = vec![ModelInfo::new(crate::ids::ModelId::new("llama-x").unwrap(), "Llama X")]; + let mut d = ConnectionDraft::from_connection(&saved); + d.set_models(found); + d.endpoint = "https://other.example.com/v1".into(); + assert!(d.prepare(std::slice::from_ref(&saved)).unwrap().connection.models.is_empty()); +} + #[test] fn editing_something_removed_elsewhere_is_an_error() { let saved = saved_openai(); diff --git a/crates/ai-model/src/endpoint.rs b/crates/ai-core/src/endpoint.rs similarity index 100% rename from crates/ai-model/src/endpoint.rs rename to crates/ai-core/src/endpoint.rs diff --git a/crates/ai-model/src/error.rs b/crates/ai-core/src/error.rs similarity index 100% rename from crates/ai-model/src/error.rs rename to crates/ai-core/src/error.rs diff --git a/crates/ai-model/src/history.rs b/crates/ai-core/src/history.rs similarity index 100% rename from crates/ai-model/src/history.rs rename to crates/ai-core/src/history.rs diff --git a/crates/ai-model/src/history/report.rs b/crates/ai-core/src/history/report.rs similarity index 100% rename from crates/ai-model/src/history/report.rs rename to crates/ai-core/src/history/report.rs diff --git a/crates/ai-model/src/history/report/tests.rs b/crates/ai-core/src/history/report/tests.rs similarity index 100% rename from crates/ai-model/src/history/report/tests.rs rename to crates/ai-core/src/history/report/tests.rs diff --git a/crates/ai-model/src/history/tests.rs b/crates/ai-core/src/history/tests.rs similarity index 100% rename from crates/ai-model/src/history/tests.rs rename to crates/ai-core/src/history/tests.rs diff --git a/crates/ai-model/src/ids.rs b/crates/ai-core/src/ids.rs similarity index 100% rename from crates/ai-model/src/ids.rs rename to crates/ai-core/src/ids.rs diff --git a/crates/ai-model/src/job.rs b/crates/ai-core/src/job.rs similarity index 100% rename from crates/ai-model/src/job.rs rename to crates/ai-core/src/job.rs diff --git a/crates/ai-model/src/lib.rs b/crates/ai-core/src/lib.rs similarity index 100% rename from crates/ai-model/src/lib.rs rename to crates/ai-core/src/lib.rs diff --git a/crates/ai-model/src/mock.rs b/crates/ai-core/src/mock.rs similarity index 100% rename from crates/ai-model/src/mock.rs rename to crates/ai-core/src/mock.rs diff --git a/crates/ai-model/src/model.rs b/crates/ai-core/src/model.rs similarity index 100% rename from crates/ai-model/src/model.rs rename to crates/ai-core/src/model.rs diff --git a/crates/ai-model/src/payload.rs b/crates/ai-core/src/payload.rs similarity index 100% rename from crates/ai-model/src/payload.rs rename to crates/ai-core/src/payload.rs diff --git a/crates/ai-model/src/picks.rs b/crates/ai-core/src/picks.rs similarity index 100% rename from crates/ai-model/src/picks.rs rename to crates/ai-core/src/picks.rs diff --git a/crates/ai-model/src/picks/tests.rs b/crates/ai-core/src/picks/tests.rs similarity index 100% rename from crates/ai-model/src/picks/tests.rs rename to crates/ai-core/src/picks/tests.rs diff --git a/crates/ai-model/src/provider.rs b/crates/ai-core/src/provider.rs similarity index 100% rename from crates/ai-model/src/provider.rs rename to crates/ai-core/src/provider.rs diff --git a/crates/ai-model/src/request.rs b/crates/ai-core/src/request.rs similarity index 100% rename from crates/ai-model/src/request.rs rename to crates/ai-core/src/request.rs diff --git a/crates/ai-model/src/role.rs b/crates/ai-core/src/role.rs similarity index 100% rename from crates/ai-model/src/role.rs rename to crates/ai-core/src/role.rs diff --git a/crates/ai-model/src/secret.rs b/crates/ai-core/src/secret.rs similarity index 100% rename from crates/ai-model/src/secret.rs rename to crates/ai-core/src/secret.rs diff --git a/crates/ai-model/src/spec.rs b/crates/ai-core/src/spec.rs similarity index 100% rename from crates/ai-model/src/spec.rs rename to crates/ai-core/src/spec.rs diff --git a/crates/ai-model/src/spec/check.rs b/crates/ai-core/src/spec/check.rs similarity index 100% rename from crates/ai-model/src/spec/check.rs rename to crates/ai-core/src/spec/check.rs diff --git a/crates/ai-model/src/spec/template.rs b/crates/ai-core/src/spec/template.rs similarity index 100% rename from crates/ai-model/src/spec/template.rs rename to crates/ai-core/src/spec/template.rs diff --git a/crates/ai-model/src/store.rs b/crates/ai-core/src/store.rs similarity index 100% rename from crates/ai-model/src/store.rs rename to crates/ai-core/src/store.rs diff --git a/crates/ai-model/tests/custom_connection.rs b/crates/ai-core/tests/custom_connection.rs similarity index 95% rename from crates/ai-model/tests/custom_connection.rs rename to crates/ai-core/tests/custom_connection.rs index fff3f61e..f209dbe4 100644 --- a/crates/ai-model/tests/custom_connection.rs +++ b/crates/ai-core/tests/custom_connection.rs @@ -1,8 +1,8 @@ //! u1: the rules for a custom (unlisted, OpenAI-shaped) connection: a named key header, and what //! a stored file may carry. The form is `ConnectionDraft`; these tests go through its public API. -use craft_ai_model::connection::ConnectionError; -use craft_ai_model::{Billing, Connection, ConnectionDraft, Field, KeyChange, Locality}; +use craft_ai_core::connection::ConnectionError; +use craft_ai_core::{Billing, Connection, ConnectionDraft, Field, KeyChange, Locality}; fn custom(endpoint: &str, header: &str) -> ConnectionDraft { let mut d = ConnectionDraft::custom(); @@ -97,7 +97,7 @@ fn a_stored_file_with_a_bad_header_name_is_refused_on_validate() { c.key_header = Some("host".to_owned()); assert!(matches!(c.validate(), Err(ConnectionError::KeyHeader(_)))); c.key_header = Some("x-api-key".to_owned()); - c.auth = craft_ai_model::Auth::None; + c.auth = craft_ai_core::Auth::None; assert!(matches!(c.validate(), Err(ConnectionError::KeyHeader(_)))); } diff --git a/crates/ai-model/tests/custom_tools.rs b/crates/ai-core/tests/custom_tools.rs similarity index 92% rename from crates/ai-model/tests/custom_tools.rs rename to crates/ai-core/tests/custom_tools.rs index 1b1c7a8e..67427279 100644 --- a/crates/ai-model/tests/custom_tools.rs +++ b/crates/ai-core/tests/custom_tools.rs @@ -1,4 +1,4 @@ -use craft_ai_model::{CapabilityId, InputRole, Role, RoleId, StoreFile}; +use craft_ai_core::{CapabilityId, InputRole, Role, RoleId, StoreFile}; fn tool(id: &str) -> Role { Role { @@ -45,8 +45,8 @@ fn hostile_tool_declarations_are_refused_on_load() { #[test] fn an_app_cannot_reinterpret_the_inputs_of_a_shared_custom_tool() { - use craft_ai_model::role::ResolveError; - use craft_ai_model::{AppId, Binding, CapabilitySpec, Connection, ConnectionId, ModelId, ModelInfo}; + use craft_ai_core::role::ResolveError; + use craft_ai_core::{AppId, Binding, CapabilitySpec, Connection, ConnectionId, ModelId, ModelInfo}; let shared = tool("describe-photo"); let model = ModelInfo::new(ModelId::new("m").unwrap(), "M").with(CapabilitySpec::new(shared.capability.clone()).accepting(InputRole::Source, 1)); let connection: Connection = serde_json::from_value(serde_json::json!({ diff --git a/crates/ai-model/tests/mock_conformance.rs b/crates/ai-core/tests/mock_conformance.rs similarity index 95% rename from crates/ai-model/tests/mock_conformance.rs rename to crates/ai-core/tests/mock_conformance.rs index eb79f13f..26fe5ed2 100644 --- a/crates/ai-model/tests/mock_conformance.rs +++ b/crates/ai-core/tests/mock_conformance.rs @@ -2,12 +2,12 @@ //! end to end the way an app would use it: declare roles, resolve through the store, check //! consent, submit, poll, cancel. Also proves one library serves different modalities. -use craft_ai_model::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job}; -use craft_ai_model::consent::Disclosure; -use craft_ai_model::job::{Event, JobState, Output}; -use craft_ai_model::mock::{MockProvider, Outcome, Script}; -use craft_ai_model::role::{Binding, Role}; -use craft_ai_model::*; +use craft_ai_core::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job}; +use craft_ai_core::consent::Disclosure; +use craft_ai_core::job::{Event, JobState, Output}; +use craft_ai_core::mock::{MockProvider, Outcome, Script}; +use craft_ai_core::role::{Binding, Role}; +use craft_ai_core::*; fn cap(s: &str) -> CapabilityId { CapabilityId::parse(s).unwrap() diff --git a/crates/ai-model/tests/spec_calls.rs b/crates/ai-core/tests/spec_calls.rs similarity index 100% rename from crates/ai-model/tests/spec_calls.rs rename to crates/ai-core/tests/spec_calls.rs diff --git a/crates/ai-model/tests/spec_rules.rs b/crates/ai-core/tests/spec_rules.rs similarity index 99% rename from crates/ai-model/tests/spec_rules.rs rename to crates/ai-core/tests/spec_rules.rs index 9130d22a..28a24087 100644 --- a/crates/ai-model/tests/spec_rules.rs +++ b/crates/ai-core/tests/spec_rules.rs @@ -3,7 +3,7 @@ mod spec_support; -use craft_ai_model::spec::{MAX_SPEC_BYTES, Spec}; +use craft_ai_core::spec::{MAX_SPEC_BYTES, Spec}; use serde_json::{Value, json}; use spec_support::{direct, parse, queue, refused, set}; diff --git a/crates/ai-model/tests/spec_support/mod.rs b/crates/ai-core/tests/spec_support/mod.rs similarity index 98% rename from crates/ai-model/tests/spec_support/mod.rs rename to crates/ai-core/tests/spec_support/mod.rs index b0c5a5af..6750aba4 100644 --- a/crates/ai-model/tests/spec_support/mod.rs +++ b/crates/ai-core/tests/spec_support/mod.rs @@ -1,7 +1,7 @@ //! Fixtures for the spec tests: the two documented shapes and small edit helpers. #![allow(dead_code)] -use craft_ai_model::spec::{Spec, SpecError}; +use craft_ai_core::spec::{Spec, SpecError}; use serde_json::{Value, json}; pub fn queue() -> Value { diff --git a/crates/ai-model/tests/spec_template.rs b/crates/ai-core/tests/spec_template.rs similarity index 96% rename from crates/ai-model/tests/spec_template.rs rename to crates/ai-core/tests/spec_template.rs index 4a02e36b..f5689f67 100644 --- a/crates/ai-model/tests/spec_template.rs +++ b/crates/ai-core/tests/spec_template.rs @@ -1,7 +1,7 @@ //! Substitution rules for declarative specs: what a template may name, and that the structure of //! a request can never come from the user's text. -use craft_ai_model::spec::template::{Slot, Values, render_text, render_value, slots, value_slots}; +use craft_ai_core::spec::template::{Slot, Values, render_text, render_value, slots, value_slots}; use serde_json::json; fn values() -> Values<'static> { diff --git a/docs/ai-declarative-adapter.md b/docs/ai-declarative-adapter.md index c2faa668..3fcce63e 100644 --- a/docs/ai-declarative-adapter.md +++ b/docs/ai-declarative-adapter.md @@ -108,7 +108,7 @@ live in the wasm-safe model crate so the launcher can check a spec as it is past ## Where it sits -- `ai-model`: `spec.rs`, the types and the pure validator (wasm-safe, so the launcher can validate +- `ai-core`: `spec.rs`, the types and the pure validator (wasm-safe, so the launcher can validate live). No I/O. - New native crate `craft-ai-declarative`: the runner that implements `Provider`, with the HTTP layer copied in shape from `craft-ai-bfl` (no redirects, redacted errors, capped bodies). @@ -155,9 +155,9 @@ tests), none above 500. ## As built (13/10/2026) -Code: `crates/ai-model/src/spec.rs` and `spec/` (types, checker, substitutions; wasm-safe), +Code: `crates/ai-core/src/spec.rs` and `spec/` (types, checker, substitutions; wasm-safe), `crates/ai-declarative` (the runner), `crates/ai-client/src/declarative.rs` (the factory entry). -Tests: `ai-model/tests/spec_*.rs`, `ai-declarative/tests/{queue,direct}.rs`, +Tests: `ai-core/tests/spec_*.rs`, `ai-declarative/tests/{queue,direct}.rs`, `ai-client/tests/declarative.rs`. Deliberate-bug cases: group `u2` in `docs/research/phase2d/mutation_check.py` (cases in `mutation_u2.py`). diff --git a/docs/ai-shared-layer.md b/docs/ai-shared-layer.md index 0eef4899..279b4393 100644 --- a/docs/ai-shared-layer.md +++ b/docs/ai-shared-layer.md @@ -1,6 +1,6 @@ # Suite-wide AI contract for app maintainers -**Status:** proposed for review, 13/10/2026. This repo receives `craft-ai-model` (the wasm-safe data model and contract) first; the store, client, adapters and widgets named below arrive in later pull requests and are described here so the whole contract can be reviewed at once. No app mounts it end to end yet. +**Status:** proposed for review, 13/10/2026. This repo receives `craft-ai-core` (the wasm-safe data model and contract) first; the store, client, adapters and widgets named below arrive in later pull requests and are described here so the whole contract can be reviewed at once. No app mounts it end to end yet. ## Ownership boundary @@ -14,11 +14,11 @@ A registered tool is a **declaration**, not installed code. A compatible binding ## Declaring and adopting a tool -1. Define a `craft_ai_model::Role` with a stable `RoleId`, a display label, one `CapabilityId`, and a nonempty set of `InputRole`s. Choose a suite-wide ID with the other maintainers. `ToolDesk::register_tool(role)` writes it once to the shared store; `ToolDesk::custom_tools()` reads registered declarations in any process using that same store. Built-in IDs cannot be replaced. A declaration does not add it to the seven built-in catalogue. +1. Define a `craft_ai_core::Role` with a stable `RoleId`, a display label, one `CapabilityId`, and a nonempty set of `InputRole`s. Choose a suite-wide ID with the other maintainers. `ToolDesk::register_tool(role)` writes it once to the shared store; `ToolDesk::custom_tools()` reads registered declarations in any process using that same store. Built-in IDs cannot be replaced. A declaration does not add it to the seven built-in catalogue. 2. Add or discover a connection and a model advertising that capability and the required inputs. `ToolDesk::choose(&role, &connection_id, &model_id)` sets the suite default. The shared update verifies the declared tool contract and model compatibility before persisting the binding. An app can use `AiClient::bind` for an explicit per-app override. Removal with `ToolDesk::remove_tool(&role_id)` deletes the declaration and its suite/app bindings together. 3. An app reads the same store and resolves the role with `AiClient::status`, `choices`, `picker`, `plan` and `start` as appropriate. It may localise the label, but **must not reinterpret the same ID's capability or inputs**. Mismatched contracts are rejected during resolution, model listing, picker creation and binding. Apps still own their UI, input conversion and actual execution route. -Concrete API examples and regressions: `crates/ai-client/tests/custom_tools.rs`, `crates/ai-model/tests/custom_tools.rs` and `crates/ai-native/tests/shared_store.rs`. The shared store is `craft_ai_native::shared::SharedStore`; it uses locked atomic updates for registration, binding and removal. Old version-1 JSON files without `custom_tools` still load with an empty list. Secret values belong in the secret store, never in the shared JSON or a tool declaration. +Concrete API examples and regressions: `crates/ai-client/tests/custom_tools.rs`, `crates/ai-core/tests/custom_tools.rs` and `crates/ai-native/tests/shared_store.rs`. The shared store is `craft_ai_native::shared::SharedStore`; it uses locked atomic updates for registration, binding and removal. Old version-1 JSON files without `custom_tools` still load with an empty list. Secret values belong in the secret store, never in the shared JSON or a tool declaration. ## Deliberate limits and next gate