mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 23:59:16 +00:00
craft-ai-core: track model provenance by address; require the inputs a model needs
This commit is contained in:
1 parent
90792958fb
commit
a40245bd36
4 files changed
+168
-20
No files matched your search
+10
-16
@@ -244,15 +244,12 @@ pub struct ConnectionDraft {
|
|||||||
/// Header that carries the key; empty sends it as `Authorization: Bearer`.
|
/// Header that carries the key; empty sends it as `Authorization: Bearer`.
|
||||||
pub key_header: String,
|
pub key_header: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Models found by a test, carried onto the saved connection.
|
/// Models carried onto the saved connection. Use [`set_models`](Self::set_models) when
|
||||||
|
/// replacing models after an address change so their address is recorded.
|
||||||
pub models: Vec<ModelInfo>,
|
pub models: Vec<ModelInfo>,
|
||||||
/// Editing: the saved key's locator, and whether a key was saved at all.
|
/// Editing: the saved key's locator, and whether a key was saved at all.
|
||||||
secret: Option<SecretRef>,
|
secret: Option<SecretRef>,
|
||||||
original_endpoint: Option<String>,
|
/// The address `models` were loaded, preset or last set for.
|
||||||
/// Editing: the models the connection had when the form opened, which belong to the original
|
|
||||||
/// address.
|
|
||||||
loaded_models: Vec<ModelInfo>,
|
|
||||||
/// The address `models` were last set for through [`set_models`](Self::set_models).
|
|
||||||
models_for: Option<String>,
|
models_for: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +267,6 @@ impl Default for ConnectionDraft {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
secret: None,
|
secret: None,
|
||||||
original_endpoint: None,
|
|
||||||
loaded_models: Vec::new(),
|
|
||||||
models_for: None,
|
models_for: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,13 +289,15 @@ impl fmt::Debug for ConnectionDraft {
|
|||||||
impl ConnectionDraft {
|
impl ConnectionDraft {
|
||||||
/// A new connection started from a preset.
|
/// A new connection started from a preset.
|
||||||
pub fn from_preset(preset: &Preset) -> Self {
|
pub fn from_preset(preset: &Preset) -> Self {
|
||||||
|
let models = preset_models(preset);
|
||||||
Self {
|
Self {
|
||||||
label: preset.name.to_owned(),
|
label: preset.name.to_owned(),
|
||||||
provider: preset.provider.to_owned(),
|
provider: preset.provider.to_owned(),
|
||||||
endpoint: preset.endpoint.to_owned(),
|
endpoint: preset.endpoint.to_owned(),
|
||||||
billing: preset.billing,
|
billing: preset.billing,
|
||||||
uses_key: preset.needs_key,
|
uses_key: preset.needs_key,
|
||||||
models: preset_models(preset),
|
models_for: (!models.is_empty()).then(|| preset.endpoint.trim().to_owned()),
|
||||||
|
models,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,9 +316,7 @@ impl ConnectionDraft {
|
|||||||
enabled: c.enabled,
|
enabled: c.enabled,
|
||||||
models: c.models.clone(),
|
models: c.models.clone(),
|
||||||
secret: c.auth.secret_ref().cloned(),
|
secret: c.auth.secret_ref().cloned(),
|
||||||
original_endpoint: c.endpoint.clone(),
|
models_for: c.endpoint.clone(),
|
||||||
loaded_models: c.models.clone(),
|
|
||||||
models_for: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,11 +327,10 @@ impl ConnectionDraft {
|
|||||||
self.models_for = Some(self.endpoint.trim().to_owned());
|
self.models_for = Some(self.endpoint.trim().to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// False when the address moved since the form opened and `models` are still the old
|
/// Known model provenance must match the current address, including after repeated edits.
|
||||||
/// address's: neither found by a test at this address nor different from what was loaded.
|
/// A new custom draft may also have manually declared models without a prior address.
|
||||||
fn models_fit(&self, address: &str) -> bool {
|
fn models_fit(&self, address: &str) -> bool {
|
||||||
let moved = self.original_endpoint.as_deref().is_some_and(|o| o != address);
|
self.models_for.as_deref().is_none_or(|origin| origin == 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
|
/// Where this would send data, as far as the address can tell. `None` while it is not a usable
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ fn models_found_at_the_new_address_are_kept() {
|
|||||||
let found = vec![ModelInfo::new(crate::ids::ModelId::new("llama-x").unwrap(), "Llama X")];
|
let found = vec![ModelInfo::new(crate::ids::ModelId::new("llama-x").unwrap(), "Llama X")];
|
||||||
let mut moved = ConnectionDraft::from_connection(&saved);
|
let mut moved = ConnectionDraft::from_connection(&saved);
|
||||||
moved.endpoint = "https://other.example.com/v1".into();
|
moved.endpoint = "https://other.example.com/v1".into();
|
||||||
moved.models = found.clone();
|
moved.set_models(found.clone());
|
||||||
assert_eq!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, found);
|
assert_eq!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, found);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub struct Role {
|
|||||||
pub id: RoleId,
|
pub id: RoleId,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub capability: CapabilityId,
|
pub capability: CapabilityId,
|
||||||
/// Inputs this feature will send, so a model that cannot take them is not offered.
|
/// Inputs this feature will send. A model must accept them and require no others.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub inputs: Vec<InputRole>,
|
pub inputs: Vec<InputRole>,
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,7 @@ pub enum ResolveError {
|
|||||||
ModelMissing { connection: ConnectionId, model: ModelId },
|
ModelMissing { connection: ConnectionId, model: ModelId },
|
||||||
CapabilityMismatch { model: ModelId, capability: CapabilityId },
|
CapabilityMismatch { model: ModelId, capability: CapabilityId },
|
||||||
InputUnsupported { model: ModelId, input: InputRole },
|
InputUnsupported { model: ModelId, input: InputRole },
|
||||||
|
InputRequired { model: ModelId, input: InputRole },
|
||||||
ToolMismatch(RoleId),
|
ToolMismatch(RoleId),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,9 @@ impl std::fmt::Display for ResolveError {
|
|||||||
Self::InputUnsupported { model, input } => {
|
Self::InputUnsupported { model, input } => {
|
||||||
write!(f, "model {model} does not accept a '{input}' input")
|
write!(f, "model {model} does not accept a '{input}' input")
|
||||||
}
|
}
|
||||||
|
Self::InputRequired { model, input } => {
|
||||||
|
write!(f, "model {model} requires a '{input}' input that this tool does not provide")
|
||||||
|
}
|
||||||
Self::ToolMismatch(id) => write!(f, "tool {id} does not match its shared definition"),
|
Self::ToolMismatch(id) => write!(f, "tool {id} does not match its shared definition"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,14 +98,18 @@ pub fn resolve<'a>(role: &Role, layers: &[&[Binding]], connections: &'a [Connect
|
|||||||
if let Some(input) = role.inputs.iter().find(|i| !spec.accepts.contains_key(i)) {
|
if let Some(input) = role.inputs.iter().find(|i| !spec.accepts.contains_key(i)) {
|
||||||
return Err(ResolveError::InputUnsupported { model: model.id.clone(), input: input.clone() });
|
return Err(ResolveError::InputUnsupported { model: model.id.clone(), input: input.clone() });
|
||||||
}
|
}
|
||||||
|
if let Some(input) = spec.requires.iter().find(|i| !role.inputs.contains(i)) {
|
||||||
|
return Err(ResolveError::InputRequired { model: model.id.clone(), input: input.clone() });
|
||||||
|
}
|
||||||
Ok(Resolved { connection, model, spec })
|
Ok(Resolved { connection, model, spec })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `model` can do for `role`: its spec for the role's capability, if it takes every input the
|
/// What `model` can do for `role`: its spec for the role's capability, if it takes every input the
|
||||||
/// role sends. `None` means the model cannot fill the role.
|
/// role sends and the role supplies every input the model requires. `None` means the model
|
||||||
|
/// cannot fill the role.
|
||||||
pub fn fits<'a>(role: &Role, model: &'a ModelInfo) -> Option<&'a CapabilitySpec> {
|
pub fn fits<'a>(role: &Role, model: &'a ModelInfo) -> Option<&'a CapabilitySpec> {
|
||||||
let spec = model.spec_for(&role.capability)?;
|
let spec = model.spec_for(&role.capability)?;
|
||||||
role.inputs.iter().all(|i| spec.accepts.contains_key(i)).then_some(spec)
|
(role.inputs.iter().all(|i| spec.accepts.contains_key(i)) && spec.requires.iter().all(|i| role.inputs.contains(i))).then_some(spec)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every enabled connection's models that could fill `role`. This is the model picker's list.
|
/// Every enabled connection's models that could fill `role`. This is the model picker's list.
|
||||||
@@ -187,6 +195,17 @@ mod tests {
|
|||||||
assert!(matches!(err, ResolveError::InputUnsupported { input: InputRole::Mask, .. }));
|
assert!(matches!(err, ResolveError::InputUnsupported { input: InputRole::Mask, .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_required_input_the_role_cannot_supply_has_a_specific_error() {
|
||||||
|
let mut conns = [conn("a", true)];
|
||||||
|
conns[0].models[0].capabilities[0].requires = vec![InputRole::Mask];
|
||||||
|
let mut r = role();
|
||||||
|
r.inputs = vec![InputRole::Source];
|
||||||
|
let err = resolve(&r, &[&[bind("a")]], &conns).unwrap_err();
|
||||||
|
assert!(matches!(err, ResolveError::InputRequired { input: InputRole::Mask, .. }));
|
||||||
|
assert_eq!(err.to_string(), "model m requires a 'mask' input that this tool does not provide");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wrong_capability_and_missing_model() {
|
fn wrong_capability_and_missing_model() {
|
||||||
let mut conns = [conn("a", true)];
|
let mut conns = [conn("a", true)];
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use craft_ai_core::role::{eligible, fits, resolve};
|
||||||
|
use craft_ai_core::*;
|
||||||
|
|
||||||
|
fn draft(endpoint: &str) -> ConnectionDraft {
|
||||||
|
let mut draft = ConnectionDraft::default();
|
||||||
|
draft.label = "Test".into();
|
||||||
|
draft.endpoint = endpoint.into();
|
||||||
|
draft.uses_key = false;
|
||||||
|
draft
|
||||||
|
}
|
||||||
|
|
||||||
|
fn models(id: &str) -> Vec<ModelInfo> {
|
||||||
|
vec![ModelInfo::new(ModelId::new(id).unwrap(), id)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_draft_discards_models_when_address_changes_after_probe() {
|
||||||
|
let mut draft = draft("https://a.example/v1");
|
||||||
|
draft.set_models(models("a-only"));
|
||||||
|
draft.endpoint = "https://b.example/v1".into();
|
||||||
|
assert!(draft.prepare(&[]).unwrap().connection.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returning_to_original_address_does_not_keep_another_addresses_models() {
|
||||||
|
let saved = draft("https://a.example/v1").prepare(&[]).unwrap().connection;
|
||||||
|
let mut draft = ConnectionDraft::from_connection(&saved);
|
||||||
|
draft.endpoint = "https://b.example/v1".into();
|
||||||
|
draft.set_models(models("b-only"));
|
||||||
|
draft.endpoint = "https://a.example/v1".into();
|
||||||
|
assert!(draft.prepare(&[saved]).unwrap().connection.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_model_contents_does_not_establish_address_provenance() {
|
||||||
|
let saved = draft("https://a.example/v1").prepare(&[]).unwrap().connection;
|
||||||
|
let mut draft = ConnectionDraft::from_connection(&saved);
|
||||||
|
draft.models = models("refreshed-at-a");
|
||||||
|
draft.endpoint = "https://b.example/v1".into();
|
||||||
|
assert!(draft.prepare(&[saved]).unwrap().connection.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_models_belong_to_the_preset_address() {
|
||||||
|
let preset = PRESETS.iter().find(|preset| preset.key == "vertex-express").unwrap();
|
||||||
|
let mut draft = ConnectionDraft::from_preset(preset);
|
||||||
|
draft.key = "test-key".into();
|
||||||
|
assert!(!draft.prepare(&[]).unwrap().connection.models.is_empty());
|
||||||
|
draft.endpoint = "https://other.example/v1".into();
|
||||||
|
assert!(draft.prepare(&[]).unwrap().connection.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_test_at_each_address_keeps_its_models() {
|
||||||
|
let saved = draft("https://a.example/v1").prepare(&[]).unwrap().connection;
|
||||||
|
let mut draft = ConnectionDraft::from_connection(&saved);
|
||||||
|
for address in ["https://b.example/v1", "https://a.example/v1", "https://c.example/v1"] {
|
||||||
|
draft.endpoint = format!(" {address} ");
|
||||||
|
draft.set_models(models("same-model"));
|
||||||
|
assert_eq!(draft.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, models("same-model"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_and_returning_without_a_probe_preserves_original_models() {
|
||||||
|
let mut saved = draft("https://a.example/v1").prepare(&[]).unwrap().connection;
|
||||||
|
saved.models = models("a-only");
|
||||||
|
let mut draft = ConnectionDraft::from_connection(&saved);
|
||||||
|
draft.endpoint = "https://b.example/v1".into();
|
||||||
|
assert!(draft.prepare(std::slice::from_ref(&saved)).unwrap().connection.models.is_empty());
|
||||||
|
draft.endpoint = "https://a.example/v1".into();
|
||||||
|
assert_eq!(draft.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, saved.models);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_manual_models_still_work_for_a_new_connection_and_unchanged_address() {
|
||||||
|
let mut draft = ConnectionDraft::custom();
|
||||||
|
draft.endpoint = "https://custom.example/v1".into();
|
||||||
|
draft.uses_key = false;
|
||||||
|
draft.models = models("manual-model");
|
||||||
|
let saved = draft.prepare(&[]).unwrap().connection;
|
||||||
|
assert_eq!(saved.models, models("manual-model"));
|
||||||
|
let mut edited = ConnectionDraft::from_connection(&saved);
|
||||||
|
edited.models = models("other-manual-model");
|
||||||
|
assert_eq!(edited.prepare(&[saved]).unwrap().connection.models, models("other-manual-model"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_and_connection() -> (Role, Connection, Binding) {
|
||||||
|
let capability = CapabilityId::parse("image.inpaint").unwrap();
|
||||||
|
let role = Role {
|
||||||
|
id: RoleId::new("source-only").unwrap(),
|
||||||
|
label: "Source only".into(),
|
||||||
|
capability: capability.clone(),
|
||||||
|
inputs: vec![InputRole::Source],
|
||||||
|
};
|
||||||
|
let mut connection = draft("https://a.example/v1").prepare(&[]).unwrap().connection;
|
||||||
|
connection.models = vec![
|
||||||
|
ModelInfo::new(ModelId::new("needs-mask").unwrap(), "Needs mask").with(
|
||||||
|
CapabilitySpec::new(capability)
|
||||||
|
.accepting(InputRole::Source, 1)
|
||||||
|
.accepting(InputRole::Mask, 1)
|
||||||
|
.requiring(InputRole::Source)
|
||||||
|
.requiring(InputRole::Mask),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let binding = Binding { role: role.id.clone(), connection: connection.id.clone(), model: connection.models[0].id.clone() };
|
||||||
|
(role, connection, binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_picker_excludes_inputs_the_tool_cannot_supply() {
|
||||||
|
let (role, connection, _) = role_and_connection();
|
||||||
|
let request = Request::new(role.capability.clone()).with_input(InputRole::Source, Payload::inline("image/png", vec![1]).unwrap());
|
||||||
|
assert!(request.validate(&connection.models[0].capabilities[0], &Limits::default()).is_err());
|
||||||
|
assert!(fits(&role, &connection.models[0]).is_none());
|
||||||
|
assert!(eligible(&role, &[connection]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binding_resolution_rejects_inputs_the_tool_cannot_supply() {
|
||||||
|
let (role, connection, binding) = role_and_connection();
|
||||||
|
assert!(resolve(&role, &[&[binding]], &[connection]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_inputs_resolve_when_supplied_and_optional_inputs_need_not_be_sent() {
|
||||||
|
let (mut role, mut connection, binding) = role_and_connection();
|
||||||
|
role.inputs.push(InputRole::Mask);
|
||||||
|
assert!(fits(&role, &connection.models[0]).is_some());
|
||||||
|
assert!(resolve(&role, &[std::slice::from_ref(&binding)], std::slice::from_ref(&connection)).is_ok());
|
||||||
|
role.inputs = vec![InputRole::Source];
|
||||||
|
connection.models[0].capabilities[0].requires = vec![InputRole::Source];
|
||||||
|
assert!(fits(&role, &connection.models[0]).is_some());
|
||||||
|
assert!(resolve(&role, &[&[binding]], &[connection]).is_ok());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user