mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-21 03:51:45 +08:00
feat(server): converge legacy compatibility (#15426)
#### PR Dependency Tree * **PR #15426** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added workspace BYOK profiles with provider/model catalogs, capability validation, connection probing, credential rotation, reordering, and secure local leases. * Added Copilot route options, selectable targets, managed tiers, explicit profile/model overrides, and improved streaming with tool callbacks and abort support. * Added Copilot availability controls to prevent access when the feature is disabled. * **Changes** * Simplified Copilot configuration and removed legacy provider-specific settings. * Removed obsolete model, token-cost, transcript strategy, and provider metadata fields from public responses. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,291 +1,98 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use jsonschema::Draft;
|
||||
use napi::{Error, Result, Status};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
super::contract_schema::{transcript_input_schema, transcript_result_schema},
|
||||
ActionRecipe, ActionRecipeStep, ActionStepKind,
|
||||
};
|
||||
|
||||
fn invalid_recipe(message: impl Into<String>) -> Error {
|
||||
Error::new(Status::InvalidArg, message.into())
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ActionRecipe<'a> {
|
||||
action_id: &'a str,
|
||||
action_version: &'a str,
|
||||
slot: &'a str,
|
||||
prompt_ref: &'a str,
|
||||
response_contract: Value,
|
||||
output_projection: &'a str,
|
||||
}
|
||||
|
||||
pub fn built_in_recipes() -> Vec<ActionRecipe> {
|
||||
vec![
|
||||
action_recipe("mindmap.generate", "v1"),
|
||||
action_recipe("slides.outline", "v1"),
|
||||
action_recipe("image.filter.sketch", "v1"),
|
||||
action_recipe("image.filter.clay", "v1"),
|
||||
action_recipe("image.filter.anime", "v1"),
|
||||
action_recipe("image.filter.pixel", "v1"),
|
||||
transcript_recipe("transcript.audio.gemini", "v1"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn find_recipe(id: &str, version: Option<&str>) -> Result<ActionRecipe> {
|
||||
let catalog = load_catalog()?;
|
||||
catalog
|
||||
.into_iter()
|
||||
.find(|recipe| recipe.id == id && version.is_none_or(|version| recipe.version == version))
|
||||
.ok_or_else(|| {
|
||||
invalid_recipe(format!(
|
||||
"Action recipe not found: {}{}",
|
||||
id,
|
||||
version.map(|version| format!("@{version}")).unwrap_or_default()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_catalog() -> Result<Vec<ActionRecipe>> {
|
||||
let recipes = built_in_recipes();
|
||||
validate_catalog(&recipes)?;
|
||||
Ok(recipes)
|
||||
}
|
||||
|
||||
pub fn validate_catalog(recipes: &[ActionRecipe]) -> Result<()> {
|
||||
let mut keys = HashSet::new();
|
||||
for recipe in recipes {
|
||||
validate_recipe(recipe)?;
|
||||
let key = format!("{}@{}", recipe.id, recipe.version);
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(invalid_recipe(format!("Duplicated action recipe: {key}")));
|
||||
}
|
||||
#[napi_derive::napi]
|
||||
pub fn copilot_action_recipe(action_id: String, action_version: Option<String>) -> Result<String> {
|
||||
let version = action_version.as_deref().unwrap_or("v1");
|
||||
if version != "v1" {
|
||||
return Err(Error::new(Status::InvalidArg, "Action recipe not found"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_recipe(recipe: &ActionRecipe) -> Result<()> {
|
||||
if recipe.id.trim().is_empty() {
|
||||
return Err(invalid_recipe("Action recipe id is required"));
|
||||
}
|
||||
if recipe.version.trim().is_empty() {
|
||||
return Err(invalid_recipe("Action recipe version is required"));
|
||||
}
|
||||
if recipe.steps.is_empty() {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} must declare at least one step",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
compile_schema("inputSchema", &recipe.input_schema)?;
|
||||
compile_schema("outputSchema", &recipe.output_schema)?;
|
||||
|
||||
let mut step_ids = HashSet::new();
|
||||
let mut has_final = false;
|
||||
for step in &recipe.steps {
|
||||
if step.id.trim().is_empty() {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} contains a step without id",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
if !step_ids.insert(step.id.clone()) {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} contains duplicated step id {}",
|
||||
recipe.id, recipe.version, step.id
|
||||
)));
|
||||
}
|
||||
if step.kind == ActionStepKind::Final {
|
||||
has_final = true;
|
||||
}
|
||||
}
|
||||
if !has_final {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} must end with a final step",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
if recipe
|
||||
.steps
|
||||
.last()
|
||||
.is_some_and(|step| step.kind != ActionStepKind::Final)
|
||||
{
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} must end with a final step",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compile_schema(label: &str, schema: &Value) -> Result<()> {
|
||||
jsonschema::options()
|
||||
.with_draft(Draft::Draft7)
|
||||
.build(schema)
|
||||
.map(|_| ())
|
||||
.map_err(|error| invalid_recipe(format!("Invalid action recipe {label}: {error}")))
|
||||
}
|
||||
|
||||
fn action_recipe(id: &str, version: &str) -> ActionRecipe {
|
||||
let steps = if id.starts_with("image.filter.") {
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "generate-image".to_string(),
|
||||
kind: ActionStepKind::PromptImage,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.generate-image" },
|
||||
"outputKey": "artifact"
|
||||
})),
|
||||
state_patch: Some(json!({ "imageGenerated": true })),
|
||||
let recipe = match action_id.as_str() {
|
||||
"mindmap.generate" => structured(&action_id, "mindmap.generate", text_result_schema()),
|
||||
"slides.outline" => structured(&action_id, "slides.outline", text_result_schema()),
|
||||
"transcript.audio" => structured(
|
||||
&action_id,
|
||||
"Transcript audio structured",
|
||||
super::super::contract_schema::transcript_result_schema(),
|
||||
),
|
||||
"image.filter.sketch" | "image.filter.clay" | "image.filter.anime" | "image.filter.pixel" => ActionRecipe {
|
||||
action_id: &action_id,
|
||||
action_version: "v1",
|
||||
slot: match action_id.as_str() {
|
||||
"image.filter.sketch" => "action.image.filter.sketch",
|
||||
"image.filter.clay" => "action.image.filter.clay",
|
||||
"image.filter.anime" => "action.image.filter.anime",
|
||||
_ => "action.image.filter.pixel",
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"copy": { "$state": "artifact" }
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
]
|
||||
} else if id == "slides.outline" {
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "generate-structured".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.generate" },
|
||||
"unwrapKey": "result",
|
||||
"outputKey": "generated"
|
||||
})),
|
||||
state_patch: Some(json!({ "generatedAt": "promptStructured" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"value": { "$state": "generated" },
|
||||
"schema": text_action_output_schema()
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "generated" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: Some(json!({ "projectedAt": "slidesOutlineMarkdown" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"copy": { "$state": "outlineMarkdown" }
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "generate-structured".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.generate" },
|
||||
"unwrapKey": "result",
|
||||
"outputKey": "generated"
|
||||
})),
|
||||
state_patch: Some(json!({ "generatedAt": "promptStructured" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"value": { "$state": "generated" },
|
||||
"schema": text_action_output_schema()
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"copy": { "$state": "generated" }
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
]
|
||||
prompt_ref: &action_id,
|
||||
response_contract: Value::Null,
|
||||
output_projection: "first_image",
|
||||
},
|
||||
_ => return Err(Error::new(Status::InvalidArg, "Action recipe not found")),
|
||||
};
|
||||
|
||||
recipe(id, version, action_output_schema(id), steps)
|
||||
serde_json::to_string(&recipe).map_err(|error| Error::new(Status::GenericFailure, error.to_string()))
|
||||
}
|
||||
|
||||
fn transcript_recipe(id: &str, version: &str) -> ActionRecipe {
|
||||
let mut recipe = recipe(
|
||||
id,
|
||||
version,
|
||||
transcript_result_schema(),
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "transcribe".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.transcribe" },
|
||||
"outputKey": "transcriptResult"
|
||||
})),
|
||||
state_patch: Some(json!({ "transcribedAt": "promptStructured" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"sourceAudio": { "$state": "sourceAudio" },
|
||||
"quality": { "$state": "quality" },
|
||||
"infos": { "$state": "infos" },
|
||||
"sliceManifest": { "$state": "sliceManifest" },
|
||||
"normalizedSegments": { "$state": "transcriptResult.normalizedSegments" },
|
||||
"normalizedTranscript": { "$state": "transcriptResult.normalizedTranscript" },
|
||||
"summaryJson": { "$state": "transcriptResult.summaryJson" },
|
||||
"providerMeta": { "$state": "transcriptResult.providerMeta" },
|
||||
"version": "transcript-result-v1",
|
||||
"strategy": id.strip_prefix("transcript.audio.").unwrap_or(id)
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
],
|
||||
);
|
||||
recipe.input_schema = transcript_input_schema();
|
||||
recipe
|
||||
}
|
||||
|
||||
fn action_output_schema(id: &str) -> Value {
|
||||
if id.starts_with("image.filter.") {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string" },
|
||||
"data_base64": { "type": "string" },
|
||||
"media_type": { "type": "string" }
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["url"] },
|
||||
{ "required": ["data_base64", "media_type"] }
|
||||
],
|
||||
"additionalProperties": true
|
||||
})
|
||||
} else {
|
||||
text_action_output_schema()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_action_output_schema() -> Value {
|
||||
fn text_result_schema() -> Value {
|
||||
json!({
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"type": "object",
|
||||
"properties": { "result": { "type": "string", "minLength": 1 } },
|
||||
"required": ["result"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn recipe(id: &str, version: &str, output_schema: Value, steps: Vec<ActionRecipeStep>) -> ActionRecipe {
|
||||
fn structured<'a>(action_id: &'a str, prompt_ref: &'a str, schema: Value) -> ActionRecipe<'a> {
|
||||
ActionRecipe {
|
||||
id: id.to_string(),
|
||||
version: version.to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema,
|
||||
steps,
|
||||
action_id,
|
||||
action_version: "v1",
|
||||
slot: match action_id {
|
||||
"mindmap.generate" => "action.mindmap.generate",
|
||||
"slides.outline" => "action.slides.outline",
|
||||
"transcript.audio" => "transcript.audio",
|
||||
_ => unreachable!("structured recipe action is validated by the catalog"),
|
||||
},
|
||||
prompt_ref,
|
||||
response_contract: json!({ "schema": schema, "strict": true }),
|
||||
output_projection: if action_id == "slides.outline" {
|
||||
"slides_outline_markdown"
|
||||
} else if action_id == "transcript.audio" {
|
||||
"transcript_result"
|
||||
} else {
|
||||
"identity"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::copilot_action_recipe;
|
||||
|
||||
#[test]
|
||||
fn recipes_only_expose_slot_prompt_contract_and_projection() {
|
||||
for (id, slot) in [
|
||||
("mindmap.generate", "action.mindmap.generate"),
|
||||
("slides.outline", "action.slides.outline"),
|
||||
("image.filter.sketch", "action.image.filter.sketch"),
|
||||
("transcript.audio", "transcript.audio"),
|
||||
] {
|
||||
let recipe = copilot_action_recipe(id.to_string(), None).unwrap();
|
||||
assert!(!recipe.contains("prepared"));
|
||||
assert!(recipe.contains(&format!("\"slot\":\"{slot}\"")));
|
||||
assert!(recipe.contains("\"promptRef\""));
|
||||
assert!(recipe.contains("\"outputProjection\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +1,7 @@
|
||||
use napi_derive::napi;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRecipe {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub input_schema: Value,
|
||||
pub output_schema: Value,
|
||||
pub steps: Vec<ActionRecipeStep>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRecipeStep {
|
||||
pub id: String,
|
||||
pub kind: ActionStepKind,
|
||||
#[serde(default)]
|
||||
pub input: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub state_patch: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ActionStepKind {
|
||||
PromptStructured,
|
||||
PromptImage,
|
||||
ValidateJson,
|
||||
Transform,
|
||||
Final,
|
||||
}
|
||||
|
||||
#[napi(string_enum = "snake_case")]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActionEventType {
|
||||
ActionStart,
|
||||
StepStart,
|
||||
Attachment,
|
||||
StepEnd,
|
||||
ActionDone,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionEvent {
|
||||
#[serde(rename = "type")]
|
||||
#[napi(js_name = "type")]
|
||||
pub event_type: ActionEventType,
|
||||
pub action_id: String,
|
||||
pub action_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub step_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<ActionRunStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachment: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace: Option<ActionTrace>,
|
||||
}
|
||||
|
||||
#[napi(string_enum = "snake_case")]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActionRunStatus {
|
||||
Created,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRuntimeInput {
|
||||
pub recipe_id: String,
|
||||
#[serde(default)]
|
||||
pub recipe_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRuntimeOutput {
|
||||
pub result: Value,
|
||||
pub status: ActionRunStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
pub state: Value,
|
||||
pub steps: Vec<ActionStepRuntimeState>,
|
||||
pub trace: ActionTrace,
|
||||
pub events: Vec<ActionEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionStepRuntimeState {
|
||||
pub id: String,
|
||||
pub input: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_patch: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ActionStepError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionStepError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionTrace {
|
||||
pub action_id: String,
|
||||
pub action_version: String,
|
||||
pub status: ActionRunStatus,
|
||||
#[serde(default)]
|
||||
pub lightweight: Vec<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -159,8 +14,6 @@ pub struct TranscriptInputContract {
|
||||
pub infos: Option<Vec<TranscriptAudioInfo>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slice_manifest: Option<Vec<TranscriptSliceManifestItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prepared_routes: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
@@ -232,8 +85,6 @@ pub struct TranscriptGeneratedResult {
|
||||
pub normalized_transcript: String,
|
||||
#[schemars(required)]
|
||||
pub summary_json: Option<MeetingSummary>,
|
||||
#[schemars(required)]
|
||||
pub provider_meta: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
@@ -253,8 +104,5 @@ pub struct TranscriptResult {
|
||||
pub normalized_transcript: String,
|
||||
#[schemars(required)]
|
||||
pub summary_json: Option<MeetingSummary>,
|
||||
#[schemars(required)]
|
||||
pub provider_meta: Option<Value>,
|
||||
pub version: String,
|
||||
pub strategy: String,
|
||||
}
|
||||
|
||||
@@ -1,99 +1,5 @@
|
||||
mod catalog;
|
||||
mod contract;
|
||||
mod runtime;
|
||||
mod slides_outline;
|
||||
|
||||
use std::sync::{Arc, atomic::AtomicBool, mpsc};
|
||||
|
||||
#[cfg(test)]
|
||||
use catalog::{load_catalog, validate_catalog, validate_recipe};
|
||||
use contract::{
|
||||
ActionEvent, ActionEventType, ActionRecipe, ActionRecipeStep, ActionRunStatus, ActionRuntimeInput,
|
||||
ActionRuntimeOutput, ActionStepError, ActionStepKind, ActionStepRuntimeState, ActionTrace,
|
||||
};
|
||||
pub use catalog::copilot_action_recipe;
|
||||
pub(crate) use contract::{TranscriptGeneratedResult, TranscriptInputContract, TranscriptResult};
|
||||
use napi::{
|
||||
Result,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use runtime::{ACTION_ABORTED_ERROR_CODE, run_action_recipe_for_test, run_action_recipe_for_test_with_control};
|
||||
use runtime::{ActionRuntimeControl, run_action_recipe_prepared_with_control};
|
||||
|
||||
use crate::llm::{LlmStreamHandle, STREAM_END_MARKER};
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn run_native_action_recipe_prepared_stream(
|
||||
input: ActionRuntimeInput,
|
||||
callback: ThreadsafeFunction<String, ()>,
|
||||
) -> Result<LlmStreamHandle> {
|
||||
let action_id = input.recipe_id.clone();
|
||||
let action_version = input.recipe_version.clone().unwrap_or_default();
|
||||
let aborted = Arc::new(AtomicBool::new(false));
|
||||
let aborted_in_worker = aborted.clone();
|
||||
let (event_sender, event_receiver) = mpsc::channel::<ActionEvent>();
|
||||
let error_sender = event_sender.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Err(error) = run_action_recipe_prepared_with_control(
|
||||
input,
|
||||
ActionRuntimeControl {
|
||||
abort_signal: Some(aborted_in_worker.clone()),
|
||||
event_sender: Some(event_sender),
|
||||
#[cfg(test)]
|
||||
abort_after_events: None,
|
||||
#[cfg(test)]
|
||||
mock_output: None,
|
||||
},
|
||||
) {
|
||||
let _ = error_sender.send(ActionEvent {
|
||||
event_type: ActionEventType::Error,
|
||||
action_id,
|
||||
action_version,
|
||||
step_id: None,
|
||||
status: Some(ActionRunStatus::Failed),
|
||||
attachment: None,
|
||||
result: None,
|
||||
error_code: Some("action_runtime_error".to_string()),
|
||||
error_message: Some(error.reason.clone()),
|
||||
trace: None,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for event in event_receiver {
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(event) => {
|
||||
let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = callback.call(
|
||||
Ok(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"actionId": event.action_id,
|
||||
"actionVersion": event.action_version,
|
||||
"errorCode": "action_event_encode_failed",
|
||||
"errorMessage": error.to_string()
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = callback.call(
|
||||
Ok(STREAM_END_MARKER.to_string()),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
});
|
||||
|
||||
Ok(LlmStreamHandle { aborted })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
use std::{
|
||||
cell::Cell,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::Sender,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use llm_runtime::{
|
||||
RecipeDefinition, RecipeRuntimeEvent, RecipeRuntimeOutput, RecipeRuntimeStatus, RecipeStepExecution,
|
||||
RecipeStepExecutor, StepExecutionError, execute_transform_step, execute_validate_json_step, resolve_state_ref,
|
||||
run_recipe_runtime, validate_json_schema,
|
||||
};
|
||||
use napi::{Error, Result, Status};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{
|
||||
ActionEvent, ActionEventType, ActionRecipe, ActionRunStatus, ActionRuntimeInput, ActionRuntimeOutput,
|
||||
ActionStepError, ActionStepKind, ActionStepRuntimeState, ActionTrace, catalog::find_recipe,
|
||||
slides_outline::project_slides_outline_markdown,
|
||||
};
|
||||
use crate::llm::{
|
||||
LlmPreparedImageDispatchRoutePayload, dispatch_prepared_image_route_payloads, dispatch_prepared_structured_routes,
|
||||
};
|
||||
|
||||
pub const ACTION_ABORTED_ERROR_CODE: &str = "action_aborted";
|
||||
pub const ACTION_INVALID_STEP_ERROR_CODE: &str = "action_invalid_step";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ActionRuntimeControl {
|
||||
pub abort_signal: Option<Arc<AtomicBool>>,
|
||||
pub event_sender: Option<Sender<ActionEvent>>,
|
||||
#[cfg(test)]
|
||||
pub abort_after_events: Option<usize>,
|
||||
#[cfg(test)]
|
||||
pub mock_output: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ActionRuntimeState {
|
||||
pub status: ActionRunStatus,
|
||||
pub result: Value,
|
||||
pub action_state: Value,
|
||||
pub steps: Vec<ActionStepRuntimeState>,
|
||||
pub events: Vec<ActionEvent>,
|
||||
pub trace: ActionTrace,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
fn invalid_input(message: impl Into<String>) -> Error {
|
||||
Error::new(Status::InvalidArg, message.into())
|
||||
}
|
||||
|
||||
pub fn run_action_recipe_prepared_with_control(
|
||||
input: ActionRuntimeInput,
|
||||
control: ActionRuntimeControl,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
let recipe = find_recipe(&input.recipe_id, input.recipe_version.as_deref())?;
|
||||
validate_value("input", &recipe.input_schema, &input.input)?;
|
||||
|
||||
run_recipe(recipe, input, control)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_action_recipe_for_test(
|
||||
recipe: ActionRecipe,
|
||||
input: ActionRuntimeInput,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
validate_value("input", &recipe.input_schema, &input.input)?;
|
||||
run_recipe(recipe, input, ActionRuntimeControl::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_action_recipe_for_test_with_control(
|
||||
recipe: ActionRecipe,
|
||||
input: ActionRuntimeInput,
|
||||
control: ActionRuntimeControl,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
validate_value("input", &recipe.input_schema, &input.input)?;
|
||||
run_recipe(recipe, input, control)
|
||||
}
|
||||
|
||||
fn run_recipe(
|
||||
recipe: ActionRecipe,
|
||||
input: ActionRuntimeInput,
|
||||
control: ActionRuntimeControl,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
let mut runtime = Runtime::new(recipe, input, control);
|
||||
runtime.run()
|
||||
}
|
||||
|
||||
struct Runtime {
|
||||
recipe: ActionRecipe,
|
||||
state: ActionRuntimeState,
|
||||
started_at: Instant,
|
||||
control: ActionRuntimeControl,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
fn new(recipe: ActionRecipe, input: ActionRuntimeInput, control: ActionRuntimeControl) -> Self {
|
||||
let trace = ActionTrace {
|
||||
action_id: recipe.id.clone(),
|
||||
action_version: recipe.version.clone(),
|
||||
status: ActionRunStatus::Created,
|
||||
lightweight: Vec::new(),
|
||||
error_code: None,
|
||||
};
|
||||
|
||||
Self {
|
||||
recipe,
|
||||
state: ActionRuntimeState {
|
||||
status: ActionRunStatus::Created,
|
||||
result: input.input.clone(),
|
||||
action_state: input.input,
|
||||
steps: Vec::new(),
|
||||
events: Vec::new(),
|
||||
trace,
|
||||
error_code: None,
|
||||
},
|
||||
started_at: Instant::now(),
|
||||
control,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self) -> Result<ActionRuntimeOutput> {
|
||||
let recipe = self.recipe_definition();
|
||||
let action_id = self.recipe.id.clone();
|
||||
let action_version = self.recipe.version.clone();
|
||||
let output_schema = self.recipe.output_schema.clone();
|
||||
let step_patches = self
|
||||
.recipe
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| (step.id.clone(), step.state_patch.clone()))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let attachments = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut executor = AffineActionStepExecutor::new(&self.control, attachments.clone());
|
||||
let mut events = Vec::new();
|
||||
let mut lightweight = Vec::new();
|
||||
let event_sender = self.control.event_sender.clone();
|
||||
let abort_signal = self.control.abort_signal.clone();
|
||||
let event_count = Cell::new(0usize);
|
||||
#[cfg(test)]
|
||||
let abort_after_events = self.control.abort_after_events;
|
||||
|
||||
let mut record = |event: ActionEvent| {
|
||||
lightweight.push(json!({
|
||||
"type": event.event_type,
|
||||
"stepId": event.step_id,
|
||||
"status": event.status
|
||||
}));
|
||||
if let Some(sender) = &event_sender {
|
||||
let _ = sender.send(event.clone());
|
||||
}
|
||||
events.push(event);
|
||||
event_count.set(events.len());
|
||||
};
|
||||
|
||||
let runtime_output = run_recipe_runtime(
|
||||
recipe,
|
||||
self.state.action_state.clone(),
|
||||
&mut executor,
|
||||
|event| {
|
||||
for action_event in map_recipe_event(&action_id, &action_version, event, &attachments) {
|
||||
record(action_event);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
abort_signal
|
||||
.as_ref()
|
||||
.is_some_and(|signal| signal.load(Ordering::SeqCst))
|
||||
|| {
|
||||
#[cfg(test)]
|
||||
{
|
||||
abort_after_events.is_some_and(|max_events| event_count.get() >= max_events)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if matches!(runtime_output.status, RecipeRuntimeStatus::Succeeded) {
|
||||
validate_value("output", &output_schema, &runtime_output.result)?;
|
||||
}
|
||||
|
||||
self.state = self.action_state_from_runtime_output(runtime_output, events, lightweight, step_patches);
|
||||
self.finalize_trace();
|
||||
if let Some(event) = self
|
||||
.state
|
||||
.events
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|event| matches!(event.event_type, ActionEventType::ActionDone))
|
||||
{
|
||||
event.trace = Some(self.state.trace.clone());
|
||||
}
|
||||
Ok(self.output())
|
||||
}
|
||||
|
||||
fn recipe_definition(&self) -> RecipeDefinition {
|
||||
RecipeDefinition {
|
||||
id: self.recipe.id.clone(),
|
||||
version: self.recipe.version.clone(),
|
||||
steps: self
|
||||
.recipe
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| RecipeStepExecution {
|
||||
id: step.id.clone(),
|
||||
kind: action_step_kind_name(step.kind).to_string(),
|
||||
input: step.input.clone(),
|
||||
state_patch: step.state_patch.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn action_state_from_runtime_output(
|
||||
&self,
|
||||
output: RecipeRuntimeOutput,
|
||||
events: Vec<ActionEvent>,
|
||||
lightweight: Vec<Value>,
|
||||
step_patches: std::collections::HashMap<String, Option<Value>>,
|
||||
) -> ActionRuntimeState {
|
||||
let status = recipe_status_to_action_status(&output.status);
|
||||
let error_code = output
|
||||
.trace
|
||||
.error_code
|
||||
.as_deref()
|
||||
.map(map_recipe_error_code)
|
||||
.map(ToString::to_string);
|
||||
ActionRuntimeState {
|
||||
status,
|
||||
result: output.result,
|
||||
action_state: output.state,
|
||||
steps: output
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| ActionStepRuntimeState {
|
||||
id: step.id.clone(),
|
||||
input: step.input.unwrap_or(Value::Null),
|
||||
output: step.output,
|
||||
state_patch: step_patches.get(&step.id).cloned().flatten(),
|
||||
error: step.error.map(ActionStepError::from),
|
||||
})
|
||||
.collect(),
|
||||
events,
|
||||
trace: ActionTrace {
|
||||
action_id: self.recipe.id.clone(),
|
||||
action_version: self.recipe.version.clone(),
|
||||
status,
|
||||
lightweight,
|
||||
error_code: error_code.clone(),
|
||||
},
|
||||
error_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn output(&mut self) -> ActionRuntimeOutput {
|
||||
self.finalize_trace();
|
||||
|
||||
ActionRuntimeOutput {
|
||||
result: self.state.result.clone(),
|
||||
status: self.state.status,
|
||||
error_code: self.state.error_code.clone(),
|
||||
state: self.state.action_state.clone(),
|
||||
steps: self.state.steps.clone(),
|
||||
trace: self.state.trace.clone(),
|
||||
events: self.state.events.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_trace(&mut self) {
|
||||
self.state.trace.status = self.state.status;
|
||||
if self
|
||||
.state
|
||||
.trace
|
||||
.lightweight
|
||||
.last()
|
||||
.and_then(|event| event.get("type"))
|
||||
.is_some_and(|event_type| event_type == "action_trace")
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.state.trace.lightweight.push(json!({
|
||||
"type": "action_trace",
|
||||
"actionId": self.recipe.id.clone(),
|
||||
"actionVersion": self.recipe.version.clone(),
|
||||
"status": self.state.status,
|
||||
"durationMs": self.started_at.elapsed().as_millis()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn recipe_status_to_action_status(status: &RecipeRuntimeStatus) -> ActionRunStatus {
|
||||
match status {
|
||||
RecipeRuntimeStatus::Created => ActionRunStatus::Created,
|
||||
RecipeRuntimeStatus::Running => ActionRunStatus::Running,
|
||||
RecipeRuntimeStatus::Succeeded => ActionRunStatus::Succeeded,
|
||||
RecipeRuntimeStatus::Failed => ActionRunStatus::Failed,
|
||||
RecipeRuntimeStatus::Aborted => ActionRunStatus::Aborted,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_recipe_error_code(code: &str) -> &str {
|
||||
match code {
|
||||
"aborted" => ACTION_ABORTED_ERROR_CODE,
|
||||
"invalid_step" | "invalid_schema" | "invalid_value" => ACTION_INVALID_STEP_ERROR_CODE,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_recipe_event(
|
||||
action_id: &str,
|
||||
action_version: &str,
|
||||
event: &RecipeRuntimeEvent,
|
||||
attachments: &Arc<Mutex<Vec<Value>>>,
|
||||
) -> Vec<ActionEvent> {
|
||||
let status = recipe_status_to_action_status(&event.status);
|
||||
let mut events = Vec::new();
|
||||
if event.event_type == "step_end" {
|
||||
let mut pending = attachments.lock().expect("attachment queue lock");
|
||||
events.extend(pending.drain(..).map(|attachment| ActionEvent {
|
||||
event_type: ActionEventType::Attachment,
|
||||
action_id: action_id.to_string(),
|
||||
action_version: action_version.to_string(),
|
||||
step_id: None,
|
||||
status: Some(ActionRunStatus::Running),
|
||||
attachment: Some(attachment),
|
||||
result: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
trace: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let event_type = match event.event_type.as_str() {
|
||||
"recipe_start" => ActionEventType::ActionStart,
|
||||
"step_start" => ActionEventType::StepStart,
|
||||
"step_end" => ActionEventType::StepEnd,
|
||||
"recipe_done" => ActionEventType::ActionDone,
|
||||
"error" => ActionEventType::Error,
|
||||
_ => return events,
|
||||
};
|
||||
let error = event.error.as_ref();
|
||||
events.push(ActionEvent {
|
||||
event_type,
|
||||
action_id: action_id.to_string(),
|
||||
action_version: action_version.to_string(),
|
||||
step_id: event.step_id.clone(),
|
||||
status: Some(status),
|
||||
attachment: None,
|
||||
result: event.result.clone(),
|
||||
error_code: error.map(|error| map_recipe_error_code(&error.code).to_string()),
|
||||
error_message: error.map(|error| error.message.clone()),
|
||||
trace: None,
|
||||
});
|
||||
events
|
||||
}
|
||||
|
||||
impl From<StepExecutionError> for ActionStepError {
|
||||
fn from(error: StepExecutionError) -> Self {
|
||||
let code = if error.code == "invalid_step" || error.code == "invalid_schema" || error.code == "invalid_value" {
|
||||
ACTION_INVALID_STEP_ERROR_CODE.to_string()
|
||||
} else {
|
||||
error.code
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_step_kind_name(kind: ActionStepKind) -> &'static str {
|
||||
match kind {
|
||||
ActionStepKind::PromptStructured => "promptStructured",
|
||||
ActionStepKind::PromptImage => "promptImage",
|
||||
ActionStepKind::ValidateJson => "validateJson",
|
||||
ActionStepKind::Transform => "transform",
|
||||
ActionStepKind::Final => "final",
|
||||
}
|
||||
}
|
||||
|
||||
struct AffineActionStepExecutor<'a> {
|
||||
#[cfg(test)]
|
||||
control: &'a ActionRuntimeControl,
|
||||
#[cfg(not(test))]
|
||||
_marker: std::marker::PhantomData<&'a ()>,
|
||||
attachments: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
impl<'a> AffineActionStepExecutor<'a> {
|
||||
fn new(_control: &'a ActionRuntimeControl, attachments: Arc<Mutex<Vec<Value>>>) -> Self {
|
||||
Self {
|
||||
#[cfg(test)]
|
||||
control: _control,
|
||||
#[cfg(not(test))]
|
||||
_marker: std::marker::PhantomData,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_mock_output(&self, _step_id: &str) -> Option<&Value> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self
|
||||
.control
|
||||
.mock_output
|
||||
.as_ref()
|
||||
.and_then(|mock_output| mock_output.get(_step_id))
|
||||
.filter(|value| !value.is_null())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_structured_step(
|
||||
&self,
|
||||
step: &RecipeStepExecution,
|
||||
input: Option<Value>,
|
||||
) -> std::result::Result<Value, StepExecutionError> {
|
||||
let value = if let Some(routes) = input
|
||||
.as_ref()
|
||||
.and_then(|input| input.get("preparedRoutes"))
|
||||
.filter(|routes| !routes.is_null())
|
||||
{
|
||||
let (_provider_id, response) =
|
||||
dispatch_prepared_structured_routes(&serde_json::to_string(routes).map_err(|error| {
|
||||
StepExecutionError::new(
|
||||
"invalid_step",
|
||||
format!("Invalid promptStructured prepared routes: {error}"),
|
||||
)
|
||||
})?)
|
||||
.map_err(|error| StepExecutionError::new("invalid_step", error.reason.clone()))?;
|
||||
response.output_json.unwrap_or(Value::Null)
|
||||
} else if let Some(mock_output) = self.test_mock_output(&step.id) {
|
||||
mock_output.clone()
|
||||
} else {
|
||||
return Err(StepExecutionError::new(
|
||||
"invalid_step",
|
||||
"promptStructured requires preparedRoutes",
|
||||
));
|
||||
};
|
||||
Ok(
|
||||
input
|
||||
.as_ref()
|
||||
.and_then(|input| input.get("unwrapKey"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|key| value.get(key).cloned())
|
||||
.unwrap_or(value),
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt_image_step(
|
||||
&mut self,
|
||||
step: &RecipeStepExecution,
|
||||
input: Option<Value>,
|
||||
) -> std::result::Result<Value, StepExecutionError> {
|
||||
let attachment = if let Some(routes) = input
|
||||
.as_ref()
|
||||
.and_then(|input| input.get("preparedRoutes"))
|
||||
.filter(|routes| !routes.is_null())
|
||||
{
|
||||
let payload =
|
||||
serde_json::from_value::<Vec<LlmPreparedImageDispatchRoutePayload>>(routes.clone()).map_err(|error| {
|
||||
StepExecutionError::new("invalid_step", format!("Invalid promptImage prepared routes: {error}"))
|
||||
})?;
|
||||
let (_provider_id, response) = dispatch_prepared_image_route_payloads(payload)
|
||||
.map_err(|error| StepExecutionError::new("invalid_step", error.reason.clone()))?;
|
||||
image_response_attachment(response.provider_metadata, response.images)
|
||||
.ok_or_else(|| StepExecutionError::new("invalid_step", "promptImage native dispatch produced no image"))?
|
||||
} else if let Some(mock_output) = self.test_mock_output(&step.id) {
|
||||
mock_output.clone()
|
||||
} else {
|
||||
return Err(StepExecutionError::new(
|
||||
"invalid_step",
|
||||
"promptImage requires preparedRoutes",
|
||||
));
|
||||
};
|
||||
self
|
||||
.attachments
|
||||
.lock()
|
||||
.expect("attachment queue lock")
|
||||
.push(attachment.clone());
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
fn transform_step(&self, input: Option<Value>, state: &Value) -> std::result::Result<Value, StepExecutionError> {
|
||||
if let Some(value) = execute_transform_step(input.clone(), state)? {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let Some(input) = input else {
|
||||
return Ok(state.clone());
|
||||
};
|
||||
if let Some(slides_outline) = input.get("slidesOutlineMarkdown") {
|
||||
let value = resolve_state_ref(slides_outline, state);
|
||||
return project_slides_outline_markdown(&value)
|
||||
.map(Value::String)
|
||||
.map_err(|message| StepExecutionError::new("invalid_step", message));
|
||||
}
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl RecipeStepExecutor for AffineActionStepExecutor<'_> {
|
||||
fn execute_step(
|
||||
&mut self,
|
||||
step: &RecipeStepExecution,
|
||||
input: Option<Value>,
|
||||
state: &Value,
|
||||
) -> std::result::Result<Value, StepExecutionError> {
|
||||
match step.kind.as_str() {
|
||||
"promptStructured" => self.prompt_structured_step(step, input),
|
||||
"promptImage" => self.prompt_image_step(step, input),
|
||||
"validateJson" => execute_validate_json_step(input.or_else(|| Some(state.clone()))),
|
||||
"transform" | "final" => self.transform_step(input, state),
|
||||
other => Err(StepExecutionError::new(
|
||||
"invalid_step",
|
||||
format!("Unsupported action step kind: {other}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_response_attachment(provider_metadata: Value, images: Vec<llm_adapter::core::ImageArtifact>) -> Option<Value> {
|
||||
let image = images.into_iter().next()?;
|
||||
let mut attachment = Map::new();
|
||||
if let Some(url) = image.url {
|
||||
attachment.insert("url".to_string(), Value::String(url));
|
||||
}
|
||||
if let Some(data_base64) = image.data_base64 {
|
||||
attachment.insert("data_base64".to_string(), Value::String(data_base64));
|
||||
}
|
||||
attachment.insert("media_type".to_string(), Value::String(image.media_type));
|
||||
if let Some(width) = image.width {
|
||||
attachment.insert("width".to_string(), json!(width));
|
||||
}
|
||||
if let Some(height) = image.height {
|
||||
attachment.insert("height".to_string(), json!(height));
|
||||
}
|
||||
if !image.provider_metadata.is_null() {
|
||||
attachment.insert("providerMetadata".to_string(), image.provider_metadata);
|
||||
} else if !provider_metadata.is_null() {
|
||||
attachment.insert("providerMetadata".to_string(), provider_metadata);
|
||||
}
|
||||
if !attachment.contains_key("url") && !attachment.contains_key("data_base64") {
|
||||
return None;
|
||||
}
|
||||
Some(Value::Object(attachment))
|
||||
}
|
||||
|
||||
fn validate_value(label: &str, schema: &Value, value: &Value) -> Result<()> {
|
||||
validate_json_schema(label, schema, value).map_err(|error| invalid_input(error.message))
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn project_slides_outline_markdown(value: &Value) -> Result<String, String> {
|
||||
let text = match value {
|
||||
Value::String(text) => text.as_str(),
|
||||
Value::Object(object) => {
|
||||
if let Some(Value::String(text)) = object.get("result") {
|
||||
text
|
||||
} else if let Some(Value::String(text)) = object.get("content") {
|
||||
text
|
||||
} else if let Some(Value::String(text)) = object.get("text") {
|
||||
text
|
||||
} else {
|
||||
return Err("slidesOutlineMarkdown requires a string result".to_string());
|
||||
}
|
||||
}
|
||||
_ => return Err("slidesOutlineMarkdown requires a string result".to_string()),
|
||||
};
|
||||
|
||||
if is_markdown_list(text) {
|
||||
return Ok(text.to_string());
|
||||
}
|
||||
|
||||
let mut projected = Vec::new();
|
||||
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let item = serde_json::from_str::<Value>(line)
|
||||
.map_err(|_| "slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string())?;
|
||||
if !item.is_object() {
|
||||
return Err("slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string());
|
||||
}
|
||||
projected.push(render_slide_item(&item)?);
|
||||
}
|
||||
|
||||
if projected.is_empty() {
|
||||
Err("slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string())
|
||||
} else {
|
||||
Ok(projected.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_markdown_list(text: &str) -> bool {
|
||||
let mut saw_line = false;
|
||||
for line in text.lines().map(str::trim_start).filter(|line| !line.trim().is_empty()) {
|
||||
saw_line = true;
|
||||
if !(line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
saw_line
|
||||
}
|
||||
|
||||
fn render_legacy_slide_item(item: &Value) -> Option<String> {
|
||||
let kind = item.get("type").and_then(Value::as_str)?;
|
||||
let content = item.get("content").and_then(value_to_optional_string)?;
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match kind {
|
||||
"name" => Some(format!("- {content}")),
|
||||
"title" => Some(format!(" - {content}")),
|
||||
"content" => {
|
||||
if content.contains('\n') {
|
||||
Some(
|
||||
content
|
||||
.lines()
|
||||
.map(|line| format!(" - {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
} else {
|
||||
Some(format!(" - {content}"))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_slide_item(item: &Value) -> Result<String, String> {
|
||||
if let Some(markdown) = render_legacy_slide_item(item) {
|
||||
return Ok(markdown);
|
||||
}
|
||||
if item.get("content").and_then(Value::as_object).is_some() {
|
||||
return render_structured_slide_item(item);
|
||||
}
|
||||
if item.get("content").and_then(Value::as_str).is_some() {
|
||||
return render_labeled_string_slide_item(item);
|
||||
}
|
||||
Err("slidesOutlineMarkdown item is not a recognized slide outline object".to_string())
|
||||
}
|
||||
|
||||
fn render_labeled_string_slide_item(item: &Value) -> Result<String, String> {
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires string content".to_string())?;
|
||||
if content.trim().is_empty() {
|
||||
return Err("slidesOutlineMarkdown labeled item requires string content".to_string());
|
||||
}
|
||||
let labels = parse_labeled_segments(content);
|
||||
let title = labels
|
||||
.get("title")
|
||||
.cloned()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Title".to_string())?;
|
||||
let keywords = labels
|
||||
.get("image keywords")
|
||||
.cloned()
|
||||
.or_else(|| labels.get("keywords").cloned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Image Keywords".to_string())?;
|
||||
let description = labels
|
||||
.get("description")
|
||||
.cloned()
|
||||
.or_else(|| labels.get("content").cloned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Description".to_string())?;
|
||||
|
||||
Ok(
|
||||
[
|
||||
format!("- {title}"),
|
||||
format!(" - {title}"),
|
||||
format!(" - {keywords}"),
|
||||
format!(" - {description}"),
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_structured_slide_item(item: &Value) -> Result<String, String> {
|
||||
let item_object = item
|
||||
.as_object()
|
||||
.ok_or_else(|| "slidesOutlineMarkdown structured item requires object content".to_string())?;
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| "slidesOutlineMarkdown structured item requires object content".to_string())?;
|
||||
let title = string_prop(content, &["title", "name", "page_name", "pageName"])
|
||||
.or_else(|| string_prop(item_object, &["title", "name", "page_name", "pageName", "page"]))
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown requires slide title".to_string())?;
|
||||
let sections = content.get("sections").and_then(Value::as_array);
|
||||
let rendered_sections = if let Some(sections) = sections.filter(|sections| !sections.is_empty()) {
|
||||
sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, section)| render_slide_section(section, index + 1))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
render_slide_object(content)?
|
||||
};
|
||||
|
||||
Ok(
|
||||
std::iter::once(format!("- {title}"))
|
||||
.chain(rendered_sections)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_labeled_segments(text: &str) -> std::collections::HashMap<String, String> {
|
||||
text
|
||||
.split(';')
|
||||
.filter_map(|segment| {
|
||||
let (key, value) = segment.split_once(':')?;
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = value.trim().to_string();
|
||||
if key.is_empty() || value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key, value))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_slide_section(section: &Value, index: usize) -> Result<Vec<String>, String> {
|
||||
let Some(object) = section.as_object() else {
|
||||
return Err(format!("slidesOutlineMarkdown section {index} requires object content"));
|
||||
};
|
||||
|
||||
render_slide_object(object)
|
||||
}
|
||||
|
||||
fn render_slide_object(object: &Map<String, Value>) -> Result<Vec<String>, String> {
|
||||
let title = required_string_prop(
|
||||
object,
|
||||
&["title", "name", "section", "page_name", "pageName"],
|
||||
"slide section title",
|
||||
)?;
|
||||
let keywords = string_prop(
|
||||
object,
|
||||
&["image_keywords", "imageKeywords", "keywords", "image_keywords_optional"],
|
||||
)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| title.clone());
|
||||
let content = required_string_prop(
|
||||
object,
|
||||
&["content", "description", "summary", "text"],
|
||||
"slide section content",
|
||||
)?;
|
||||
|
||||
Ok(vec![
|
||||
format!(" - {title}"),
|
||||
format!(" - {keywords}"),
|
||||
format!(" - {content}"),
|
||||
])
|
||||
}
|
||||
|
||||
fn string_prop(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys
|
||||
.iter()
|
||||
.find_map(|key| object.get(*key).and_then(value_to_optional_string))
|
||||
}
|
||||
|
||||
fn required_string_prop(object: &Map<String, Value>, keys: &[&str], name: &str) -> Result<String, String> {
|
||||
string_prop(object, keys)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("slidesOutlineMarkdown requires {name}"))
|
||||
}
|
||||
|
||||
fn value_to_optional_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Array(items) => {
|
||||
let joined = items
|
||||
.iter()
|
||||
.filter_map(value_to_optional_string)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Some(joined)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
use napi::Status;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
ACTION_ABORTED_ERROR_CODE, ActionEventType, ActionRecipe, ActionRecipeStep, ActionRunStatus, ActionRuntimeControl,
|
||||
ActionRuntimeInput, ActionStepKind, load_catalog, run_action_recipe_for_test,
|
||||
run_action_recipe_for_test_with_control, run_action_recipe_prepared_with_control, validate_catalog, validate_recipe,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn validates_built_in_recipe_catalog() {
|
||||
let catalog = load_catalog().unwrap();
|
||||
let mindmap = catalog.iter().find(|recipe| recipe.id == "mindmap.generate").unwrap();
|
||||
assert!(
|
||||
mindmap
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.kind == ActionStepKind::PromptStructured)
|
||||
);
|
||||
assert!(
|
||||
mindmap
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.kind == ActionStepKind::ValidateJson)
|
||||
);
|
||||
let slides = catalog.iter().find(|recipe| recipe.id == "slides.outline").unwrap();
|
||||
assert!(
|
||||
slides
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.id == "project-outline" && step.kind == ActionStepKind::Transform)
|
||||
);
|
||||
assert!(catalog.iter().any(|recipe| recipe.id == "transcript.audio.gemini"));
|
||||
assert!(!catalog.iter().any(|recipe| recipe.id == "transcript.audio.local-asr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_transcript_action_final_result_is_schema_checked() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "transcript.audio.gemini".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({
|
||||
"sourceAudio": { "blobId": "blob-1", "mimeType": "audio/opus" },
|
||||
"quality": null,
|
||||
"infos": [{ "url": "https://example.com/audio.opus", "mimeType": "audio/opus", "index": 0 }],
|
||||
"sliceManifest": [{
|
||||
"index": 0,
|
||||
"fileName": "audio.opus",
|
||||
"mimeType": "audio/opus",
|
||||
"startSec": 12,
|
||||
"durationSec": 30,
|
||||
"byteSize": 42
|
||||
}],
|
||||
}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"transcribe": {
|
||||
"normalizedTranscript": "00:00:01 A: Hello",
|
||||
"summaryJson": {
|
||||
"title": "Sync",
|
||||
"durationMinutes": 1,
|
||||
"attendees": ["A"],
|
||||
"keyPoints": ["Hello"],
|
||||
"actionItems": [],
|
||||
"decisions": [],
|
||||
"openQuestions": [],
|
||||
"blockers": []
|
||||
},
|
||||
"providerMeta": { "provider": "gemini", "model": "gemini-3.5-flash-lite" }
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result["version"], json!("transcript-result-v1"));
|
||||
assert_eq!(output.result["strategy"], json!("gemini"));
|
||||
assert_eq!(output.result["normalizedSegments"], json!(null));
|
||||
assert_eq!(output.result["sourceAudio"]["blobId"], json!("blob-1"));
|
||||
assert_eq!(
|
||||
output.result["infos"][0]["url"],
|
||||
json!("https://example.com/audio.opus")
|
||||
);
|
||||
assert_eq!(output.result["sliceManifest"][0]["startSec"], json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_transcript_action_rejects_malformed_summary() {
|
||||
let error = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "transcript.audio.gemini".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"transcribe": {
|
||||
"normalizedTranscript": "00:00:01 A: Hello",
|
||||
"summaryJson": { "title": "Sync" },
|
||||
"providerMeta": { "provider": "gemini", "model": "gemini-3.5-flash-lite" }
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.reason.contains("does not match JSON schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_action_final_result_comes_from_prompt_output_state() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": "- Root"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!("- Root"));
|
||||
assert_eq!(output.state["generated"], json!("- Root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_action_unwraps_structured_text_result() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": "- Root"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!("- Root"));
|
||||
assert_eq!(output.state["generated"], json!("- Root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_slides_outline_projects_final_result_to_markdown() {
|
||||
let outline = [
|
||||
serde_json::to_string(&json!({
|
||||
"page": "Cover",
|
||||
"type": "cover",
|
||||
"content": {
|
||||
"title": "Apple Inc.",
|
||||
"description": "Company overview",
|
||||
"image_keywords": ["Apple logo", "Apple Park"]
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::to_string(&json!({
|
||||
"page": 2,
|
||||
"type": "content",
|
||||
"content": {
|
||||
"title": "Products",
|
||||
"sections": [{
|
||||
"title": "iPhone",
|
||||
"keywords": ["smartphone", "iOS"],
|
||||
"content": "Flagship product line"
|
||||
}]
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::to_string(&json!({
|
||||
"page": 3,
|
||||
"type": "cover",
|
||||
"content": "Page Name: Closing; Title: Outlook; Description: Future strategy; Image Keywords: roadmap, devices"
|
||||
}))
|
||||
.unwrap(),
|
||||
]
|
||||
.join("\n");
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "slides.outline".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": outline
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(
|
||||
[
|
||||
"- Apple Inc.",
|
||||
" - Apple Inc.",
|
||||
" - Apple logo, Apple Park",
|
||||
" - Company overview",
|
||||
"- Products",
|
||||
" - iPhone",
|
||||
" - smartphone, iOS",
|
||||
" - Flagship product line",
|
||||
"- Outlook",
|
||||
" - Outlook",
|
||||
" - roadmap, devices",
|
||||
" - Future strategy"
|
||||
]
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
output
|
||||
.steps
|
||||
.iter()
|
||||
.find(|step| step.id == "project-outline")
|
||||
.and_then(|step| step.output.as_ref()),
|
||||
Some(&output.result)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_keeps_legacy_markdown_shape() {
|
||||
let outline = [
|
||||
serde_json::to_string(&json!({ "page": 1, "type": "name", "content": "Launch deck" })).unwrap(),
|
||||
serde_json::to_string(&json!({ "page": 1, "type": "title", "content": "Context" })).unwrap(),
|
||||
serde_json::to_string(&json!({ "page": 1, "type": "content", "content": "Problem\nOpportunity" })).unwrap(),
|
||||
]
|
||||
.join("\n");
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": outline
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(["- Launch deck", " - Context", " - Problem", " - Opportunity"].join("\n"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_rejects_unrecognized_text() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": "not valid ndjson"
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert_eq!(output.error_code, Some("action_invalid_step".to_string()));
|
||||
assert_eq!(
|
||||
output.events.last().and_then(|event| event.error_message.as_deref()),
|
||||
Some("slidesOutlineMarkdown requires markdown or NDJSON object lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_accepts_cover_without_image_keywords() {
|
||||
let outline = serde_json::to_string(&json!({
|
||||
"page": 1,
|
||||
"type": "cover",
|
||||
"content": {
|
||||
"title": "Launch deck",
|
||||
"description": "Overview"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": outline
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(
|
||||
[
|
||||
"- Launch deck",
|
||||
" - Launch deck",
|
||||
" - Launch deck",
|
||||
" - Overview"
|
||||
]
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_accepts_page_name_from_item() {
|
||||
let outline = serde_json::to_string(&json!({
|
||||
"page": 2,
|
||||
"type": "content",
|
||||
"page_name": "Workspace Benefits",
|
||||
"content": {
|
||||
"sections": [
|
||||
{
|
||||
"section": "Unified writing",
|
||||
"keywords": ["docs", "canvas"],
|
||||
"text": "AFFiNE combines documents and whiteboards."
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": outline
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(
|
||||
[
|
||||
"- Workspace Benefits",
|
||||
" - Unified writing",
|
||||
" - docs, canvas",
|
||||
" - AFFiNE combines documents and whiteboards."
|
||||
]
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_action_events_for_server_contract() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": "- Root"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
let first = serde_json::to_value(output.events.first().unwrap()).unwrap();
|
||||
let last = serde_json::to_value(output.events.last().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(first["type"], json!("action_start"));
|
||||
assert_eq!(last["type"], json!("action_done"));
|
||||
assert_eq!(last["status"], json!("succeeded"));
|
||||
assert_eq!(last["trace"]["status"], json!("succeeded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_action_fails_without_routes_or_mock_output() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
ActionRuntimeControl::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.error_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
.contains("promptStructured requires")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_image_action_uses_prompt_image_step_output() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "image.filter.sketch".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-image": {
|
||||
"url": "https://example.com/artifact-1.png"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!({ "url": "https://example.com/artifact-1.png" }));
|
||||
assert_eq!(
|
||||
output.state.pointer("/artifact/url"),
|
||||
Some(&json!("https://example.com/artifact-1.png"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_image_action_accepts_inline_artifact_output() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "image.filter.sketch".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-image": {
|
||||
"data_base64": "aW1n",
|
||||
"media_type": "image/webp"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!({
|
||||
"data_base64": "aW1n",
|
||||
"media_type": "image/webp"
|
||||
})
|
||||
);
|
||||
assert_eq!(output.state.pointer("/artifact/data_base64"), Some(&json!("aW1n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_recipe_without_final_step() {
|
||||
let recipe = ActionRecipe {
|
||||
id: "invalid.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps: vec![ActionRecipeStep {
|
||||
id: "start".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let error = validate_recipe(&recipe).unwrap_err();
|
||||
assert_eq!(error.status, Status::InvalidArg);
|
||||
assert!(error.reason.contains("must end with a final step"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicated_recipe_identity() {
|
||||
let recipe = ActionRecipe {
|
||||
id: "duplicated.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps: vec![ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let error = validate_catalog(&[recipe.clone(), recipe]).unwrap_err();
|
||||
assert_eq!(error.status, Status::InvalidArg);
|
||||
assert!(error.reason.contains("Duplicated action recipe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_recipe_where_final_step_is_not_last() {
|
||||
let recipe = ActionRecipe {
|
||||
id: "invalid.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps: vec![
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "after-final".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let error = validate_recipe(&recipe).unwrap_err();
|
||||
assert_eq!(error.status, Status::InvalidArg);
|
||||
assert!(error.reason.contains("must end with a final step"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_json_and_prompt_projection_steps() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "prompt-structured".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "prompt-image".to_string(),
|
||||
kind: ActionStepKind::PromptImage,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"schema": { "type": "object", "required": ["title"] },
|
||||
"value": { "title": "Hello" }
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "done": true } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let output = run_action_recipe_for_test_with_control(
|
||||
recipe,
|
||||
runtime_input(json!({})),
|
||||
mock_control(json!({
|
||||
"prompt-structured": { "title": "Hello" },
|
||||
"prompt-image": { "url": "https://example.com/artifact-1.png" }
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.event_type)
|
||||
.filter(|event_type| matches!(event_type, ActionEventType::Attachment))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![ActionEventType::Attachment]
|
||||
);
|
||||
assert_eq!(output.steps[2].output, Some(json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_prompt_steps_without_prepared_routes_or_explicit_boundary() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "prompt".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let output = run_action_recipe_for_test(recipe, runtime_input(json!({}))).unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert_eq!(output.error_code, Some("action_invalid_step".to_string()));
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.error_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
.contains("requires")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_prompt_image_without_prepared_routes() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "prompt-image".to_string(),
|
||||
kind: ActionStepKind::PromptImage,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let output = run_action_recipe_for_test(recipe, runtime_input(json!({}))).unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.error_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
.contains("preparedRoutes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_json_distinguishes_invalid_schema_from_invalid_value() {
|
||||
let invalid_value = run_action_recipe_for_test(
|
||||
test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"schema": { "type": "object", "required": ["title"] },
|
||||
"value": {}
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": {} })),
|
||||
state_patch: None,
|
||||
},
|
||||
]),
|
||||
runtime_input(json!({})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(invalid_value.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(invalid_value.steps[0].output, Some(json!(false)));
|
||||
|
||||
let invalid_schema = run_action_recipe_for_test(
|
||||
test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"schema": { "type": 1 },
|
||||
"value": {}
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
]),
|
||||
runtime_input(json!({})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(invalid_schema.status, ActionRunStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_ordered_action_events_and_final_result() {
|
||||
let output = run_action_recipe_for_test(
|
||||
test_recipe(vec![ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": {} })),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
}]),
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "test.recipe".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({ "content": "hello" }),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!({}));
|
||||
assert_eq!(output.error_code, None);
|
||||
assert_eq!(output.state, json!({ "content": "hello", "finalized": true }));
|
||||
assert_eq!(output.steps.len(), 1);
|
||||
assert_eq!(output.steps[0].id, "final");
|
||||
assert_eq!(output.steps[0].output, Some(json!({})));
|
||||
assert_eq!(output.steps[0].state_patch, Some(json!({ "finalized": true })));
|
||||
assert_eq!(output.steps[0].error, None);
|
||||
assert_eq!(
|
||||
output.events.iter().map(|event| event.event_type).collect::<Vec<_>>(),
|
||||
vec![
|
||||
ActionEventType::ActionStart,
|
||||
ActionEventType::StepStart,
|
||||
ActionEventType::StepEnd,
|
||||
ActionEventType::ActionDone,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn runtime_input(input: serde_json::Value) -> ActionRuntimeInput {
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "test.recipe".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input,
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_control(mock_output: serde_json::Value) -> ActionRuntimeControl {
|
||||
ActionRuntimeControl {
|
||||
abort_signal: None,
|
||||
event_sender: None,
|
||||
abort_after_events: None,
|
||||
mock_output: Some(mock_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_recipe(steps: Vec<ActionRecipeStep>) -> ActionRecipe {
|
||||
ActionRecipe {
|
||||
id: "test.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_lightweight_trace() {
|
||||
let output = run_action_recipe_for_test(
|
||||
test_recipe(vec![ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": {} })),
|
||||
state_patch: None,
|
||||
}]),
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "test.recipe".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.trace.status, ActionRunStatus::Succeeded);
|
||||
assert!(!output.trace.lightweight.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_control_stops_runtime() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "image.filter.sketch".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
ActionRuntimeControl {
|
||||
abort_signal: None,
|
||||
event_sender: None,
|
||||
abort_after_events: Some(1),
|
||||
mock_output: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Aborted);
|
||||
assert_eq!(output.error_code, Some(ACTION_ABORTED_ERROR_CODE.to_string()));
|
||||
assert_eq!(
|
||||
output.events.last().map(|event| event.event_type),
|
||||
Some(ActionEventType::Error)
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
{
|
||||
"name": "Transcript audio",
|
||||
"action": "Transcript audio",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"optionalModels": ["gemini-3.5-flash-lite", "gemini-3.6-flash"],
|
||||
"managedRoute": {
|
||||
"targets": ["gemini-3.5-flash-lite"],
|
||||
"premiumTargets": ["gemini-3.6-flash"]
|
||||
},
|
||||
"config": {
|
||||
"requireContent": false,
|
||||
"requireAttachment": true,
|
||||
@@ -14,8 +16,10 @@
|
||||
{
|
||||
"name": "Transcript audio structured",
|
||||
"action": "Transcript audio structured",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"optionalModels": ["gemini-3.5-flash-lite", "gemini-3.6-flash"],
|
||||
"managedRoute": {
|
||||
"targets": ["gemini-3.5-flash-lite"],
|
||||
"premiumTargets": ["gemini-3.6-flash"]
|
||||
},
|
||||
"config": {
|
||||
"requireContent": false,
|
||||
"requireAttachment": true,
|
||||
@@ -35,7 +39,7 @@
|
||||
{
|
||||
"name": "Generate a caption",
|
||||
"action": "Generate a caption",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"config": {
|
||||
"requireContent": false,
|
||||
"requireAttachment": true
|
||||
@@ -50,7 +54,7 @@
|
||||
{
|
||||
"name": "Conversation Summary",
|
||||
"action": "Conversation Summary",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"config": {
|
||||
"requireContent": false
|
||||
},
|
||||
@@ -68,7 +72,7 @@
|
||||
{
|
||||
"name": "Summary",
|
||||
"action": "Summary",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -83,7 +87,7 @@
|
||||
{
|
||||
"name": "Summary as title",
|
||||
"action": "Summary as title",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -98,7 +102,7 @@
|
||||
{
|
||||
"name": "Summary the webpage",
|
||||
"action": "Summary the webpage",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -109,7 +113,7 @@
|
||||
{
|
||||
"name": "Explain this",
|
||||
"action": "Explain this",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"builtins": ["language"],
|
||||
"messages": [
|
||||
{
|
||||
@@ -125,7 +129,7 @@
|
||||
{
|
||||
"name": "Explain this image",
|
||||
"action": "Explain this image",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"config": {
|
||||
"requireContent": false,
|
||||
"requireAttachment": true
|
||||
@@ -144,7 +148,7 @@
|
||||
{
|
||||
"name": "Explain this code",
|
||||
"action": "Explain this code",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -159,7 +163,7 @@
|
||||
{
|
||||
"name": "Translate to",
|
||||
"action": "Translate",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"params": {
|
||||
"language": {
|
||||
"default": "English",
|
||||
@@ -192,7 +196,7 @@
|
||||
{
|
||||
"name": "Summarize the meeting structured",
|
||||
"action": "Summarize the meeting structured",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -207,7 +211,7 @@
|
||||
{
|
||||
"name": "Summarize the meeting",
|
||||
"action": "Summarize the meeting",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -222,7 +226,7 @@
|
||||
{
|
||||
"name": "Find action for summary",
|
||||
"action": "Find action for summary",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -237,7 +241,7 @@
|
||||
{
|
||||
"name": "Write an article about this",
|
||||
"action": "Write an article about this",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -252,7 +256,7 @@
|
||||
{
|
||||
"name": "Write a twitter about this",
|
||||
"action": "Write a twitter about this",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -267,7 +271,7 @@
|
||||
{
|
||||
"name": "Write a poem about this",
|
||||
"action": "Write a poem about this",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -282,7 +286,7 @@
|
||||
{
|
||||
"name": "Write a blog post about this",
|
||||
"action": "Write a blog post about this",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -297,7 +301,7 @@
|
||||
{
|
||||
"name": "Write outline",
|
||||
"action": "Write outline",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -312,7 +316,7 @@
|
||||
{
|
||||
"name": "Change tone to",
|
||||
"action": "Change tone",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"params": {
|
||||
"tone": {
|
||||
"default": "professional",
|
||||
@@ -333,7 +337,7 @@
|
||||
{
|
||||
"name": "Brainstorm ideas about this",
|
||||
"action": "Brainstorm ideas about this",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -348,7 +352,7 @@
|
||||
{
|
||||
"name": "Brainstorm mindmap",
|
||||
"action": "Brainstorm mindmap",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -363,7 +367,7 @@
|
||||
{
|
||||
"name": "Expand mind map",
|
||||
"action": "Expand mind map",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -382,7 +386,7 @@
|
||||
{
|
||||
"name": "Improve writing for it",
|
||||
"action": "Improve writing for it",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -397,7 +401,7 @@
|
||||
{
|
||||
"name": "Improve grammar for it",
|
||||
"action": "Improve grammar for it",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -412,7 +416,7 @@
|
||||
{
|
||||
"name": "Fix spelling for it",
|
||||
"action": "Fix spelling for it",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -427,7 +431,7 @@
|
||||
{
|
||||
"name": "Find action items from it",
|
||||
"action": "Find action items from it",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -442,7 +446,7 @@
|
||||
{
|
||||
"name": "Check code error",
|
||||
"action": "Check code error",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -457,7 +461,7 @@
|
||||
{
|
||||
"name": "Create a presentation",
|
||||
"action": "Create a presentation",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -472,7 +476,7 @@
|
||||
{
|
||||
"name": "Create headings",
|
||||
"action": "Create headings",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -487,7 +491,7 @@
|
||||
{
|
||||
"name": "Make it real",
|
||||
"action": "Make it real",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"managedRoute": { "targets": ["claude-sonnet-4-6"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -502,7 +506,7 @@
|
||||
{
|
||||
"name": "Make it real with text",
|
||||
"action": "Make it real with text",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"managedRoute": { "targets": ["claude-sonnet-4-6"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -517,7 +521,7 @@
|
||||
{
|
||||
"name": "Make it longer",
|
||||
"action": "Make it longer",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -532,7 +536,7 @@
|
||||
{
|
||||
"name": "Make it shorter",
|
||||
"action": "Make it shorter",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -547,7 +551,7 @@
|
||||
{
|
||||
"name": "Continue writing",
|
||||
"action": "Continue writing",
|
||||
"model": "gemini-3.5-flash-lite",
|
||||
"managedRoute": { "targets": ["gemini-3.5-flash-lite"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -562,7 +566,7 @@
|
||||
{
|
||||
"name": "Section Edit",
|
||||
"action": "Section Edit",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"managedRoute": { "targets": ["claude-sonnet-4-6"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -577,7 +581,7 @@
|
||||
{
|
||||
"name": "Generate image",
|
||||
"action": "image",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -588,7 +592,7 @@
|
||||
{
|
||||
"name": "Convert to Clay style",
|
||||
"action": "Convert to Clay style",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -599,7 +603,7 @@
|
||||
{
|
||||
"name": "Convert to Sketch style",
|
||||
"action": "Convert to Sketch style",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -610,7 +614,7 @@
|
||||
{
|
||||
"name": "Convert to Anime style",
|
||||
"action": "Convert to Anime style",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -621,7 +625,7 @@
|
||||
{
|
||||
"name": "Convert to Pixel style",
|
||||
"action": "Convert to Pixel style",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -632,7 +636,7 @@
|
||||
{
|
||||
"name": "Convert to sticker",
|
||||
"action": "Convert to sticker",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -643,7 +647,7 @@
|
||||
{
|
||||
"name": "Upscale image",
|
||||
"action": "Upscale image",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -654,7 +658,7 @@
|
||||
{
|
||||
"name": "Remove background",
|
||||
"action": "Remove background",
|
||||
"model": "gpt-image-1",
|
||||
"managedRoute": { "targets": ["gpt-image-1"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -665,7 +669,7 @@
|
||||
{
|
||||
"name": "debug:action:fal-teed",
|
||||
"action": "fal-teed",
|
||||
"model": "workflowutils/teed",
|
||||
"managedRoute": { "targets": ["workflowutils/teed"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -675,7 +679,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Code Artifact",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"managedRoute": { "targets": ["claude-sonnet-4-6"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -689,13 +693,36 @@
|
||||
},
|
||||
{
|
||||
"name": "Chat With AFFiNE AI",
|
||||
"model": "gpt-5.6-luna",
|
||||
"optionalModels": [
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-terra",
|
||||
"gemini-3.6-flash",
|
||||
"claude-sonnet-4-6"
|
||||
],
|
||||
"managedRoute": {
|
||||
"targets": ["gpt-5.6-luna"],
|
||||
"premiumTargets": ["gpt-5.6-luna"],
|
||||
"selectableTargets": [
|
||||
{
|
||||
"id": "luna",
|
||||
"modelId": "gpt-5.6-luna",
|
||||
"displayName": "GPT 5.6 Luna",
|
||||
"minimumTier": "standard"
|
||||
},
|
||||
{
|
||||
"id": "terra",
|
||||
"modelId": "gpt-5.6-terra",
|
||||
"displayName": "GPT 5.6 Terra",
|
||||
"minimumTier": "premium"
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"modelId": "gemini-3.6-flash",
|
||||
"displayName": "Gemini 3.6 Flash",
|
||||
"minimumTier": "premium"
|
||||
},
|
||||
{
|
||||
"id": "claude",
|
||||
"modelId": "claude-sonnet-4-6",
|
||||
"displayName": "Claude Sonnet 4.6",
|
||||
"minimumTier": "premium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"tools": [
|
||||
"docRead",
|
||||
@@ -708,8 +735,7 @@
|
||||
"docCompose",
|
||||
"codeArtifact",
|
||||
"blobRead"
|
||||
],
|
||||
"proModels": ["gpt-5.6-terra", "gemini-3.6-flash", "claude-sonnet-4-6"]
|
||||
]
|
||||
},
|
||||
"builtins": [
|
||||
"date",
|
||||
@@ -734,7 +760,7 @@
|
||||
{
|
||||
"name": "mindmap.generate",
|
||||
"action": "mindmap.generate",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"config": {
|
||||
"frequencyPenalty": 0.5,
|
||||
"presencePenalty": 0.5,
|
||||
@@ -759,7 +785,7 @@
|
||||
{
|
||||
"name": "slides.outline",
|
||||
"action": "slides.outline",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -778,7 +804,7 @@
|
||||
{
|
||||
"name": "image.filter.sketch",
|
||||
"action": "image.filter.sketch",
|
||||
"model": "lora/image-to-image",
|
||||
"managedRoute": { "targets": ["lora/image-to-image"] },
|
||||
"config": {
|
||||
"modelName": "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"loras": [
|
||||
@@ -798,7 +824,7 @@
|
||||
{
|
||||
"name": "image.filter.clay",
|
||||
"action": "image.filter.clay",
|
||||
"model": "lora/image-to-image",
|
||||
"managedRoute": { "targets": ["lora/image-to-image"] },
|
||||
"config": {
|
||||
"modelName": "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"loras": [
|
||||
@@ -818,7 +844,7 @@
|
||||
{
|
||||
"name": "image.filter.anime",
|
||||
"action": "image.filter.anime",
|
||||
"model": "lora/image-to-image",
|
||||
"managedRoute": { "targets": ["lora/image-to-image"] },
|
||||
"config": {
|
||||
"modelName": "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"loras": [
|
||||
@@ -838,7 +864,7 @@
|
||||
{
|
||||
"name": "image.filter.pixel",
|
||||
"action": "image.filter.pixel",
|
||||
"model": "lora/image-to-image",
|
||||
"managedRoute": { "targets": ["lora/image-to-image"] },
|
||||
"config": {
|
||||
"modelName": "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"loras": [
|
||||
@@ -858,13 +884,12 @@
|
||||
{
|
||||
"name": "workflow:presentation",
|
||||
"action": "workflow:presentation",
|
||||
"model": "slides.outline",
|
||||
"messages": []
|
||||
},
|
||||
{
|
||||
"name": "workflow:presentation:step1",
|
||||
"action": "workflow:presentation:step1",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"config": {
|
||||
"temperature": 0.7
|
||||
},
|
||||
@@ -882,7 +907,7 @@
|
||||
{
|
||||
"name": "workflow:presentation:step2",
|
||||
"action": "workflow:presentation:step2",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -901,7 +926,7 @@
|
||||
{
|
||||
"name": "workflow:presentation:step4",
|
||||
"action": "workflow:presentation:step4",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -920,13 +945,12 @@
|
||||
{
|
||||
"name": "workflow:brainstorm",
|
||||
"action": "workflow:brainstorm",
|
||||
"model": "mindmap.generate",
|
||||
"messages": []
|
||||
},
|
||||
{
|
||||
"name": "workflow:brainstorm:step1",
|
||||
"action": "workflow:brainstorm:step1",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"config": {
|
||||
"temperature": 0.7
|
||||
},
|
||||
@@ -944,7 +968,7 @@
|
||||
{
|
||||
"name": "workflow:brainstorm:step2",
|
||||
"action": "workflow:brainstorm:step2",
|
||||
"model": "gpt-5.6-luna",
|
||||
"managedRoute": { "targets": ["gpt-5.6-luna"] },
|
||||
"config": {
|
||||
"frequencyPenalty": 0.5,
|
||||
"presencePenalty": 0.5,
|
||||
@@ -969,25 +993,21 @@
|
||||
{
|
||||
"name": "workflow:image-sketch",
|
||||
"action": "workflow:image-sketch",
|
||||
"model": "image.filter.sketch",
|
||||
"messages": []
|
||||
},
|
||||
{
|
||||
"name": "workflow:image-clay",
|
||||
"action": "workflow:image-clay",
|
||||
"model": "image.filter.clay",
|
||||
"messages": []
|
||||
},
|
||||
{
|
||||
"name": "workflow:image-anime",
|
||||
"action": "workflow:image-anime",
|
||||
"model": "image.filter.anime",
|
||||
"messages": []
|
||||
},
|
||||
{
|
||||
"name": "workflow:image-pixel",
|
||||
"action": "workflow:image-pixel",
|
||||
"model": "image.filter.pixel",
|
||||
"messages": []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use llm_adapter::capability::provider_default_capability_upper_bound;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{ByokCapabilityInput, contract::capability_input};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokCatalogModelOutput {
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub recommended: bool,
|
||||
pub capabilities: Vec<ByokCapabilityInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokCatalogProviderOutput {
|
||||
pub provider: String,
|
||||
pub models: Vec<ByokCatalogModelOutput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokCatalogOutput {
|
||||
pub version: String,
|
||||
pub providers: Vec<ByokCatalogProviderOutput>,
|
||||
}
|
||||
|
||||
pub fn byok_catalog() -> ByokCatalogOutput {
|
||||
let variants = llm_adapter::core::default_model_registry_variants();
|
||||
let mut providers = ["openai", "anthropic", "gemini", "fal"]
|
||||
.into_iter()
|
||||
.map(|provider| (provider, BTreeMap::new()))
|
||||
.collect::<BTreeMap<_, BTreeMap<String, ByokCatalogModelOutput>>>();
|
||||
|
||||
for variant in variants {
|
||||
let Some(provider) = provider_for_backend(&variant.backend_kind) else {
|
||||
continue;
|
||||
};
|
||||
let Some(capabilities) = provider_default_capability_upper_bound(provider, &variant.raw_model_id) else {
|
||||
continue;
|
||||
};
|
||||
providers
|
||||
.entry(provider)
|
||||
.or_default()
|
||||
.entry(variant.raw_model_id.clone())
|
||||
.or_insert_with(|| ByokCatalogModelOutput {
|
||||
model_id: variant.raw_model_id.clone(),
|
||||
display_name: variant.display_name.unwrap_or_else(|| variant.raw_model_id.clone()),
|
||||
recommended: variant
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability.default_for_output_type == Some(true)),
|
||||
capabilities: capabilities.into_iter().map(capability_input).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
let providers = providers
|
||||
.into_iter()
|
||||
.map(|(provider, models)| ByokCatalogProviderOutput {
|
||||
provider: provider.to_string(),
|
||||
models: models.into_values().collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let encoded = serde_json::to_vec(&providers).expect("BYOK catalog must serialize");
|
||||
let version = Sha256::digest(encoded)
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
ByokCatalogOutput { version, providers }
|
||||
}
|
||||
|
||||
fn provider_for_backend(backend: &str) -> Option<&'static str> {
|
||||
match backend {
|
||||
"openai_chat" | "openai_responses" => Some("openai"),
|
||||
"anthropic" => Some("anthropic"),
|
||||
"gemini_api" => Some("gemini"),
|
||||
"fal" => Some("fal"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_contains_explicit_provider_default_declarations() {
|
||||
let catalog = byok_catalog();
|
||||
assert!(!catalog.version.is_empty());
|
||||
for provider in &catalog.providers {
|
||||
assert!(
|
||||
!provider.models.is_empty(),
|
||||
"{} has no catalog models",
|
||||
provider.provider
|
||||
);
|
||||
for model in &provider.models {
|
||||
assert!(!model.model_id.is_empty());
|
||||
assert!(!model.capabilities.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use llm_adapter::{
|
||||
capability::{
|
||||
AttachmentKind, AttachmentSource, DeclaredModelCapability, ModelFeature, ModelInput, ModelOutput,
|
||||
provider_default_capability_upper_bound, validate_capability_upper_bound, validate_declared_capability,
|
||||
},
|
||||
target::canonicalize_endpoint,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokCapabilityInput {
|
||||
pub input: Vec<String>,
|
||||
pub output: Vec<String>,
|
||||
pub features: Vec<String>,
|
||||
pub attachment_kinds: Vec<String>,
|
||||
pub attachment_sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokModelDeclarationInput {
|
||||
pub model_id: String,
|
||||
pub enabled: bool,
|
||||
pub capabilities: Vec<ByokCapabilityInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokEndpointInput {
|
||||
pub kind: String,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokProfileDefinitionInput {
|
||||
pub version: u32,
|
||||
pub endpoint: ByokEndpointInput,
|
||||
pub models: Vec<ByokModelDeclarationInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CreateByokProfileInput {
|
||||
pub workspace_id: String,
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub credential: String,
|
||||
pub definition: ByokProfileDefinitionInput,
|
||||
pub enabled: bool,
|
||||
pub actor_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ReplaceByokProfileInput {
|
||||
pub workspace_id: String,
|
||||
pub profile_id: String,
|
||||
pub expected_revision: i32,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub definition: ByokProfileDefinitionInput,
|
||||
pub credential: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub actor_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct RotateByokCredentialInput {
|
||||
pub workspace_id: String,
|
||||
pub profile_id: String,
|
||||
pub expected_revision: i32,
|
||||
pub credential: String,
|
||||
pub actor_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokProfileOrderInput {
|
||||
pub profile_id: String,
|
||||
pub expected_revision: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ReorderByokProfilesInput {
|
||||
pub workspace_id: String,
|
||||
pub profiles: Vec<ByokProfileOrderInput>,
|
||||
pub actor_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ProbeByokProfileInput {
|
||||
pub workspace_id: String,
|
||||
pub profile_id: String,
|
||||
pub checks: Vec<ByokProbeCheckInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ProbeByokDraftInput {
|
||||
pub workspace_id: String,
|
||||
pub provider: String,
|
||||
pub credential: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub expected_revision: Option<i32>,
|
||||
pub definition: ByokProfileDefinitionInput,
|
||||
pub checks: Vec<ByokProbeCheckInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokProbeCheckInput {
|
||||
pub model_id: String,
|
||||
pub operation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CreateByokLocalLeaseProviderInput {
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub credential: String,
|
||||
pub definition: ByokProfileDefinitionInput,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CreateByokLocalLeaseInput {
|
||||
pub workspace_id: String,
|
||||
pub user_id: String,
|
||||
pub providers: Vec<CreateByokLocalLeaseProviderInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokLocalLeaseOutput {
|
||||
pub lease_id: String,
|
||||
pub expires_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokProfileOutput {
|
||||
pub profile_id: String,
|
||||
pub workspace_id: String,
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub definition: ByokProfileDefinitionInput,
|
||||
pub enabled: bool,
|
||||
pub sort_order: i32,
|
||||
pub revision: i32,
|
||||
pub validation: Option<ByokValidationOutput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokProbeStatusOutput {
|
||||
pub kind: String,
|
||||
pub tested_at_ms: Option<i64>,
|
||||
pub error_kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokModelProbeOutput {
|
||||
pub model_id: String,
|
||||
pub checks: Vec<ByokModelProbeCheckOutput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokModelProbeCheckOutput {
|
||||
pub operation: String,
|
||||
pub status: ByokProbeStatusOutput,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokValidationOutput {
|
||||
pub definition_fingerprint: String,
|
||||
pub credential_generation: i32,
|
||||
pub connection: ByokProbeStatusOutput,
|
||||
pub models: Vec<ByokModelProbeOutput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct ByokProbeResultOutput {
|
||||
pub definition_fingerprint: String,
|
||||
pub stale: bool,
|
||||
pub connection: ByokProbeStatusOutput,
|
||||
pub models: Vec<ByokModelProbeOutput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub(crate) enum ByokEndpoint {
|
||||
ProviderDefault,
|
||||
Custom { url: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ByokModelDeclaration {
|
||||
pub(crate) model_id: String,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) capabilities: Vec<DeclaredModelCapability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ByokProfileDefinition {
|
||||
pub(crate) version: u32,
|
||||
pub(crate) endpoint: ByokEndpoint,
|
||||
pub(crate) models: Vec<ByokModelDeclaration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ByokContractError {
|
||||
#[error("unsupported BYOK definition version")]
|
||||
Version,
|
||||
#[error("unsupported BYOK provider")]
|
||||
Provider,
|
||||
#[error("{0} is required")]
|
||||
Required(&'static str),
|
||||
#[error("duplicate {0}")]
|
||||
Duplicate(&'static str),
|
||||
#[error("invalid BYOK endpoint")]
|
||||
Endpoint,
|
||||
#[error("invalid model capability: {0}")]
|
||||
Capability(String),
|
||||
#[error("declared capability exceeds provider or model upper bound")]
|
||||
CapabilityUpperBound,
|
||||
}
|
||||
|
||||
impl ByokProfileDefinition {
|
||||
pub(crate) fn endpoint_identity(&self) -> &str {
|
||||
match &self.endpoint {
|
||||
ByokEndpoint::ProviderDefault => "default",
|
||||
ByokEndpoint::Custom { url } => url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_definition(
|
||||
provider: &str,
|
||||
input: ByokProfileDefinitionInput,
|
||||
) -> Result<ByokProfileDefinition, ByokContractError> {
|
||||
if input.version != 1 {
|
||||
return Err(ByokContractError::Version);
|
||||
}
|
||||
if !matches!(provider, "openai" | "anthropic" | "gemini" | "fal") {
|
||||
return Err(ByokContractError::Provider);
|
||||
}
|
||||
let endpoint = match (input.endpoint.kind.as_str(), input.endpoint.url) {
|
||||
("provider_default", None) => ByokEndpoint::ProviderDefault,
|
||||
("custom", Some(url)) if !url.trim().is_empty() => ByokEndpoint::Custom {
|
||||
url: canonicalize_endpoint(&url).map_err(|_| ByokContractError::Endpoint)?,
|
||||
},
|
||||
_ => return Err(ByokContractError::Endpoint),
|
||||
};
|
||||
if input.models.is_empty() {
|
||||
return Err(ByokContractError::Required("models"));
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
let mut models = Vec::with_capacity(input.models.len());
|
||||
for model in input.models {
|
||||
let model_id = model.model_id.trim().to_string();
|
||||
if model_id.is_empty() || model_id.len() > 512 {
|
||||
return Err(ByokContractError::Required("modelId"));
|
||||
}
|
||||
if !ids.insert(model_id.clone()) {
|
||||
return Err(ByokContractError::Duplicate("modelId"));
|
||||
}
|
||||
if model.capabilities.is_empty() {
|
||||
return Err(ByokContractError::Required("capabilities"));
|
||||
}
|
||||
let capabilities = model
|
||||
.capabilities
|
||||
.into_iter()
|
||||
.map(parse_capability)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
validate_upper_bound(provider, &endpoint, &model_id, &capabilities)?;
|
||||
models.push(ByokModelDeclaration {
|
||||
model_id,
|
||||
enabled: model.enabled,
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ByokProfileDefinition {
|
||||
version: 1,
|
||||
endpoint,
|
||||
models,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_capability(input: ByokCapabilityInput) -> Result<DeclaredModelCapability, ByokContractError> {
|
||||
let capability = DeclaredModelCapability {
|
||||
input: parse_values(input.input, |value| match value {
|
||||
"text" => Some(ModelInput::Text),
|
||||
"image" => Some(ModelInput::Image),
|
||||
"audio" => Some(ModelInput::Audio),
|
||||
"file" => Some(ModelInput::File),
|
||||
_ => None,
|
||||
})?,
|
||||
output: parse_values(input.output, |value| match value {
|
||||
"text" => Some(ModelOutput::Text),
|
||||
"object" => Some(ModelOutput::Object),
|
||||
"structured" => Some(ModelOutput::Structured),
|
||||
"embedding" => Some(ModelOutput::Embedding),
|
||||
"rerank" => Some(ModelOutput::Rerank),
|
||||
"image" => Some(ModelOutput::Image),
|
||||
_ => None,
|
||||
})?,
|
||||
features: parse_values(input.features, |value| match value {
|
||||
"tool_calling" => Some(ModelFeature::ToolCalling),
|
||||
"reasoning" => Some(ModelFeature::Reasoning),
|
||||
"web_search" => Some(ModelFeature::WebSearch),
|
||||
_ => None,
|
||||
})?,
|
||||
attachment_kinds: parse_values(input.attachment_kinds, |value| match value {
|
||||
"image" => Some(AttachmentKind::Image),
|
||||
"audio" => Some(AttachmentKind::Audio),
|
||||
"file" => Some(AttachmentKind::File),
|
||||
_ => None,
|
||||
})?,
|
||||
attachment_sources: parse_values(input.attachment_sources, |value| match value {
|
||||
"url" => Some(AttachmentSource::Url),
|
||||
"data" => Some(AttachmentSource::Data),
|
||||
"bytes" => Some(AttachmentSource::Bytes),
|
||||
"file_handle" => Some(AttachmentSource::FileHandle),
|
||||
_ => None,
|
||||
})?,
|
||||
};
|
||||
validate_declared_capability(&capability).map_err(|error| ByokContractError::Capability(error.to_string()))?;
|
||||
Ok(capability)
|
||||
}
|
||||
|
||||
fn parse_values<T>(values: Vec<String>, parse: impl Fn(&str) -> Option<T>) -> Result<Vec<T>, ByokContractError> {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| parse(&value).ok_or_else(|| ByokContractError::Capability(format!("unknown enum {value}"))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_upper_bound(
|
||||
provider: &str,
|
||||
endpoint: &ByokEndpoint,
|
||||
model_id: &str,
|
||||
capabilities: &[DeclaredModelCapability],
|
||||
) -> Result<(), ByokContractError> {
|
||||
if provider == "fal"
|
||||
&& capabilities.iter().any(|capability| {
|
||||
capability.output.iter().any(|output| *output != ModelOutput::Image)
|
||||
|| capability
|
||||
.input
|
||||
.iter()
|
||||
.any(|input| !matches!(input, ModelInput::Text | ModelInput::Image))
|
||||
})
|
||||
{
|
||||
return Err(ByokContractError::CapabilityUpperBound);
|
||||
}
|
||||
if matches!(endpoint, ByokEndpoint::Custom { .. }) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let upper_bound =
|
||||
provider_default_capability_upper_bound(provider, model_id).ok_or(ByokContractError::CapabilityUpperBound)?;
|
||||
for capability in capabilities {
|
||||
validate_capability_upper_bound(capability, &upper_bound).map_err(|_| ByokContractError::CapabilityUpperBound)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn input_name(value: &ModelInput) -> &'static str {
|
||||
match value {
|
||||
ModelInput::Text => "text",
|
||||
ModelInput::Image => "image",
|
||||
ModelInput::Audio => "audio",
|
||||
ModelInput::File => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn output_name(value: &ModelOutput) -> &'static str {
|
||||
match value {
|
||||
ModelOutput::Text => "text",
|
||||
ModelOutput::Object => "object",
|
||||
ModelOutput::Structured => "structured",
|
||||
ModelOutput::Embedding => "embedding",
|
||||
ModelOutput::Rerank => "rerank",
|
||||
ModelOutput::Image => "image",
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_kind_name(value: &AttachmentKind) -> &'static str {
|
||||
match value {
|
||||
AttachmentKind::Image => "image",
|
||||
AttachmentKind::Audio => "audio",
|
||||
AttachmentKind::File => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_source_name(value: &AttachmentSource) -> &'static str {
|
||||
match value {
|
||||
AttachmentSource::Url => "url",
|
||||
AttachmentSource::Data => "data",
|
||||
AttachmentSource::Bytes => "bytes",
|
||||
AttachmentSource::FileHandle => "file_handle",
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ByokProfileDefinition> for ByokProfileDefinitionInput {
|
||||
fn from(definition: ByokProfileDefinition) -> Self {
|
||||
Self {
|
||||
version: definition.version,
|
||||
endpoint: match definition.endpoint {
|
||||
ByokEndpoint::ProviderDefault => ByokEndpointInput {
|
||||
kind: "provider_default".to_string(),
|
||||
url: None,
|
||||
},
|
||||
ByokEndpoint::Custom { url } => ByokEndpointInput {
|
||||
kind: "custom".to_string(),
|
||||
url: Some(url),
|
||||
},
|
||||
},
|
||||
models: definition
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| ByokModelDeclarationInput {
|
||||
model_id: model.model_id,
|
||||
enabled: model.enabled,
|
||||
capabilities: model.capabilities.into_iter().map(capability_input).collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn capability_input(capability: DeclaredModelCapability) -> ByokCapabilityInput {
|
||||
ByokCapabilityInput {
|
||||
input: capability.input.iter().map(input_name).map(str::to_string).collect(),
|
||||
output: capability.output.iter().map(output_name).map(str::to_string).collect(),
|
||||
features: capability
|
||||
.features
|
||||
.iter()
|
||||
.map(|value| match value {
|
||||
ModelFeature::ToolCalling => "tool_calling",
|
||||
ModelFeature::Reasoning => "reasoning",
|
||||
ModelFeature::WebSearch => "web_search",
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
attachment_kinds: capability
|
||||
.attachment_kinds
|
||||
.iter()
|
||||
.map(attachment_kind_name)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
attachment_sources: capability
|
||||
.attachment_sources
|
||||
.iter()
|
||||
.map(attachment_source_name)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn definition(model_id: &str, capabilities: Vec<ByokCapabilityInput>) -> ByokProfileDefinitionInput {
|
||||
ByokProfileDefinitionInput {
|
||||
version: 1,
|
||||
endpoint: ByokEndpointInput {
|
||||
kind: "custom".to_string(),
|
||||
url: Some("https://example.com/v1/".to_string()),
|
||||
},
|
||||
models: vec![ByokModelDeclarationInput {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
capabilities,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn text_capability() -> ByokCapabilityInput {
|
||||
ByokCapabilityInput {
|
||||
input: vec!["text".to_string()],
|
||||
output: vec!["text".to_string()],
|
||||
features: vec![],
|
||||
attachment_kinds: vec![],
|
||||
attachment_sources: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_opaque_model_id_and_explicit_empty_features() {
|
||||
let validated =
|
||||
validate_definition("openai", definition(" vendor/model:latest ", vec![text_capability()])).unwrap();
|
||||
assert_eq!(validated.models[0].model_id, "vendor/model:latest");
|
||||
assert!(validated.models[0].capabilities[0].features.is_empty());
|
||||
assert_eq!(validated.endpoint_identity(), "https://example.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_shapes_table() {
|
||||
let mut cases = Vec::new();
|
||||
cases.push(ByokProfileDefinitionInput {
|
||||
models: vec![],
|
||||
..definition("model", vec![text_capability()])
|
||||
});
|
||||
cases.push(definition("", vec![text_capability()]));
|
||||
cases.push(definition("model", vec![]));
|
||||
let mut empty_input = text_capability();
|
||||
empty_input.input.clear();
|
||||
cases.push(definition("model", vec![empty_input]));
|
||||
let mut duplicate = text_capability();
|
||||
duplicate.output.push("text".to_string());
|
||||
cases.push(definition("model", vec![duplicate]));
|
||||
assert!(
|
||||
cases
|
||||
.into_iter()
|
||||
.all(|case| validate_definition("openai", case).is_err())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_endpoint_tag_mismatches() {
|
||||
for endpoint in [
|
||||
ByokEndpointInput {
|
||||
kind: "provider_default".to_string(),
|
||||
url: Some("https://example.com".to_string()),
|
||||
},
|
||||
ByokEndpointInput {
|
||||
kind: "custom".to_string(),
|
||||
url: None,
|
||||
},
|
||||
ByokEndpointInput {
|
||||
kind: "custom".to_string(),
|
||||
url: Some(" ".to_string()),
|
||||
},
|
||||
] {
|
||||
let mut input = definition("model", vec![text_capability()]);
|
||||
input.endpoint = endpoint;
|
||||
assert!(validate_definition("openai", input).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, Nonce,
|
||||
aead::{Aead, KeyInit, Payload},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hkdf::Hkdf;
|
||||
use rand::RngCore;
|
||||
use sha2::Sha256;
|
||||
use thiserror::Error;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
const PREFIX: &str = "byok:v1:";
|
||||
const INFO: &[u8] = b"AFFiNE/Copilot/BYOK/v1";
|
||||
const NONCE_LEN: usize = 12;
|
||||
const TAG_LEN: usize = 16;
|
||||
|
||||
pub(crate) struct CredentialEnvelopeKey(Zeroizing<[u8; 32]>);
|
||||
pub(crate) struct SensitiveCredential(Zeroizing<Vec<u8>>);
|
||||
|
||||
impl SensitiveCredential {
|
||||
pub(crate) fn new(value: impl Into<Vec<u8>>) -> Self {
|
||||
Self(Zeroizing::new(value.into()))
|
||||
}
|
||||
|
||||
pub(crate) fn expose(&self) -> &[u8] {
|
||||
self.0.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum CredentialEnvelopeError {
|
||||
#[error("credential_unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl CredentialEnvelopeKey {
|
||||
pub(crate) fn derive(root_secret: &[u8]) -> Result<Self, CredentialEnvelopeError> {
|
||||
if root_secret.is_empty() {
|
||||
return Err(CredentialEnvelopeError::Unavailable);
|
||||
}
|
||||
let mut key = Zeroizing::new([0_u8; 32]);
|
||||
Hkdf::<Sha256>::new(None, root_secret)
|
||||
.expand(INFO, key.as_mut())
|
||||
.map_err(|_| CredentialEnvelopeError::Unavailable)?;
|
||||
Ok(Self(key))
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt(
|
||||
&self,
|
||||
credential: &SensitiveCredential,
|
||||
aad: &[u8],
|
||||
) -> Result<String, CredentialEnvelopeError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(self.0.as_slice()).map_err(|_| CredentialEnvelopeError::Unavailable)?;
|
||||
let mut nonce = [0_u8; NONCE_LEN];
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
msg: credential.expose(),
|
||||
aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| CredentialEnvelopeError::Unavailable)?;
|
||||
let mut body = Vec::with_capacity(NONCE_LEN + ciphertext.len());
|
||||
body.extend_from_slice(&nonce);
|
||||
body.extend_from_slice(&ciphertext);
|
||||
let encoded = URL_SAFE_NO_PAD.encode(&body);
|
||||
body.zeroize();
|
||||
Ok(format!("{PREFIX}{encoded}"))
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt(&self, envelope: &str, aad: &[u8]) -> Result<SensitiveCredential, CredentialEnvelopeError> {
|
||||
let encoded = envelope
|
||||
.strip_prefix(PREFIX)
|
||||
.ok_or(CredentialEnvelopeError::Unavailable)?;
|
||||
let mut body = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|_| CredentialEnvelopeError::Unavailable)?;
|
||||
if body.len() < NONCE_LEN + TAG_LEN {
|
||||
body.zeroize();
|
||||
return Err(CredentialEnvelopeError::Unavailable);
|
||||
}
|
||||
let (nonce, ciphertext) = body.split_at(NONCE_LEN);
|
||||
let cipher = Aes256Gcm::new_from_slice(self.0.as_slice()).map_err(|_| CredentialEnvelopeError::Unavailable)?;
|
||||
let result = cipher
|
||||
.decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad })
|
||||
.map(SensitiveCredential::new)
|
||||
.map_err(|_| CredentialEnvelopeError::Unavailable);
|
||||
body.zeroize();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn server_aad(workspace_id: &str, profile_id: &str, provider: &str, endpoint_identity: &str) -> Vec<u8> {
|
||||
["server", workspace_id, profile_id, provider, endpoint_identity]
|
||||
.join("\0")
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn local_aad(
|
||||
workspace_id: &str,
|
||||
user_id: &str,
|
||||
lease_id: &str,
|
||||
index: usize,
|
||||
provider: &str,
|
||||
endpoint_identity: &str,
|
||||
) -> Vec<u8> {
|
||||
[
|
||||
"local".to_string(),
|
||||
workspace_id.to_string(),
|
||||
user_id.to_string(),
|
||||
lease_id.to_string(),
|
||||
index.to_string(),
|
||||
provider.to_string(),
|
||||
endpoint_identity.to_string(),
|
||||
]
|
||||
.join("\0")
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_with_random_nonce_and_rejects_tampering() {
|
||||
let key = CredentialEnvelopeKey::derive(b"stable-root").unwrap();
|
||||
let credential = SensitiveCredential::new(b"secret".to_vec());
|
||||
let aad = server_aad("workspace", "profile", "openai", "default");
|
||||
let first = key.encrypt(&credential, &aad).unwrap();
|
||||
let second = key.encrypt(&credential, &aad).unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(key.decrypt(&first, &aad).unwrap().expose(), b"secret");
|
||||
|
||||
let mut tampered = first.into_bytes();
|
||||
let last = tampered.len() - 1;
|
||||
tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
|
||||
assert!(key.decrypt(std::str::from_utf8(&tampered).unwrap(), &aad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_context_key_version_and_legacy_ciphertext() {
|
||||
let key = CredentialEnvelopeKey::derive(b"stable-root").unwrap();
|
||||
let credential = SensitiveCredential::new(b"secret".to_vec());
|
||||
let aad = server_aad("workspace", "profile", "openai", "default");
|
||||
let encrypted = key.encrypt(&credential, &aad).unwrap();
|
||||
assert!(
|
||||
key
|
||||
.decrypt(&encrypted, &server_aad("other", "profile", "openai", "default"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
CredentialEnvelopeKey::derive(b"other")
|
||||
.unwrap()
|
||||
.decrypt(&encrypted, &aad)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
key
|
||||
.decrypt(&encrypted.replacen("byok:v1:", "byok:v2:", 1), &aad)
|
||||
.is_err()
|
||||
);
|
||||
assert!(key.decrypt("bGVnYWN5", &aad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_stable_root_secret() {
|
||||
assert!(CredentialEnvelopeKey::derive(b"").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
mod catalog;
|
||||
mod contract;
|
||||
mod envelope;
|
||||
mod validation;
|
||||
|
||||
pub use catalog::{ByokCatalogModelOutput, ByokCatalogOutput, ByokCatalogProviderOutput, byok_catalog};
|
||||
pub use contract::{
|
||||
ByokCapabilityInput, ByokEndpointInput, ByokLocalLeaseOutput, ByokModelDeclarationInput, ByokModelProbeCheckOutput,
|
||||
ByokModelProbeOutput, ByokProbeCheckInput, ByokProbeResultOutput, ByokProbeStatusOutput, ByokProfileDefinitionInput,
|
||||
ByokProfileOutput, ByokValidationOutput, CreateByokLocalLeaseInput, CreateByokLocalLeaseProviderInput,
|
||||
CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput,
|
||||
ReplaceByokProfileInput, RotateByokCredentialInput,
|
||||
};
|
||||
pub(crate) use contract::{ByokEndpoint, ByokModelDeclaration, ByokProfileDefinition, validate_definition};
|
||||
pub(crate) use envelope::{CredentialEnvelopeKey, SensitiveCredential, local_aad, server_aad};
|
||||
pub(crate) use validation::{definition_fingerprint, reconcile_validation};
|
||||
@@ -0,0 +1,93 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{ByokProfileDefinition, ByokValidationOutput};
|
||||
|
||||
pub(crate) fn definition_fingerprint(definition: &ByokProfileDefinition) -> String {
|
||||
let encoded = serde_json::to_vec(definition).expect("validated BYOK definition must serialize");
|
||||
Sha256::digest(encoded)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_validation(
|
||||
validation: Option<ByokValidationOutput>,
|
||||
old_definition: &ByokProfileDefinition,
|
||||
definition: &ByokProfileDefinition,
|
||||
credential_generation: i32,
|
||||
credential_changed: bool,
|
||||
) -> Option<ByokValidationOutput> {
|
||||
let mut validation = validation?;
|
||||
if credential_changed || old_definition.endpoint != definition.endpoint {
|
||||
return None;
|
||||
}
|
||||
validation.models.retain(|evidence| {
|
||||
let old = old_definition
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == evidence.model_id);
|
||||
let new = definition
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == evidence.model_id);
|
||||
old.is_some() && old == new
|
||||
});
|
||||
validation.definition_fingerprint = definition_fingerprint(definition);
|
||||
validation.credential_generation = credential_generation;
|
||||
Some(validation)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use llm_adapter::capability::{DeclaredModelCapability, ModelInput, ModelOutput};
|
||||
|
||||
use super::*;
|
||||
use crate::llm::byok::{ByokEndpoint, ByokModelDeclaration, ByokModelProbeOutput, ByokProbeStatusOutput};
|
||||
|
||||
fn definition(models: &[&str]) -> ByokProfileDefinition {
|
||||
ByokProfileDefinition {
|
||||
version: 1,
|
||||
endpoint: ByokEndpoint::ProviderDefault,
|
||||
models: models
|
||||
.iter()
|
||||
.map(|model| ByokModelDeclaration {
|
||||
model_id: (*model).to_string(),
|
||||
enabled: true,
|
||||
capabilities: vec![DeclaredModelCapability {
|
||||
input: vec![ModelInput::Text],
|
||||
output: vec![ModelOutput::Text],
|
||||
features: vec![],
|
||||
attachment_kinds: vec![],
|
||||
attachment_sources: vec![],
|
||||
}],
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_only_unchanged_model_evidence() {
|
||||
let old = definition(&["a", "b"]);
|
||||
let new = definition(&["b", "c"]);
|
||||
let validation = ByokValidationOutput {
|
||||
definition_fingerprint: definition_fingerprint(&old),
|
||||
credential_generation: 1,
|
||||
connection: ByokProbeStatusOutput {
|
||||
kind: "verified".to_string(),
|
||||
tested_at_ms: Some(1),
|
||||
error_kind: None,
|
||||
},
|
||||
models: ["a", "b"]
|
||||
.into_iter()
|
||||
.map(|model_id| ByokModelProbeOutput {
|
||||
model_id: model_id.to_string(),
|
||||
checks: vec![],
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let next = reconcile_validation(Some(validation), &old, &new, 1, false).unwrap();
|
||||
assert_eq!(next.models.len(), 1);
|
||||
assert_eq!(next.models[0].model_id, "b");
|
||||
assert_eq!(next.definition_fingerprint, definition_fingerprint(&new));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,7 @@ use super::{
|
||||
action::{TranscriptGeneratedResult, TranscriptInputContract, TranscriptResult},
|
||||
core::contracts::{
|
||||
CapabilityMatchRequest, CapabilityMatchResponse, ModelConditionsContract, ModelRegistryMatchRequest,
|
||||
ModelRegistryMatchResponse, ModelRegistryResolveRequest, ModelRegistryResolveResponse, PromptRenderContract,
|
||||
PromptSessionContract, ProviderDriverSpec, RequestedModelMatchRequest, RequestedModelMatchResponse,
|
||||
ModelRegistryMatchResponse, ModelRegistryResolveRequest, ModelRegistryResolveResponse, ProviderDriverSpec,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,7 +77,7 @@ fn mark_definition_property_nullable(schema: &mut Value, definition: &str, prope
|
||||
|
||||
pub(crate) fn transcript_input_schema() -> Value {
|
||||
let mut schema = generated_schema_for::<TranscriptInputContract>();
|
||||
for property in ["sourceAudio", "quality", "infos", "sliceManifest", "preparedRoutes"] {
|
||||
for property in ["sourceAudio", "quality", "infos", "sliceManifest"] {
|
||||
mark_property_nullable(&mut schema, property);
|
||||
}
|
||||
mark_definition_property_nullable(&mut schema, "TranscriptAudioInfo", "index");
|
||||
@@ -88,7 +87,7 @@ pub(crate) fn transcript_input_schema() -> Value {
|
||||
|
||||
pub(crate) fn transcript_generated_result_schema() -> Value {
|
||||
let mut schema = generated_schema_for::<TranscriptGeneratedResult>();
|
||||
for property in ["normalizedSegments", "summaryJson", "providerMeta"] {
|
||||
for property in ["normalizedSegments", "summaryJson"] {
|
||||
mark_property_nullable(&mut schema, property);
|
||||
}
|
||||
mark_definition_property_nullable(&mut schema, "MeetingSummaryActionItem", "owner");
|
||||
@@ -105,7 +104,6 @@ pub(crate) fn transcript_result_schema() -> Value {
|
||||
"sliceManifest",
|
||||
"normalizedSegments",
|
||||
"summaryJson",
|
||||
"providerMeta",
|
||||
] {
|
||||
mark_property_nullable(&mut schema, property);
|
||||
}
|
||||
@@ -118,12 +116,6 @@ pub(crate) fn transcript_result_schema() -> Value {
|
||||
|
||||
fn schema_by_name(name: &str) -> Option<Value> {
|
||||
match name {
|
||||
// runtime-owned temporary native facade
|
||||
"executionPlan" => Some(generated_schema_for::<llm_runtime::SerializableExecutionPlan>()),
|
||||
// adapter-owned temporary native facade
|
||||
"preparedRoutes" => Some(generated_schema_for::<
|
||||
Vec<llm_adapter::router::SerializablePreparedRoute>,
|
||||
>()),
|
||||
// AFFiNE-native-owned N-API projection over adapter model registry/matcher
|
||||
"capabilityMatchRequest" => Some(generated_schema_for::<CapabilityMatchRequest>()),
|
||||
"capabilityMatchResponse" => Some(generated_schema_for::<CapabilityMatchResponse>()),
|
||||
@@ -133,11 +125,6 @@ fn schema_by_name(name: &str) -> Option<Value> {
|
||||
"modelRegistryResolveRequest" => Some(generated_schema_for::<ModelRegistryResolveRequest>()),
|
||||
"modelRegistryResolveResponse" => Some(generated_schema_for::<ModelRegistryResolveResponse>()),
|
||||
"providerDriverSpec" => Some(generated_schema_for::<ProviderDriverSpec>()),
|
||||
// AFFiNE-native-owned prompt facade over adapter prompt DTOs/catalog
|
||||
"promptRenderContract" => Some(generated_schema_for::<PromptRenderContract>()),
|
||||
"promptSessionContract" => Some(generated_schema_for::<PromptSessionContract>()),
|
||||
"requestedModelMatchRequest" => Some(generated_schema_for::<RequestedModelMatchRequest>()),
|
||||
"requestedModelMatchResponse" => Some(generated_schema_for::<RequestedModelMatchResponse>()),
|
||||
// runtime-owned
|
||||
"toolCallbackRequest" => Some(generated_schema_for::<llm_runtime::ToolCallbackRequest>()),
|
||||
"toolCallbackResponse" => Some(generated_schema_for::<llm_runtime::ToolCallbackResponse>()),
|
||||
@@ -176,23 +163,6 @@ pub fn llm_validate_contract(name: String, value: Value) -> Result<Value> {
|
||||
)))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_compile_execution_plan(value: Value) -> Result<Value> {
|
||||
let value = llm_validate_contract("executionPlan".to_string(), value)?;
|
||||
llm_runtime::compile_execution_plan_value(value.clone()).map_err(|error| invalid_contract(error.to_string()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_normalize_prepared_routes(value: Value) -> Result<Value> {
|
||||
let value = llm_adapter::router::normalize_prepared_routes(value).map_err(|error| {
|
||||
invalid_contract(format!(
|
||||
"LLM prepared routes value does not match adapter contract: {error}"
|
||||
))
|
||||
})?;
|
||||
llm_validate_contract("preparedRoutes".to_string(), value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -220,8 +190,7 @@ mod tests {
|
||||
"decisions": [],
|
||||
"openQuestions": [],
|
||||
"blockers": []
|
||||
},
|
||||
"providerMeta": { "provider": "gemini" }
|
||||
}
|
||||
});
|
||||
assert!(llm_validate_contract("transcriptGeneratedResult".to_string(), value).is_ok());
|
||||
}
|
||||
@@ -234,7 +203,6 @@ mod tests {
|
||||
"normalizedSegments": null,
|
||||
"normalizedTranscript": "",
|
||||
"summaryJson": null,
|
||||
"providerMeta": null,
|
||||
"extra": true
|
||||
}),
|
||||
)
|
||||
@@ -242,24 +210,6 @@ mod tests {
|
||||
assert!(error.reason.contains("does not match schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiles_execution_plan_contract() {
|
||||
let value = json!({
|
||||
"routes": [{
|
||||
"providerId": "openai-main",
|
||||
"protocol": "openai_chat",
|
||||
"model": "gpt-5-mini",
|
||||
"backendConfig": { "base_url": "https://api.openai.com/v1", "auth_token": "token" }
|
||||
}],
|
||||
"request": { "kind": "text", "cond": { "modelId": "gpt-5-mini" }, "messages": [] },
|
||||
"routePolicy": { "fallbackOrder": ["openai-main"] },
|
||||
"runtimePolicy": {},
|
||||
"attachmentPolicy": { "materializeRemoteAttachments": true },
|
||||
"responsePostprocess": { "mode": "text" }
|
||||
});
|
||||
assert!(super::llm_compile_execution_plan(value).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_runtime_tool_callback_contracts() {
|
||||
assert!(
|
||||
@@ -288,100 +238,4 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(error.reason.contains("does not match schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_prompt_contracts_from_native_types() {
|
||||
assert!(
|
||||
llm_validate_contract(
|
||||
"promptRenderContract".to_string(),
|
||||
json!({
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"templateParams": {},
|
||||
"renderParams": {}
|
||||
}),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
llm_validate_contract(
|
||||
"promptSessionContract".to_string(),
|
||||
json!({
|
||||
"prompt": {
|
||||
"promptTokens": 1,
|
||||
"templateParams": {},
|
||||
"messages": [{ "role": "system", "content": "hello" }]
|
||||
},
|
||||
"turns": [],
|
||||
"renderParams": {},
|
||||
"maxTokenSize": 1000
|
||||
}),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_adapter_prepared_route_contract() {
|
||||
assert!(
|
||||
super::llm_normalize_prepared_routes(json!([
|
||||
{
|
||||
"provider_id": "openai-main",
|
||||
"protocol": "openai_chat",
|
||||
"model": "gpt-5-mini",
|
||||
"config": {
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"auth_token": "token"
|
||||
},
|
||||
"request": {
|
||||
"model": "gpt-5-mini",
|
||||
"messages": []
|
||||
}
|
||||
}
|
||||
]))
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let error = super::llm_normalize_prepared_routes(json!([
|
||||
{
|
||||
"provider_id": "openai-main",
|
||||
"protocol": "openai_chat",
|
||||
"model": "gpt-5-mini",
|
||||
"config": { "base_url": "https://api.openai.com/v1" },
|
||||
"request": {}
|
||||
}
|
||||
]))
|
||||
.unwrap_err();
|
||||
assert!(error.reason.contains("adapter contract"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_plan_rejects_host_only_state() {
|
||||
let value = json!({
|
||||
"routes": [],
|
||||
"request": {
|
||||
"kind": "text",
|
||||
"cond": { "modelId": "gpt-5-mini" },
|
||||
"messages": [],
|
||||
"options": { "signal": {} }
|
||||
},
|
||||
"routePolicy": { "fallbackOrder": [] },
|
||||
"runtimePolicy": {},
|
||||
"attachmentPolicy": { "materializeRemoteAttachments": true },
|
||||
"responsePostprocess": { "mode": "text" }
|
||||
});
|
||||
let error = super::llm_compile_execution_plan(value).unwrap_err();
|
||||
assert!(error.reason.contains("request.options.signal"));
|
||||
|
||||
let value = json!({
|
||||
"routes": [],
|
||||
"request": { "kind": "text", "cond": { "modelId": "gpt-5-mini" }, "messages": [] },
|
||||
"routePolicy": { "fallbackOrder": [] },
|
||||
"runtimePolicy": {},
|
||||
"attachmentPolicy": { "materializeRemoteAttachments": true },
|
||||
"responsePostprocess": { "mode": "text" },
|
||||
"hostContext": { "signal": {} }
|
||||
});
|
||||
let error = super::llm_compile_execution_plan(value).unwrap_err();
|
||||
assert!(error.reason.contains("does not match schema"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use napi::Result;
|
||||
|
||||
use crate::llm::core::contracts::{
|
||||
CapabilityMatchRequest, CapabilityMatchResponse, RequestedModelMatchRequest, RequestedModelMatchResponse,
|
||||
};
|
||||
use crate::llm::core::contracts::{CapabilityMatchRequest, CapabilityMatchResponse};
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_match_model_capabilities(payload: CapabilityMatchRequest) -> Result<CapabilityMatchResponse> {
|
||||
@@ -14,25 +12,7 @@ pub fn llm_match_model_capabilities(payload: CapabilityMatchRequest) -> Result<C
|
||||
.map_err(crate::llm::map_json_error)?;
|
||||
|
||||
Ok(CapabilityMatchResponse {
|
||||
model_id: llm_adapter::core::select_model_id(&models, &cond).map_err(crate::llm::host::invalid_arg)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_resolve_requested_model_match(payload: RequestedModelMatchRequest) -> Result<RequestedModelMatchResponse> {
|
||||
let matched_optional_model = llm_adapter::core::matches_requested_model_list(
|
||||
&payload.provider_ids,
|
||||
&payload.optional_models,
|
||||
payload.requested_model_id.as_deref(),
|
||||
);
|
||||
|
||||
Ok(RequestedModelMatchResponse {
|
||||
selected_model: if matched_optional_model {
|
||||
payload.requested_model_id
|
||||
} else {
|
||||
payload.default_model
|
||||
},
|
||||
matched_optional_model,
|
||||
model_id: llm_adapter::core::select_model_id(&models, &cond).map_err(crate::llm::invalid_arg)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,6 @@ use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptRenderContract {
|
||||
pub messages: Vec<PromptMessageContract>,
|
||||
#[napi(ts_type = "Record<string, any>")]
|
||||
pub template_params: Value,
|
||||
#[napi(ts_type = "Record<string, any>")]
|
||||
pub render_params: Value,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
pub struct PromptRenderResult {
|
||||
@@ -35,66 +24,13 @@ pub struct BuiltInPromptRenderContract {
|
||||
pub render_params: Value,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
pub struct PromptTokenCountContract {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
pub messages: Vec<PromptCountMessage>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
pub struct PromptTokenCountResult {
|
||||
pub tokens: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
pub struct PromptCountMessage {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct PromptMetadataContract {
|
||||
pub messages: Vec<PromptMessageContract>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptMetadataResult {
|
||||
pub param_keys: Vec<String>,
|
||||
#[napi(ts_type = "Record<string, any>")]
|
||||
pub template_params: Value,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptSessionContract {
|
||||
pub prompt: PromptSessionPrompt,
|
||||
pub turns: Vec<PromptMessageContract>,
|
||||
#[napi(ts_type = "Record<string, any>")]
|
||||
pub render_params: Value,
|
||||
pub max_token_size: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptSessionPrompt {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
pub prompt_tokens: u32,
|
||||
#[napi(ts_type = "Record<string, any>")]
|
||||
pub template_params: Value,
|
||||
pub messages: Vec<PromptMessageContract>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -112,7 +48,6 @@ pub struct BuiltInPromptSessionContract {
|
||||
pub turns: Vec<PromptMessageContract>,
|
||||
#[napi(ts_type = "Record<string, any>")]
|
||||
pub render_params: Value,
|
||||
pub max_token_size: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
@@ -289,29 +224,6 @@ pub struct CapabilityMatchResponse {
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RequestedModelMatchRequest {
|
||||
pub provider_ids: Vec<String>,
|
||||
pub optional_models: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requested_model_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RequestedModelMatchResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selected_model: Option<String>,
|
||||
pub matched_optional_model: bool,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -636,8 +548,6 @@ pub struct LlmImageRequestContract {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LlmImageRequestBuildContract {
|
||||
pub model: String,
|
||||
#[napi(ts_type = "'openai_chat' | 'openai_responses' | 'openai_images' | 'anthropic' | 'gemini' | 'fal_image'")]
|
||||
pub protocol: String,
|
||||
pub messages: Vec<PromptMessageContract>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<Value>,
|
||||
@@ -647,47 +557,7 @@ pub struct LlmImageRequestBuildContract {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{CapabilityMatchRequest, PromptRenderContract, PromptSessionContract, ProviderDriverSpec};
|
||||
|
||||
#[test]
|
||||
fn should_roundtrip_prompt_contracts() {
|
||||
let render_value = json!({
|
||||
"messages": [{
|
||||
"role": "system",
|
||||
"content": "summarize",
|
||||
"responseFormat": {
|
||||
"type": "json_schema",
|
||||
"responseSchemaJson": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": { "type": "string" }
|
||||
},
|
||||
"required": ["summary"]
|
||||
},
|
||||
"schemaHash": "abc123"
|
||||
}
|
||||
}],
|
||||
"templateParams": { "tone": "short" },
|
||||
"renderParams": { "topic": "docs" }
|
||||
});
|
||||
let session_value = json!({
|
||||
"prompt": {
|
||||
"model": "gpt-5-mini",
|
||||
"promptTokens": 12,
|
||||
"templateParams": {},
|
||||
"messages": [{ "role": "system", "content": "summarize" }]
|
||||
},
|
||||
"turns": [{ "role": "user", "content": "hello" }],
|
||||
"renderParams": { "tone": "short" },
|
||||
"maxTokenSize": 1024
|
||||
});
|
||||
|
||||
let render_contract: PromptRenderContract = serde_json::from_value(render_value.clone()).unwrap();
|
||||
let session_contract: PromptSessionContract = serde_json::from_value(session_value.clone()).unwrap();
|
||||
|
||||
assert_eq!(serde_json::to_value(render_contract).unwrap(), render_value);
|
||||
assert_eq!(serde_json::to_value(session_contract).unwrap(), session_value);
|
||||
}
|
||||
use super::{CapabilityMatchRequest, ProviderDriverSpec};
|
||||
|
||||
#[test]
|
||||
fn should_roundtrip_tool_and_runtime_contracts() {
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn llm_resolve_model_registry_variant(
|
||||
request.backend_kind.as_deref(),
|
||||
request.model_id.as_str(),
|
||||
)
|
||||
.map_err(crate::llm::host::invalid_arg)?
|
||||
.map_err(crate::llm::invalid_arg)?
|
||||
{
|
||||
Some((variant, matched_by)) => ModelRegistryResolveResponse {
|
||||
variant: Some(to_contract_variant(variant)?),
|
||||
@@ -44,7 +44,7 @@ pub fn llm_match_model_registry(request: ModelRegistryMatchRequest) -> Result<Mo
|
||||
.map_err(crate::llm::map_json_error)?;
|
||||
let response = ModelRegistryMatchResponse {
|
||||
variant: llm_adapter::core::select_model_registry_variant(&variants, request.backend_kind.as_str(), &cond)
|
||||
.map_err(crate::llm::host::invalid_arg)?
|
||||
.map_err(crate::llm::invalid_arg)?
|
||||
.map(to_contract_variant)
|
||||
.transpose()?,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use napi::{Error, Result, Status};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{
|
||||
llm::{
|
||||
core::contracts::{
|
||||
BuiltInPromptRenderContract, BuiltInPromptSessionContract, PromptMessageContract, PromptMetadataContract,
|
||||
PromptMetadataResult, PromptRenderContract, PromptRenderResult, PromptSessionContract, PromptSessionPrompt,
|
||||
PromptSessionResult, PromptTokenCountContract, PromptTokenCountResult,
|
||||
},
|
||||
prompt_catalog::{BuiltInPrompt, BuiltInPromptSpec, built_in_prompt, built_in_prompt_spec, built_in_prompt_specs},
|
||||
use crate::llm::{
|
||||
core::contracts::{
|
||||
BuiltInPromptRenderContract, BuiltInPromptSessionContract, PromptMessageContract, PromptMetadataResult,
|
||||
PromptRenderResult, PromptSessionResult,
|
||||
},
|
||||
tiktoken::{Tokenizer, from_model_name},
|
||||
prompt_catalog::{BuiltInPrompt, BuiltInPromptSpec, built_in_prompt, built_in_prompt_spec, built_in_prompt_specs},
|
||||
};
|
||||
|
||||
mod metadata;
|
||||
@@ -51,57 +47,6 @@ fn built_in_prompt_metadata(prompt: &BuiltInPrompt) -> Result<PromptMetadataResu
|
||||
.map_err(|error| invalid_arg(format!("Failed to collect built-in prompt metadata: {error}")))
|
||||
}
|
||||
|
||||
fn count_prompt_tokens(model: Option<&str>, messages: &[PromptMessageContract]) -> u32 {
|
||||
let content = messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.collect::<String>();
|
||||
prompt_tokenizer(model)
|
||||
.map(|tokenizer| tokenizer.count(content, None))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn prompt_tokenizer(model: Option<&str>) -> Option<Tokenizer> {
|
||||
let model = model?;
|
||||
if model.starts_with("gpt") {
|
||||
return from_model_name(model.to_string());
|
||||
}
|
||||
if model.starts_with("dall") {
|
||||
return None;
|
||||
}
|
||||
|
||||
from_model_name("gpt-4".to_string())
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_render_prompt(request: PromptRenderContract) -> Result<PromptRenderResult> {
|
||||
let response = render_prompt_response(
|
||||
&request.messages,
|
||||
&value_to_map(request.template_params, "templateParams")?,
|
||||
&value_to_map(request.render_params, "renderParams")?,
|
||||
)
|
||||
.map_err(|error| invalid_arg(format!("Failed to render prompt: {error}")))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_count_prompt_tokens(request: PromptTokenCountContract) -> Result<PromptTokenCountResult> {
|
||||
let content = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.collect::<String>();
|
||||
let tokens = request
|
||||
.model
|
||||
.as_deref()
|
||||
.and_then(|model| prompt_tokenizer(Some(model)))
|
||||
.map(|tokenizer| tokenizer.count(content, None))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(PromptTokenCountResult { tokens })
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_render_built_in_prompt(request: BuiltInPromptRenderContract) -> Result<PromptRenderResult> {
|
||||
let prompt = built_in_prompt(&request.name)
|
||||
@@ -118,46 +63,22 @@ pub fn llm_render_built_in_prompt(request: BuiltInPromptRenderContract) -> Resul
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_collect_prompt_metadata(request: PromptMetadataContract) -> Result<PromptMetadataResult> {
|
||||
let response = collect_prompt_metadata(&request.messages)
|
||||
.map_err(|error| invalid_arg(format!("Failed to collect prompt metadata: {error}")))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_render_session_prompt(request: PromptSessionContract) -> Result<PromptSessionResult> {
|
||||
let template_params = value_to_map(request.prompt.template_params.clone(), "prompt.templateParams")?;
|
||||
let render_params = value_to_map(request.render_params.clone(), "renderParams")?;
|
||||
let response = render_session_prompt(&request, &template_params, &render_params)
|
||||
.map_err(|error| invalid_arg(format!("Failed to render session prompt: {error}")))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_render_built_in_session_prompt(request: BuiltInPromptSessionContract) -> Result<PromptSessionResult> {
|
||||
let prompt = built_in_prompt(&request.name)
|
||||
.ok_or_else(|| invalid_arg(format!("Built-in prompt not found: {}", request.name)))?;
|
||||
let messages = built_in_prompt_messages(prompt);
|
||||
let metadata = built_in_prompt_metadata(prompt)?;
|
||||
let session_contract = PromptSessionContract {
|
||||
prompt: PromptSessionPrompt {
|
||||
action: prompt.action.clone(),
|
||||
model: Some(prompt.model.clone()),
|
||||
prompt_tokens: count_prompt_tokens(Some(prompt.model.as_str()), &messages),
|
||||
template_params: metadata.template_params,
|
||||
messages,
|
||||
},
|
||||
turns: request.turns,
|
||||
render_params: request.render_params,
|
||||
max_token_size: request.max_token_size,
|
||||
};
|
||||
let template_params = value_to_map(session_contract.prompt.template_params.clone(), "prompt.templateParams")?;
|
||||
let render_params = value_to_map(session_contract.render_params.clone(), "renderParams")?;
|
||||
let response = render_session_prompt(&session_contract, &template_params, &render_params)
|
||||
.map_err(|error| invalid_arg(format!("Failed to render built-in session prompt: {error}")))?;
|
||||
let template_params = value_to_map(metadata.template_params, "prompt.templateParams")?;
|
||||
let render_params = value_to_map(request.render_params, "renderParams")?;
|
||||
let response = render_session_prompt(
|
||||
&messages,
|
||||
prompt.action.as_deref(),
|
||||
&request.turns,
|
||||
&template_params,
|
||||
&render_params,
|
||||
)
|
||||
.map_err(|error| invalid_arg(format!("Failed to render built-in session prompt: {error}")))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -171,274 +92,3 @@ pub fn llm_list_built_in_prompt_specs() -> Result<Vec<BuiltInPromptSpec>> {
|
||||
pub fn llm_get_built_in_prompt_spec(name: String) -> Result<Option<BuiltInPromptSpec>> {
|
||||
Ok(built_in_prompt_spec(&name).cloned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use llm_adapter::core::prompt_template::{is_truthy_number, parse_template, render_tokens};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{llm_collect_prompt_metadata, llm_count_prompt_tokens, llm_render_prompt, llm_render_session_prompt};
|
||||
use crate::llm::core::contracts::{
|
||||
PromptMetadataContract, PromptRenderContract, PromptSessionContract, PromptTokenCountContract,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn should_render_sections_and_current_item() {
|
||||
let tokens = parse_template("{{#links}}- {{.}}\n{{/links}}").unwrap();
|
||||
let rendered = render_tokens(
|
||||
&tokens,
|
||||
&[&json!({
|
||||
"links": ["https://affine.pro", "https://github.com/toeverything/affine"]
|
||||
})],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"- https://affine.pro\n- https://github.com/toeverything/affine\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_render_prompt_with_normalized_params_and_attachments() {
|
||||
let response = llm_render_prompt(
|
||||
serde_json::from_value::<PromptRenderContract>(json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "tone={{tone}}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{{content}}"
|
||||
}
|
||||
],
|
||||
"templateParams": { "tone": ["formal", "casual"] },
|
||||
"renderParams": {
|
||||
"attachments": ["https://affine.pro/example.jpg"],
|
||||
"content": "hello world"
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "tone=formal",
|
||||
"params": {
|
||||
"attachments": ["https://affine.pro/example.jpg"],
|
||||
"content": "hello world",
|
||||
"tone": "formal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hello world",
|
||||
"attachments": ["https://affine.pro/example.jpg"],
|
||||
"params": {
|
||||
"attachments": ["https://affine.pro/example.jpg"],
|
||||
"content": "hello world",
|
||||
"tone": "formal"
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": ["Missing param value: tone, use default options: formal"]
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_render_host_builtins_and_js_like_variable_strings() {
|
||||
let response = llm_render_prompt(
|
||||
serde_json::from_value::<PromptRenderContract>(json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "{{affine::language}}|{{tags}}|{{obj}}|{{#links}}- {{.}}\n{{/links}}"
|
||||
}
|
||||
],
|
||||
"templateParams": {},
|
||||
"renderParams": {
|
||||
"language": "French",
|
||||
"affine::language": "ignored",
|
||||
"links": ["https://affine.pro", "https://github.com/toeverything/affine"],
|
||||
"obj": { "hello": "world" },
|
||||
"tags": ["a", "b"]
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "French|a,b|[object Object]|- https://affine.pro\n- https://github.com/toeverything/affine\n",
|
||||
"params": {
|
||||
"language": "French",
|
||||
"affine::language": "ignored",
|
||||
"links": ["https://affine.pro", "https://github.com/toeverything/affine"],
|
||||
"obj": { "hello": "world" },
|
||||
"tags": ["a", "b"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_count_prompt_tokens_for_unknown_models_as_zero() {
|
||||
let response = llm_count_prompt_tokens(
|
||||
serde_json::from_value::<PromptTokenCountContract>(json!({
|
||||
"model": null,
|
||||
"messages": [{ "content": "hello" }]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(response, json!({ "tokens": 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_count_prompt_tokens_for_non_gpt_models_with_fallback_tokenizer() {
|
||||
let response = llm_count_prompt_tokens(
|
||||
serde_json::from_value::<PromptTokenCountContract>(json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{ "content": "hello" }]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(response.tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_follow_js_truthiness_for_numbers() {
|
||||
assert!(!is_truthy_number(&serde_json::Number::from(0)));
|
||||
assert!(is_truthy_number(&serde_json::Number::from(1)));
|
||||
assert!(is_truthy_number(&serde_json::Number::from_f64(0.5).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_render_session_prompt_by_merging_latest_user_content() {
|
||||
let response = llm_render_session_prompt(
|
||||
serde_json::from_value::<PromptSessionContract>(json!({
|
||||
"prompt": {
|
||||
"model": "test",
|
||||
"promptTokens": 0,
|
||||
"templateParams": {},
|
||||
"messages": [
|
||||
{ "role": "system", "content": "answer briefly" },
|
||||
{ "role": "user", "content": "{{content}}" }
|
||||
]
|
||||
},
|
||||
"turns": [
|
||||
{ "role": "user", "content": "hello", "attachments": ["https://affine.pro/hello.png"] }
|
||||
],
|
||||
"renderParams": {},
|
||||
"maxTokenSize": 1000
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"messages": [
|
||||
{ "role": "system", "content": "answer briefly", "params": { "content": "hello" } },
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hello",
|
||||
"attachments": ["https://affine.pro/hello.png"],
|
||||
"params": { "content": "hello" }
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"promptMessagePositions": [0, 1]
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_render_session_prompt_by_picking_recent_turns_under_budget() {
|
||||
let response = llm_render_session_prompt(
|
||||
serde_json::from_value::<PromptSessionContract>(json!({
|
||||
"prompt": {
|
||||
"model": "test",
|
||||
"promptTokens": 0,
|
||||
"templateParams": {},
|
||||
"messages": [
|
||||
{ "role": "system", "content": "hello {{word}}" }
|
||||
]
|
||||
},
|
||||
"turns": [
|
||||
{ "role": "user", "content": "older turn" }
|
||||
],
|
||||
"renderParams": { "word": "world" },
|
||||
"maxTokenSize": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"messages": [
|
||||
{ "role": "system", "content": "hello world", "params": { "word": "world" } }
|
||||
],
|
||||
"warnings": [],
|
||||
"promptMessagePositions": [0]
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_collect_prompt_metadata_from_templates_and_params() {
|
||||
let response = llm_collect_prompt_metadata(
|
||||
serde_json::from_value::<PromptMetadataContract>(json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "tone={{tone}}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{{content}}",
|
||||
"params": { "tone": ["formal", "casual"] }
|
||||
}
|
||||
]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({
|
||||
"paramKeys": ["tone", "content"],
|
||||
"templateParams": {
|
||||
"tone": ["formal", "casual"]
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,3 +156,68 @@ fn render_prompt_message(
|
||||
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn message(role: &str, content: &str) -> PromptMessageContract {
|
||||
serde_json::from_value(json!({ "role": role, "content": content })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_lists_and_normalizes_missing_or_invalid_params_to_defaults() {
|
||||
let messages = vec![
|
||||
message("system", "translate {{src}} to {{dest}}: {{content}}"),
|
||||
message("user", "links:\n{{#links}}- {{.}}\n{{/links}}"),
|
||||
];
|
||||
let template_params = serde_json::from_value(json!({
|
||||
"src": ["eng"],
|
||||
"dest": ["chs", "jpn"]
|
||||
}))
|
||||
.unwrap();
|
||||
let params = serde_json::from_value(json!({
|
||||
"src": "invalid",
|
||||
"content": "hello",
|
||||
"links": ["https://affine.pro", "https://github.com/toeverything/AFFiNE"]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let rendered = render_prompt_response(&messages, &template_params, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(rendered.messages[0].content, "translate eng to chs: hello");
|
||||
assert_eq!(
|
||||
rendered.messages[1].content,
|
||||
"links:\n- https://affine.pro\n- https://github.com/toeverything/AFFiNE\n"
|
||||
);
|
||||
assert_eq!(rendered.warnings.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_input_attachments_only_to_user_messages() {
|
||||
let messages = vec![message("system", "system"), message("user", "{{content}}")];
|
||||
let params = serde_json::from_value(json!({
|
||||
"content": "summarize",
|
||||
"attachments": [{
|
||||
"kind": "file_handle",
|
||||
"fileHandle": "file-1",
|
||||
"mimeType": "application/pdf"
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let rendered = render_prompt_response(&messages, &Map::new(), ¶ms).unwrap();
|
||||
|
||||
assert!(rendered.messages[0].attachments.is_none());
|
||||
assert_eq!(
|
||||
rendered.messages[1].attachments,
|
||||
Some(vec![json!({
|
||||
"kind": "file_handle",
|
||||
"fileHandle": "file-1",
|
||||
"mimeType": "application/pdf"
|
||||
})])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,45 @@ use llm_adapter::core::prompt_template::{parse_template, template_uses_key};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::{
|
||||
super::contracts::{PromptMessageContract, PromptSessionContract, PromptSessionResult},
|
||||
super::contracts::{PromptMessageContract, PromptSessionResult},
|
||||
render::render_prompt_response,
|
||||
};
|
||||
use crate::tiktoken::{Tokenizer, from_model_name};
|
||||
|
||||
const DEFAULT_HISTORY_INPUT_BYTES: usize = 128 * 1024;
|
||||
const MESSAGE_FRAMING_BYTES: usize = 16;
|
||||
|
||||
pub(super) fn render_session_prompt(
|
||||
request: &PromptSessionContract,
|
||||
prompt_messages: &[PromptMessageContract],
|
||||
action: Option<&str>,
|
||||
turns: &[PromptMessageContract],
|
||||
template_params: &Map<String, Value>,
|
||||
params: &Map<String, Value>,
|
||||
) -> std::result::Result<PromptSessionResult, String> {
|
||||
let tokenizer = session_tokenizer(request.prompt.model.as_deref());
|
||||
let mut selected_turns = take_session_turns(request, tokenizer.as_ref())?;
|
||||
let latest_turn = selected_turns.pop();
|
||||
render_session_prompt_with_budget(
|
||||
prompt_messages,
|
||||
action,
|
||||
turns,
|
||||
template_params,
|
||||
params,
|
||||
DEFAULT_HISTORY_INPUT_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
if prompt_uses_content(&request.prompt.messages)?
|
||||
&& !selected_turns.iter().any(message_is_assistant)
|
||||
fn render_session_prompt_with_budget(
|
||||
prompt_messages: &[PromptMessageContract],
|
||||
action: Option<&str>,
|
||||
turns: &[PromptMessageContract],
|
||||
template_params: &Map<String, Value>,
|
||||
params: &Map<String, Value>,
|
||||
history_input_bytes: usize,
|
||||
) -> std::result::Result<PromptSessionResult, String> {
|
||||
let (prior_turns, latest_turn) = turns
|
||||
.split_last()
|
||||
.map(|(latest, prior)| (prior, Some(latest.clone())))
|
||||
.unwrap_or((&[], None));
|
||||
|
||||
if prompt_uses_content(prompt_messages)?
|
||||
&& !prior_turns.iter().any(message_is_assistant)
|
||||
&& let Some(last_message) = latest_turn
|
||||
.as_ref()
|
||||
.filter(|message| message_role(message) == Some("user"))
|
||||
@@ -29,16 +52,17 @@ pub(super) fn render_session_prompt(
|
||||
}
|
||||
merged_params.insert("content".to_string(), Value::String(last_message.content.clone()));
|
||||
|
||||
let rendered = render_prompt_response(&request.prompt.messages, template_params, &merged_params)?;
|
||||
let rendered = render_prompt_response(prompt_messages, template_params, &merged_params)?;
|
||||
let mut messages = rendered.messages;
|
||||
let Some(first_user_message_index) = messages
|
||||
.iter()
|
||||
.position(|message| message_role(message) == Some("user"))
|
||||
else {
|
||||
ensure_messages_fit(&messages, &[], history_input_bytes)?;
|
||||
return Ok(PromptSessionResult {
|
||||
messages,
|
||||
warnings: rendered.warnings,
|
||||
prompt_message_positions: (0..request.prompt.messages.len()).map(|index| index as u32).collect(),
|
||||
prompt_message_positions: (0..prompt_messages.len()).map(|index| index as u32).collect(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -57,9 +81,10 @@ pub(super) fn render_session_prompt(
|
||||
messages[first_user_message_index].attachments = Some(merged_attachments);
|
||||
}
|
||||
|
||||
let selected_turns = select_history_turns(&messages, prior_turns, history_input_bytes)?;
|
||||
let prior_turn_count = selected_turns.len();
|
||||
messages.splice(first_user_message_index..first_user_message_index, selected_turns);
|
||||
let prompt_message_positions = (0..request.prompt.messages.len())
|
||||
let prompt_message_positions = (0..prompt_messages.len())
|
||||
.map(|index| {
|
||||
if index < first_user_message_index {
|
||||
index as u32
|
||||
@@ -81,52 +106,63 @@ pub(super) fn render_session_prompt(
|
||||
} else {
|
||||
latest_turn.as_ref().map(message_params).unwrap_or_default()
|
||||
};
|
||||
let rendered = render_prompt_response(&request.prompt.messages, template_params, &final_params)?;
|
||||
let rendered = render_prompt_response(prompt_messages, template_params, &final_params)?;
|
||||
|
||||
let trailing_turns = selected_turns
|
||||
let latest_turns = latest_turn
|
||||
.into_iter()
|
||||
.chain(latest_turn)
|
||||
.filter(prompt_message_should_survive)
|
||||
.collect::<Vec<_>>();
|
||||
let mut messages = rendered.messages;
|
||||
messages.extend(trailing_turns);
|
||||
let selected_turns = if action.is_some() {
|
||||
ensure_messages_fit(&messages, &latest_turns, history_input_bytes)?;
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut fixed = messages.clone();
|
||||
fixed.extend(latest_turns.clone());
|
||||
select_history_turns(&fixed, prior_turns, history_input_bytes)?
|
||||
};
|
||||
messages.extend(selected_turns);
|
||||
messages.extend(latest_turns);
|
||||
|
||||
Ok(PromptSessionResult {
|
||||
messages,
|
||||
warnings: rendered.warnings,
|
||||
prompt_message_positions: (0..request.prompt.messages.len()).map(|index| index as u32).collect(),
|
||||
prompt_message_positions: (0..prompt_messages.len()).map(|index| index as u32).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn session_tokenizer(model: Option<&str>) -> Option<Tokenizer> {
|
||||
let model = model?;
|
||||
if model.starts_with("gpt") {
|
||||
return from_model_name(model.to_string());
|
||||
fn estimated_message_bytes(message: &PromptMessageContract) -> usize {
|
||||
let mut size = MESSAGE_FRAMING_BYTES
|
||||
.saturating_add(message.role.len())
|
||||
.saturating_add(message.content.len());
|
||||
for value in [
|
||||
message.attachments.as_ref().map(serde_json::to_vec),
|
||||
message.params.as_ref().map(serde_json::to_vec),
|
||||
message.response_format.as_ref().map(serde_json::to_vec),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
size = size.saturating_add(value.map(|bytes| bytes.len()).unwrap_or(usize::MAX));
|
||||
}
|
||||
if model.starts_with("dall") {
|
||||
return None;
|
||||
}
|
||||
|
||||
from_model_name("gpt-4".to_string())
|
||||
size
|
||||
}
|
||||
|
||||
fn take_session_turns(
|
||||
request: &PromptSessionContract,
|
||||
tokenizer: Option<&Tokenizer>,
|
||||
fn select_history_turns(
|
||||
fixed_messages: &[PromptMessageContract],
|
||||
history: &[PromptMessageContract],
|
||||
history_input_bytes: usize,
|
||||
) -> std::result::Result<Vec<PromptMessageContract>, String> {
|
||||
if request.prompt.action.is_some() {
|
||||
return Ok(request.turns.last().cloned().into_iter().collect());
|
||||
let mut size = fixed_messages.iter().fold(0usize, |size, message| {
|
||||
size.saturating_add(estimated_message_bytes(message))
|
||||
});
|
||||
if size > history_input_bytes {
|
||||
return Err("session input exceeds history byte budget".to_string());
|
||||
}
|
||||
|
||||
let mut picked = Vec::new();
|
||||
let mut size = request.prompt.prompt_tokens;
|
||||
|
||||
for message in request.turns.iter().rev() {
|
||||
let content = message.content.as_str();
|
||||
size += tokenizer
|
||||
.map(|tokenizer| tokenizer.count(content.to_string(), None))
|
||||
.unwrap_or(0);
|
||||
if size > request.max_token_size {
|
||||
for message in history.iter().rev() {
|
||||
size = size.saturating_add(estimated_message_bytes(message));
|
||||
if size > history_input_bytes {
|
||||
break;
|
||||
}
|
||||
picked.push(message.clone());
|
||||
@@ -136,6 +172,16 @@ fn take_session_turns(
|
||||
Ok(picked)
|
||||
}
|
||||
|
||||
fn ensure_messages_fit(
|
||||
prompt_messages: &[PromptMessageContract],
|
||||
latest_turns: &[PromptMessageContract],
|
||||
history_input_bytes: usize,
|
||||
) -> std::result::Result<(), String> {
|
||||
let mut messages = prompt_messages.to_vec();
|
||||
messages.extend_from_slice(latest_turns);
|
||||
select_history_turns(&messages, &[], history_input_bytes).map(|_| ())
|
||||
}
|
||||
|
||||
fn prompt_uses_content(messages: &[PromptMessageContract]) -> std::result::Result<bool, String> {
|
||||
for message in messages {
|
||||
if template_uses_key(&parse_template(&message.content)?, "content") {
|
||||
@@ -202,3 +248,102 @@ fn attachment_has_source(attachment: &Value) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn message(role: &str, content: &str) -> PromptMessageContract {
|
||||
serde_json::from_value(json!({ "role": role, "content": content })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_estimate_is_model_independent_and_utf8_aware() {
|
||||
let ascii = estimated_message_bytes(&message("user", "abc"));
|
||||
let cjk = estimated_message_bytes(&message("user", "中文文"));
|
||||
let emoji = estimated_message_bytes(&message("user", "😀😀😀"));
|
||||
assert!(ascii < cjk);
|
||||
assert!(cjk < emoji);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_latest_turn_and_only_a_contiguous_history_suffix() {
|
||||
let prompt = vec![message("system", "system")];
|
||||
let turns = vec![
|
||||
message("user", "old"),
|
||||
message("assistant", "recent"),
|
||||
message("user", "latest"),
|
||||
];
|
||||
let fixed_bytes = estimated_message_bytes(&prompt[0]) + estimated_message_bytes(&turns[2]);
|
||||
let budget = fixed_bytes + estimated_message_bytes(&turns[1]) + 8;
|
||||
let result = render_session_prompt_with_budget(&prompt, None, &turns, &Map::new(), &Map::new(), budget).unwrap();
|
||||
assert_eq!(
|
||||
result
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["system", "recent", "latest"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_rendered_prompt_or_latest_turn() {
|
||||
let prompt = vec![message("system", "{{content}}")];
|
||||
let turns = vec![message("user", "large input")];
|
||||
let error = render_session_prompt_with_budget(&prompt, None, &turns, &Map::new(), &Map::new(), 1).unwrap_err();
|
||||
assert_eq!(error, "session input exceeds history byte budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_latest_user_content_params_and_file_handles_into_prompt() {
|
||||
let prompt = vec![message("user", "{{content}} {{tone}}")];
|
||||
let latest = serde_json::from_value(json!({
|
||||
"role": "user",
|
||||
"content": "Summarize this file",
|
||||
"attachments": [{
|
||||
"kind": "file_handle",
|
||||
"fileHandle": "file-1",
|
||||
"mimeType": "application/pdf"
|
||||
}],
|
||||
"params": { "tone": "brief" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let result = render_session_prompt(&prompt, None, &[latest], &Map::new(), &Map::new()).unwrap();
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(result.messages[0].content, "Summarize this file brief");
|
||||
assert_eq!(
|
||||
result.messages[0].attachments,
|
||||
Some(vec![json!({
|
||||
"kind": "file_handle",
|
||||
"fileHandle": "file-1",
|
||||
"mimeType": "application/pdf"
|
||||
})])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_prompt_drops_prior_history_but_keeps_latest_turn() {
|
||||
let prompt = vec![message("system", "action")];
|
||||
let turns = vec![
|
||||
message("user", "old"),
|
||||
message("assistant", "old answer"),
|
||||
message("user", "latest"),
|
||||
];
|
||||
|
||||
let result = render_session_prompt(&prompt, Some("edit"), &turns, &Map::new(), &Map::new()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["action", "latest"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use llm_adapter::core::{self as adapter_core, EmbeddingRequest, ImageInput, ImageRequest, RerankRequest};
|
||||
use llm_adapter::core::{self as adapter_core, EmbeddingRequest, ImageRequest, RerankRequest};
|
||||
use napi::Result;
|
||||
use napi_derive::napi;
|
||||
use serde::Serialize;
|
||||
@@ -8,7 +8,7 @@ use super::contracts::{
|
||||
LlmImageRequestBuildContract, LlmImageRequestContract, LlmRequestContract, LlmRerankRequestContract,
|
||||
LlmStructuredRequestContract, ModelConditionsContract, PromptMessageContract,
|
||||
};
|
||||
use crate::llm::{LlmDispatchPayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload, host::invalid_arg};
|
||||
use crate::llm::{LlmDispatchPayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload, invalid_arg};
|
||||
|
||||
mod types;
|
||||
|
||||
@@ -62,38 +62,11 @@ pub(crate) fn build_image_request(request: ImageRequest) -> Result<ImageRequest>
|
||||
}
|
||||
|
||||
pub(crate) fn build_image_request_from_messages(request: LlmImageRequestBuildContract) -> Result<ImageRequest> {
|
||||
let protocol = request.protocol.clone();
|
||||
let mut request =
|
||||
let request =
|
||||
adapter_core::build_image_request_from_prompt_messages(to_adapter(&request)?).map_err(map_builder_error)?;
|
||||
if protocol == "fal_image" {
|
||||
keep_fal_data_uri_inputs_as_urls(&mut request);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn keep_fal_data_uri_inputs_as_urls(request: &mut ImageRequest) {
|
||||
let ImageRequest::Edit(edit) = request else {
|
||||
return;
|
||||
};
|
||||
|
||||
for image in &mut edit.images {
|
||||
let replacement = match image {
|
||||
ImageInput::Data {
|
||||
data_base64,
|
||||
media_type,
|
||||
..
|
||||
} => Some(ImageInput::Url {
|
||||
url: format!("data:{media_type};base64,{data_base64}"),
|
||||
media_type: Some(media_type.clone()),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(replacement) = replacement {
|
||||
*image = replacement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn infer_prompt_model_conditions(messages: Vec<PromptMessageInput>) -> Result<ModelConditionsContract> {
|
||||
let messages = adapter_core::canonicalize_prompt_messages(to_adapter_prompt_messages(messages)?);
|
||||
serde_json::to_value(adapter_core::infer_model_conditions_from_prompt_messages(messages))
|
||||
@@ -471,11 +444,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_keep_fal_data_uri_image_inputs_as_urls() {
|
||||
fn should_canonicalize_data_uri_image_inputs() {
|
||||
let response = llm_build_image_request_from_messages(
|
||||
serde_json::from_value(json!({
|
||||
"model": "lora/image-to-image",
|
||||
"protocol": "fal_image",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "restyle",
|
||||
@@ -493,11 +465,7 @@ mod tests {
|
||||
let response = serde_json::to_value(response).unwrap();
|
||||
assert_eq!(
|
||||
response.pointer("/images/0"),
|
||||
Some(&json!({
|
||||
"kind": "url",
|
||||
"url": "data:image/png;base64,aW1n",
|
||||
"media_type": "image/png"
|
||||
}))
|
||||
Some(&json!({ "kind": "data", "data_base64": "aW1n", "media_type": "image/png" }))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::super::contracts::{
|
||||
LlmRerankRequestContract, LlmStructuredRequestContract, RerankCandidate as ContractRerankCandidate, ToolContract,
|
||||
};
|
||||
use crate::llm::{
|
||||
LlmDispatchPayload, LlmMiddlewarePayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload, host::invalid_arg,
|
||||
LlmDispatchPayload, LlmMiddlewarePayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload, invalid_arg,
|
||||
map_json_error,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
mod dispatch;
|
||||
mod middleware;
|
||||
mod payload;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use dispatch::AsyncLlmDispatchPreparedTask;
|
||||
pub(crate) use dispatch::{
|
||||
dispatch_prepared_image_route_payloads, dispatch_prepared_structured_routes,
|
||||
parse_prepared_chat_routes_with_middleware, parse_prepared_chat_routes_without_middleware,
|
||||
};
|
||||
pub use dispatch::{
|
||||
llm_dispatch_prepared, llm_embedding_dispatch, llm_embedding_dispatch_prepared, llm_image_dispatch_prepared,
|
||||
llm_plan_attachment_reference, llm_rerank_dispatch, llm_rerank_dispatch_prepared, llm_resolve_request_intent,
|
||||
llm_structured_dispatch, llm_structured_dispatch_prepared,
|
||||
};
|
||||
pub(crate) use llm_adapter::middleware::StreamPipeline;
|
||||
#[cfg(test)]
|
||||
pub(crate) use middleware::resolve_request_chain;
|
||||
pub(crate) use middleware::{
|
||||
apply_request_middlewares, apply_structured_request_middlewares, backend_transport_error, map_backend_error,
|
||||
map_json_error, parse_embedding_protocol, parse_protocol, parse_rerank_protocol, parse_structured_protocol,
|
||||
resolve_stream_chain,
|
||||
};
|
||||
pub(crate) use payload::{
|
||||
LlmDispatchPayload, LlmEmbeddingDispatchPayload, LlmMiddlewarePayload, LlmPreparedImageDispatchRoutePayload,
|
||||
LlmRerankDispatchPayload, LlmRoutedBackendPayload, LlmStructuredDispatchPayload,
|
||||
LlmDispatchPayload, LlmMiddlewarePayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use llm_adapter::{
|
||||
backend::BackendConfig,
|
||||
core::{CoreRequest, EmbeddingRequest, RerankRequest, StructuredRequest},
|
||||
core::{CoreRequest, RerankRequest, StructuredRequest},
|
||||
middleware::MiddlewareConfig,
|
||||
router::SerializablePreparedRoute,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm::core::contracts::{
|
||||
LlmEmbeddingRequestContract, LlmImageRequestContract, LlmRequestContract, LlmRerankRequestContract,
|
||||
LlmStructuredRequestContract,
|
||||
};
|
||||
use crate::llm::core::contracts::{LlmRequestContract, LlmRerankRequestContract, LlmStructuredRequestContract};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
@@ -41,15 +36,6 @@ pub(crate) struct LlmDispatchPayload {
|
||||
pub(crate) middleware: LlmMiddlewarePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct LlmRoutedBackendPayload {
|
||||
pub(crate) provider_id: String,
|
||||
pub(crate) protocol: String,
|
||||
pub(crate) model: String,
|
||||
#[serde(alias = "backendConfig")]
|
||||
pub(crate) config: BackendConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(try_from = "LlmStructuredRequestContract")]
|
||||
pub(crate) struct LlmStructuredDispatchPayload {
|
||||
@@ -59,20 +45,6 @@ pub(crate) struct LlmStructuredDispatchPayload {
|
||||
pub(crate) middleware: LlmMiddlewarePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(from = "LlmEmbeddingRequestContract")]
|
||||
pub(crate) struct LlmEmbeddingDispatchPayload {
|
||||
pub(crate) request: EmbeddingRequest,
|
||||
}
|
||||
|
||||
impl From<LlmEmbeddingRequestContract> for LlmEmbeddingDispatchPayload {
|
||||
fn from(request: LlmEmbeddingRequestContract) -> Self {
|
||||
Self {
|
||||
request: request.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(from = "LlmRerankRequestContract")]
|
||||
pub(crate) struct LlmRerankDispatchPayload {
|
||||
@@ -87,128 +59,3 @@ impl From<LlmRerankRequestContract> for LlmRerankDispatchPayload {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type LlmPreparedImageDispatchRoutePayload = SerializablePreparedRoute<LlmImageRequestContract>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use llm_adapter::router::SerializablePreparedRoute;
|
||||
|
||||
use super::{
|
||||
LlmDispatchPayload, LlmPreparedImageDispatchRoutePayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn prepared_chat_route_payload_deserializes_nested_request() {
|
||||
let payload = serde_json::from_value::<Vec<SerializablePreparedRoute<LlmDispatchPayload>>>(serde_json::json!([
|
||||
{
|
||||
"provider_id": "openai-primary",
|
||||
"protocol": "openai_chat",
|
||||
"model": "gpt-5-mini",
|
||||
"config": {
|
||||
"base_url": "https://api.openai.com",
|
||||
"auth_token": "test-key"
|
||||
},
|
||||
"request": {
|
||||
"model": "gpt-5-mini",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.expect("prepared chat route payload should deserialize");
|
||||
|
||||
assert_eq!(payload[0].model, "gpt-5-mini");
|
||||
assert_eq!(payload[0].request.request.model, "gpt-5-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_structured_route_payload_deserializes_nested_request() {
|
||||
let payload =
|
||||
serde_json::from_value::<Vec<SerializablePreparedRoute<LlmStructuredDispatchPayload>>>(serde_json::json!([
|
||||
{
|
||||
"provider_id": "openai-primary",
|
||||
"protocol": "openai_responses",
|
||||
"model": "gpt-5-mini",
|
||||
"config": {
|
||||
"base_url": "https://api.openai.com",
|
||||
"auth_token": "test-key"
|
||||
},
|
||||
"request": {
|
||||
"model": "gpt-5-mini",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": { "type": "string" }
|
||||
},
|
||||
"required": ["summary"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]))
|
||||
.expect("prepared structured route payload should deserialize");
|
||||
|
||||
assert_eq!(payload[0].model, "gpt-5-mini");
|
||||
assert_eq!(payload[0].request.request.model, "gpt-5-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_rerank_route_payload_deserializes_nested_request() {
|
||||
let payload =
|
||||
serde_json::from_value::<Vec<SerializablePreparedRoute<LlmRerankDispatchPayload>>>(serde_json::json!([
|
||||
{
|
||||
"provider_id": "openai-primary",
|
||||
"protocol": "openai_chat",
|
||||
"model": "gpt-5-mini",
|
||||
"config": {
|
||||
"base_url": "https://api.openai.com",
|
||||
"auth_token": "test-key"
|
||||
},
|
||||
"request": {
|
||||
"model": "gpt-5-mini",
|
||||
"query": "hello",
|
||||
"candidates": [{ "text": "world" }]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.expect("prepared rerank route payload should deserialize");
|
||||
|
||||
assert_eq!(payload[0].model, "gpt-5-mini");
|
||||
assert_eq!(payload[0].request.request.model, "gpt-5-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_image_route_payload_deserializes_nested_request() {
|
||||
let payload = serde_json::from_value::<Vec<LlmPreparedImageDispatchRoutePayload>>(serde_json::json!([
|
||||
{
|
||||
"provider_id": "openai-primary",
|
||||
"protocol": "openai_images",
|
||||
"model": "gpt-image-1",
|
||||
"config": {
|
||||
"base_url": "https://api.openai.com",
|
||||
"auth_token": "test-key",
|
||||
"request_layer": "openai_images"
|
||||
},
|
||||
"request": {
|
||||
"model": "gpt-image-1",
|
||||
"prompt": "draw",
|
||||
"operation": "generate"
|
||||
}
|
||||
}
|
||||
]))
|
||||
.expect("prepared image route payload should deserialize");
|
||||
|
||||
assert_eq!(payload[0].model, "gpt-image-1");
|
||||
assert_eq!(payload[0].request.prompt, "draw");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
mod action;
|
||||
pub(crate) mod byok;
|
||||
mod contract_schema;
|
||||
mod core;
|
||||
mod ffi;
|
||||
mod host;
|
||||
mod prompt_catalog;
|
||||
pub(crate) mod route;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use core::{
|
||||
capability::{llm_match_model_capabilities, llm_resolve_requested_model_match},
|
||||
pub use self::core::{
|
||||
capability::llm_match_model_capabilities,
|
||||
model_registry::{llm_match_model_registry, llm_resolve_model_registry_variant},
|
||||
prompt::{
|
||||
llm_collect_prompt_metadata, llm_count_prompt_tokens, llm_get_built_in_prompt_spec, llm_list_built_in_prompt_specs,
|
||||
llm_render_built_in_prompt, llm_render_built_in_session_prompt, llm_render_prompt, llm_render_session_prompt,
|
||||
llm_get_built_in_prompt_spec, llm_list_built_in_prompt_specs, llm_render_built_in_prompt,
|
||||
llm_render_built_in_session_prompt,
|
||||
},
|
||||
request_builder::{
|
||||
llm_build_canonical_request, llm_build_canonical_structured_request, llm_build_embedding_request,
|
||||
@@ -22,29 +20,34 @@ pub use core::{
|
||||
structured_output::{llm_canonical_json_schema_hash, llm_validate_json_schema},
|
||||
};
|
||||
|
||||
pub use action::run_native_action_recipe_prepared_stream;
|
||||
pub use contract_schema::{
|
||||
llm_compile_execution_plan, llm_get_contract_schema, llm_normalize_prepared_routes, llm_validate_contract,
|
||||
pub use action::copilot_action_recipe;
|
||||
pub use byok::{
|
||||
ByokCapabilityInput, ByokCatalogModelOutput, ByokCatalogOutput, ByokCatalogProviderOutput, ByokEndpointInput,
|
||||
ByokLocalLeaseOutput, ByokModelDeclarationInput, ByokModelProbeCheckOutput, ByokModelProbeOutput,
|
||||
ByokProbeCheckInput, ByokProbeResultOutput, ByokProbeStatusOutput, ByokProfileDefinitionInput, ByokProfileOutput,
|
||||
ByokValidationOutput, CreateByokLocalLeaseInput, CreateByokLocalLeaseProviderInput, CreateByokProfileInput,
|
||||
ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput, ReplaceByokProfileInput,
|
||||
RotateByokCredentialInput, byok_catalog,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use ffi::{AsyncLlmDispatchPreparedTask, resolve_request_chain};
|
||||
|
||||
#[napi_derive::napi(catch_unwind)]
|
||||
pub fn llm_get_byok_catalog() -> ByokCatalogOutput {
|
||||
byok_catalog()
|
||||
}
|
||||
pub(crate) use byok::{ByokProfileDefinition, validate_definition};
|
||||
pub use contract_schema::{llm_get_contract_schema, llm_validate_contract};
|
||||
pub(crate) use ffi::{
|
||||
LlmDispatchPayload, LlmEmbeddingDispatchPayload, LlmMiddlewarePayload, LlmPreparedImageDispatchRoutePayload,
|
||||
LlmRerankDispatchPayload, LlmRoutedBackendPayload, LlmStructuredDispatchPayload, StreamPipeline,
|
||||
apply_request_middlewares, apply_structured_request_middlewares, backend_transport_error,
|
||||
dispatch_prepared_image_route_payloads, dispatch_prepared_structured_routes, map_backend_error, map_json_error,
|
||||
parse_embedding_protocol, parse_prepared_chat_routes_with_middleware, parse_prepared_chat_routes_without_middleware,
|
||||
parse_protocol, parse_rerank_protocol, parse_structured_protocol, resolve_stream_chain,
|
||||
LlmDispatchPayload, LlmMiddlewarePayload, LlmRerankDispatchPayload, LlmStructuredDispatchPayload,
|
||||
};
|
||||
pub use ffi::{
|
||||
llm_dispatch_prepared, llm_embedding_dispatch, llm_embedding_dispatch_prepared, llm_image_dispatch_prepared,
|
||||
llm_plan_attachment_reference, llm_rerank_dispatch, llm_rerank_dispatch_prepared, llm_resolve_request_intent,
|
||||
llm_structured_dispatch, llm_structured_dispatch_prepared,
|
||||
};
|
||||
pub(crate) use host::{
|
||||
LlmStreamHandle, STREAM_ABORTED_REASON, STREAM_CALLBACK_DISPATCH_FAILED_REASON, STREAM_END_MARKER, emit_error_event,
|
||||
};
|
||||
pub use host::{
|
||||
llm_dispatch_prepared_stream, llm_dispatch_tool_loop_stream, llm_dispatch_tool_loop_stream_prepared,
|
||||
llm_dispatch_tool_loop_stream_routed,
|
||||
pub use prompt_catalog::llm_get_built_in_route_options;
|
||||
pub use route::{
|
||||
CopilotAccessProjection, CopilotExecuteInput, CopilotManagedTier, CopilotRouteCheckInput, CopilotTargetOverrideInput,
|
||||
};
|
||||
|
||||
pub(crate) fn invalid_arg(message: impl Into<String>) -> napi::Error {
|
||||
napi::Error::new(napi::Status::InvalidArg, message.into())
|
||||
}
|
||||
|
||||
pub(crate) fn map_json_error(error: serde_json::Error) -> napi::Error {
|
||||
invalid_arg(error.to_string())
|
||||
}
|
||||
|
||||
@@ -46,15 +46,12 @@ pub struct PromptSpecMessage {
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuiltInPromptSpec {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<String>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub optional_models: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -64,6 +61,69 @@ pub struct BuiltInPromptSpec {
|
||||
pub messages: Vec<PromptSpecMessage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PromptCatalogSpec {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
action: Option<String>,
|
||||
#[serde(default)]
|
||||
managed_route: Option<BuiltInManagedRouteSpec>,
|
||||
#[serde(default)]
|
||||
config: Option<Value>,
|
||||
#[serde(default)]
|
||||
params: Option<BTreeMap<String, PromptParamSpec>>,
|
||||
#[serde(default)]
|
||||
builtins: Option<Vec<PromptBuiltin>>,
|
||||
messages: Vec<PromptSpecMessage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct BuiltInManagedRouteSpec {
|
||||
targets: Vec<String>,
|
||||
#[serde(default)]
|
||||
premium_targets: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
selectable_targets: Vec<BuiltInManagedTargetSpec>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct BuiltInManagedTargetSpec {
|
||||
id: String,
|
||||
model_id: String,
|
||||
display_name: String,
|
||||
minimum_tier: BuiltInManagedTargetTier,
|
||||
}
|
||||
|
||||
#[napi(string_enum)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BuiltInManagedTargetTier {
|
||||
Standard,
|
||||
Premium,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuiltInManagedTarget {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub minimum_tier: BuiltInManagedTargetTier,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuiltInRouteOptions {
|
||||
pub route_id: String,
|
||||
pub standard_default_target_id: Option<String>,
|
||||
pub premium_default_target_id: Option<String>,
|
||||
pub choices: Vec<BuiltInManagedTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltInPromptMessage {
|
||||
@@ -73,20 +133,29 @@ pub(crate) struct BuiltInPromptMessage {
|
||||
pub(crate) params: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltInPrompt {
|
||||
pub(crate) name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) action: Option<String>,
|
||||
pub(crate) model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) optional_models: Option<Vec<String>>,
|
||||
pub(crate) managed_targets: Vec<String>,
|
||||
pub(crate) managed_premium_targets: Option<Vec<String>>,
|
||||
#[serde(skip)]
|
||||
pub(crate) managed_selectable_targets: Vec<BuiltInManagedTargetDefinition>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) config: Option<Value>,
|
||||
pub(crate) messages: Vec<BuiltInPromptMessage>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BuiltInManagedTargetDefinition {
|
||||
pub(crate) id: String,
|
||||
pub(crate) model_id: String,
|
||||
pub(crate) display_name: String,
|
||||
pub(crate) minimum_tier: BuiltInManagedTargetTier,
|
||||
}
|
||||
|
||||
struct PromptCatalog {
|
||||
specs: Vec<BuiltInPromptSpec>,
|
||||
prompts: Vec<BuiltInPrompt>,
|
||||
@@ -112,16 +181,89 @@ pub(crate) fn built_in_prompt(name: &str) -> Option<&'static BuiltInPrompt> {
|
||||
.and_then(|index| BUILTIN_PROMPT_CATALOG.prompts.get(*index))
|
||||
}
|
||||
|
||||
pub(crate) fn built_in_managed_targets(name: &str, premium: bool) -> Option<&'static [String]> {
|
||||
built_in_prompt(name).and_then(|prompt| {
|
||||
let targets = if premium {
|
||||
prompt
|
||||
.managed_premium_targets
|
||||
.as_deref()
|
||||
.unwrap_or(prompt.managed_targets.as_slice())
|
||||
} else {
|
||||
prompt.managed_targets.as_slice()
|
||||
};
|
||||
(!targets.is_empty()).then_some(targets)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn built_in_managed_target(
|
||||
name: &str,
|
||||
target_id: &str,
|
||||
premium: bool,
|
||||
) -> Option<&'static BuiltInManagedTargetDefinition> {
|
||||
built_in_prompt(name)?
|
||||
.managed_selectable_targets
|
||||
.iter()
|
||||
.find(|target| target.id == target_id && (premium || target.minimum_tier == BuiltInManagedTargetTier::Standard))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn llm_get_built_in_route_options(name: String) -> Option<BuiltInRouteOptions> {
|
||||
let prompt = built_in_prompt(&name)?;
|
||||
if prompt.managed_selectable_targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target_id_for_model = |model: Option<&String>| {
|
||||
model.and_then(|model| {
|
||||
prompt
|
||||
.managed_selectable_targets
|
||||
.iter()
|
||||
.find(|target| &target.model_id == model)
|
||||
.map(|target| target.id.clone())
|
||||
})
|
||||
};
|
||||
Some(BuiltInRouteOptions {
|
||||
route_id: prompt.name.clone(),
|
||||
standard_default_target_id: target_id_for_model(prompt.managed_targets.first()),
|
||||
premium_default_target_id: target_id_for_model(
|
||||
prompt
|
||||
.managed_premium_targets
|
||||
.as_ref()
|
||||
.and_then(|targets| targets.first())
|
||||
.or_else(|| prompt.managed_targets.first()),
|
||||
),
|
||||
choices: prompt
|
||||
.managed_selectable_targets
|
||||
.iter()
|
||||
.map(|target| BuiltInManagedTarget {
|
||||
id: target.id.clone(),
|
||||
display_name: target.display_name.clone(),
|
||||
minimum_tier: target.minimum_tier,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
impl PromptCatalog {
|
||||
fn load() -> Result<Self, String> {
|
||||
let partials: BTreeMap<String, String> =
|
||||
serde_json::from_str(PROMPT_PARTIALS_SOURCE).map_err(|error| format!("invalid prompt partials JSON: {error}"))?;
|
||||
let specs: Vec<BuiltInPromptSpec> =
|
||||
let catalog_specs: Vec<PromptCatalogSpec> =
|
||||
serde_json::from_str(PROMPT_SPECS_SOURCE).map_err(|error| format!("invalid prompt spec JSON: {error}"))?;
|
||||
let prompts = specs
|
||||
let prompts = catalog_specs
|
||||
.iter()
|
||||
.map(|spec| compile_prompt_spec(spec, &partials))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let specs = catalog_specs
|
||||
.into_iter()
|
||||
.map(|spec| BuiltInPromptSpec {
|
||||
name: spec.name,
|
||||
action: spec.action,
|
||||
config: spec.config.filter(|value| !value.is_null()),
|
||||
params: spec.params,
|
||||
builtins: spec.builtins,
|
||||
messages: spec.messages,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Self {
|
||||
specs_by_name: specs
|
||||
@@ -140,7 +282,17 @@ impl PromptCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_prompt_spec(spec: &BuiltInPromptSpec, partials: &BTreeMap<String, String>) -> Result<BuiltInPrompt, String> {
|
||||
fn compile_prompt_spec(spec: &PromptCatalogSpec, partials: &BTreeMap<String, String>) -> Result<BuiltInPrompt, String> {
|
||||
if spec
|
||||
.managed_route
|
||||
.as_ref()
|
||||
.is_some_and(|route| !valid_managed_route(route))
|
||||
{
|
||||
return Err(format!("Prompt \"{}\" has an invalid managed route", spec.name));
|
||||
}
|
||||
if !spec.messages.is_empty() && spec.managed_route.is_none() {
|
||||
return Err(format!("Executable prompt \"{}\" requires a managed route", spec.name));
|
||||
}
|
||||
let resolved_templates = spec
|
||||
.messages
|
||||
.iter()
|
||||
@@ -186,13 +338,71 @@ fn compile_prompt_spec(spec: &BuiltInPromptSpec, partials: &BTreeMap<String, Str
|
||||
Ok(BuiltInPrompt {
|
||||
name: spec.name.clone(),
|
||||
action: spec.action.clone(),
|
||||
model: spec.model.clone(),
|
||||
optional_models: spec.optional_models.clone(),
|
||||
managed_targets: spec
|
||||
.managed_route
|
||||
.as_ref()
|
||||
.map(|route| route.targets.clone())
|
||||
.unwrap_or_default(),
|
||||
managed_premium_targets: spec
|
||||
.managed_route
|
||||
.as_ref()
|
||||
.and_then(|route| route.premium_targets.clone()),
|
||||
managed_selectable_targets: spec
|
||||
.managed_route
|
||||
.as_ref()
|
||||
.map(|route| {
|
||||
route
|
||||
.selectable_targets
|
||||
.iter()
|
||||
.map(|target| BuiltInManagedTargetDefinition {
|
||||
id: target.id.clone(),
|
||||
model_id: target.model_id.clone(),
|
||||
display_name: target.display_name.clone(),
|
||||
minimum_tier: target.minimum_tier,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
config: spec.config.clone().filter(|value| !value.is_null()),
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_managed_route(route: &BuiltInManagedRouteSpec) -> bool {
|
||||
if route.targets.is_empty()
|
||||
|| route.targets.iter().any(|target| target.trim().is_empty())
|
||||
|| route
|
||||
.premium_targets
|
||||
.as_ref()
|
||||
.is_some_and(|targets| targets.is_empty() || targets.iter().any(|target| target.trim().is_empty()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ids = route
|
||||
.selectable_targets
|
||||
.iter()
|
||||
.map(|target| target.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let models = route
|
||||
.selectable_targets
|
||||
.iter()
|
||||
.map(|target| target.model_id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if route.selectable_targets.iter().any(|target| {
|
||||
target.id.trim().is_empty() || target.model_id.trim().is_empty() || target.display_name.trim().is_empty()
|
||||
}) || ids.len() != route.selectable_targets.len()
|
||||
|| models.len() != route.selectable_targets.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
route.selectable_targets.is_empty()
|
||||
|| route
|
||||
.targets
|
||||
.iter()
|
||||
.chain(route.premium_targets.iter().flatten())
|
||||
.all(|model| models.contains(model.as_str()))
|
||||
}
|
||||
|
||||
fn normalize_prompt_param(spec: &PromptParamSpec) -> Value {
|
||||
match spec.enum_values.as_ref() {
|
||||
Some(values) if !values.is_empty() => {
|
||||
@@ -250,7 +460,7 @@ fn resolve_prompt_template(template: &str, partials: &BTreeMap<String, String>)
|
||||
Err("Prompt partial expansion exceeded maximum depth".to_string())
|
||||
}
|
||||
|
||||
fn validate_builtins(spec: &BuiltInPromptSpec, templates: &[String]) -> Result<(), String> {
|
||||
fn validate_builtins(spec: &PromptCatalogSpec, templates: &[String]) -> Result<(), String> {
|
||||
let declared = spec
|
||||
.builtins
|
||||
.clone()
|
||||
@@ -355,26 +565,35 @@ mod tests {
|
||||
);
|
||||
|
||||
let chat = built_in_prompt("Chat With AFFiNE AI").expect("chat prompt");
|
||||
assert_eq!(chat.model, "gpt-5.6-luna");
|
||||
assert_eq!(chat.managed_targets, ["gpt-5.6-luna"]);
|
||||
assert_eq!(
|
||||
chat
|
||||
.optional_models
|
||||
.as_ref()
|
||||
.map(|models| models.iter().map(String::as_str).collect::<Vec<_>>()),
|
||||
Some(vec![
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-terra",
|
||||
"gemini-3.6-flash",
|
||||
"claude-sonnet-4-6"
|
||||
])
|
||||
.managed_premium_targets
|
||||
.as_deref()
|
||||
.map(|targets| targets.iter().map(String::as_str).collect::<Vec<_>>()),
|
||||
Some(vec!["gpt-5.6-luna"])
|
||||
);
|
||||
let options = llm_get_built_in_route_options(chat.name.clone()).expect("chat route options");
|
||||
assert_eq!(options.standard_default_target_id.as_deref(), Some("luna"));
|
||||
assert_eq!(options.premium_default_target_id.as_deref(), Some("luna"));
|
||||
assert_eq!(options.choices.len(), 4);
|
||||
assert_eq!(
|
||||
built_in_managed_target(&chat.name, "terra", false).map(|target| target.model_id.as_str()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
chat.config.as_ref().and_then(|config| config.get("proModels")),
|
||||
Some(&serde_json::json!([
|
||||
"gpt-5.6-terra",
|
||||
"gemini-3.6-flash",
|
||||
"claude-sonnet-4-6"
|
||||
]))
|
||||
built_in_managed_target(&chat.name, "terra", true).map(|target| target.model_id.as_str()),
|
||||
Some("gpt-5.6-terra")
|
||||
);
|
||||
|
||||
let transcript = built_in_prompt("Transcript audio structured").expect("transcript prompt");
|
||||
assert_eq!(transcript.managed_targets, ["gemini-3.5-flash-lite"]);
|
||||
assert_eq!(
|
||||
transcript
|
||||
.managed_premium_targets
|
||||
.as_deref()
|
||||
.map(|targets| targets.iter().map(String::as_str).collect::<Vec<_>>()),
|
||||
Some(vec!["gemini-3.6-flash"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
use llm_adapter::capability::{
|
||||
AttachmentKind, AttachmentSource, ModelFeature, ModelInput, ModelOutput, ModelRequirements,
|
||||
};
|
||||
|
||||
use crate::llm::{
|
||||
prompt_catalog::{built_in_managed_target, built_in_managed_targets},
|
||||
route::CopilotManagedTier,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RouteOperation {
|
||||
Chat,
|
||||
Structured,
|
||||
Embedding,
|
||||
Rerank,
|
||||
Image,
|
||||
Transcription,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CatalogSlot {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) operation: RouteOperation,
|
||||
pub(crate) requirements: ModelRequirements,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum QuotaPolicy {
|
||||
Metered,
|
||||
Internal,
|
||||
System,
|
||||
}
|
||||
|
||||
pub(crate) fn quota_policy(slot: &CatalogSlot, built_in_route_id: Option<&str>) -> QuotaPolicy {
|
||||
if matches!(slot.id, "index.embedding" | "search.rerank") {
|
||||
QuotaPolicy::System
|
||||
} else if built_in_route_id == Some("Summary as title") {
|
||||
QuotaPolicy::Internal
|
||||
} else {
|
||||
QuotaPolicy::Metered
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn managed_targets(
|
||||
slot: &CatalogSlot,
|
||||
built_in_route_id: Option<&str>,
|
||||
managed_tier: CopilotManagedTier,
|
||||
) -> Option<Vec<String>> {
|
||||
if let Some(route_id) = built_in_route_id {
|
||||
return built_in_managed_targets(route_id, managed_tier == CopilotManagedTier::Premium).map(<[String]>::to_vec);
|
||||
}
|
||||
match slot.id {
|
||||
"index.embedding" => Some(vec!["gemini-embedding-001".to_string()]),
|
||||
"search.rerank" => Some(vec!["gpt-4o-mini".to_string()]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn managed_selected_target(
|
||||
built_in_route_id: Option<&str>,
|
||||
target_id: &str,
|
||||
managed_tier: CopilotManagedTier,
|
||||
) -> Option<String> {
|
||||
built_in_managed_target(
|
||||
built_in_route_id?,
|
||||
target_id,
|
||||
managed_tier == CopilotManagedTier::Premium,
|
||||
)
|
||||
.map(|target| target.model_id.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn slot(id: &str) -> Option<CatalogSlot> {
|
||||
let (canonical, operation, input, output, features, attachment_kinds, attachment_sources) = match id {
|
||||
"chat.default" | "prompt.text" => (
|
||||
if id == "chat.default" {
|
||||
"chat.default"
|
||||
} else {
|
||||
"prompt.text"
|
||||
},
|
||||
RouteOperation::Chat,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Text],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"chat.structured" | "prompt.structured" => (
|
||||
if id == "chat.structured" {
|
||||
"chat.structured"
|
||||
} else {
|
||||
"prompt.structured"
|
||||
},
|
||||
RouteOperation::Structured,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Structured],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"action.mindmap.generate" => (
|
||||
"action.mindmap.generate",
|
||||
RouteOperation::Structured,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Structured],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"action.slides.outline" => (
|
||||
"action.slides.outline",
|
||||
RouteOperation::Structured,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Structured],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"image.generate"
|
||||
| "action.image.filter.sketch"
|
||||
| "action.image.filter.clay"
|
||||
| "action.image.filter.anime"
|
||||
| "action.image.filter.pixel" => (
|
||||
match id {
|
||||
"image.generate" => "image.generate",
|
||||
"action.image.filter.sketch" => "action.image.filter.sketch",
|
||||
"action.image.filter.clay" => "action.image.filter.clay",
|
||||
"action.image.filter.anime" => "action.image.filter.anime",
|
||||
_ => "action.image.filter.pixel",
|
||||
},
|
||||
RouteOperation::Image,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Image],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"index.embedding" => (
|
||||
"index.embedding",
|
||||
RouteOperation::Embedding,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Embedding],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"search.rerank" => (
|
||||
"search.rerank",
|
||||
RouteOperation::Rerank,
|
||||
vec![ModelInput::Text],
|
||||
vec![ModelOutput::Rerank],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
"transcript.audio" => (
|
||||
"transcript.audio",
|
||||
RouteOperation::Transcription,
|
||||
vec![ModelInput::Audio],
|
||||
vec![ModelOutput::Structured],
|
||||
vec![],
|
||||
vec![AttachmentKind::Audio],
|
||||
vec![
|
||||
AttachmentSource::Url,
|
||||
AttachmentSource::Data,
|
||||
AttachmentSource::Bytes,
|
||||
AttachmentSource::FileHandle,
|
||||
],
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some(CatalogSlot {
|
||||
id: canonical,
|
||||
operation,
|
||||
requirements: ModelRequirements {
|
||||
input,
|
||||
output,
|
||||
features,
|
||||
attachment_kinds,
|
||||
attachment_sources,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_request_requirements(
|
||||
mut slot: CatalogSlot,
|
||||
needs_tools: bool,
|
||||
attachment_kinds: Vec<AttachmentKind>,
|
||||
attachment_sources: Vec<AttachmentSource>,
|
||||
) -> CatalogSlot {
|
||||
if needs_tools {
|
||||
slot.requirements.features.push(ModelFeature::ToolCalling);
|
||||
}
|
||||
for kind in attachment_kinds {
|
||||
let input = match kind {
|
||||
AttachmentKind::Image => ModelInput::Image,
|
||||
AttachmentKind::Audio => ModelInput::Audio,
|
||||
AttachmentKind::File => ModelInput::File,
|
||||
};
|
||||
if !slot.requirements.input.contains(&input) {
|
||||
slot.requirements.input.push(input);
|
||||
}
|
||||
if !slot.requirements.attachment_kinds.contains(&kind) {
|
||||
slot.requirements.attachment_kinds.push(kind);
|
||||
}
|
||||
}
|
||||
for source in attachment_sources {
|
||||
if !slot.requirements.attachment_sources.contains(&source) {
|
||||
slot.requirements.attachment_sources.push(source);
|
||||
}
|
||||
}
|
||||
slot
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inventory_slots_have_one_operation_and_explicit_requirements() {
|
||||
let slots = [
|
||||
"chat.default",
|
||||
"chat.structured",
|
||||
"prompt.text",
|
||||
"prompt.structured",
|
||||
"action.mindmap.generate",
|
||||
"action.slides.outline",
|
||||
"action.image.filter.sketch",
|
||||
"action.image.filter.clay",
|
||||
"action.image.filter.anime",
|
||||
"action.image.filter.pixel",
|
||||
"image.generate",
|
||||
"index.embedding",
|
||||
"search.rerank",
|
||||
"transcript.audio",
|
||||
];
|
||||
for id in slots {
|
||||
let slot = slot(id).expect("inventory slot must exist");
|
||||
assert!(!slot.requirements.input.is_empty());
|
||||
assert!(!slot.requirements.output.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_routes_are_built_in_by_prompt_or_system_slot() {
|
||||
let prompt_slot = slot("prompt.text").unwrap();
|
||||
assert_eq!(
|
||||
managed_targets(&prompt_slot, Some("Summary as title"), CopilotManagedTier::Standard).unwrap(),
|
||||
["gpt-5.6-luna"]
|
||||
);
|
||||
assert!(
|
||||
managed_targets(
|
||||
&prompt_slot,
|
||||
Some("workflow:presentation"),
|
||||
CopilotManagedTier::Standard
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
managed_targets(
|
||||
&prompt_slot,
|
||||
Some("Transcript audio structured"),
|
||||
CopilotManagedTier::Premium
|
||||
)
|
||||
.unwrap(),
|
||||
["gemini-3.6-flash"]
|
||||
);
|
||||
assert_eq!(
|
||||
managed_targets(&slot("index.embedding").unwrap(), None, CopilotManagedTier::Standard).unwrap(),
|
||||
["gemini-embedding-001"]
|
||||
);
|
||||
assert_eq!(
|
||||
managed_targets(&slot("search.rerank").unwrap(), None, CopilotManagedTier::Standard).unwrap(),
|
||||
["gpt-4o-mini"]
|
||||
);
|
||||
assert!(matches!(
|
||||
quota_policy(&prompt_slot, Some("Summary as title")),
|
||||
QuotaPolicy::Internal
|
||||
));
|
||||
assert!(matches!(
|
||||
quota_policy(&prompt_slot, Some("Chat With AFFiNE AI")),
|
||||
QuotaPolicy::Metered
|
||||
));
|
||||
assert!(matches!(
|
||||
quota_policy(&slot("index.embedding").unwrap(), None),
|
||||
QuotaPolicy::System
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#[napi_derive::napi(string_enum)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CopilotManagedTier {
|
||||
Standard,
|
||||
Premium,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CopilotAccessProjection {
|
||||
pub route_allowed: bool,
|
||||
pub managed_tier: CopilotManagedTier,
|
||||
pub server_byok: bool,
|
||||
pub local_byok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CopilotTargetOverrideInput {
|
||||
pub profile_id: String,
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CopilotRouteCheckInput {
|
||||
pub slot: String,
|
||||
pub built_in_route_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub local_lease_id: Option<String>,
|
||||
pub access: CopilotAccessProjection,
|
||||
pub managed_target_id: Option<String>,
|
||||
pub target_override: Option<CopilotTargetOverrideInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi_derive::napi(object)]
|
||||
pub struct CopilotExecuteInput {
|
||||
pub slot: String,
|
||||
pub built_in_route_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub local_lease_id: Option<String>,
|
||||
pub access: CopilotAccessProjection,
|
||||
pub managed_target_id: Option<String>,
|
||||
pub target_override: Option<CopilotTargetOverrideInput>,
|
||||
#[napi(ts_type = "unknown")]
|
||||
pub request: serde_json::Value,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
mod catalog;
|
||||
mod contract;
|
||||
mod policy;
|
||||
|
||||
pub(crate) use catalog::{
|
||||
CatalogSlot, QuotaPolicy, RouteOperation, managed_selected_target, managed_targets, quota_policy, slot,
|
||||
with_request_requirements,
|
||||
};
|
||||
pub use contract::{
|
||||
CopilotAccessProjection, CopilotExecuteInput, CopilotManagedTier, CopilotRouteCheckInput, CopilotTargetOverrideInput,
|
||||
};
|
||||
pub(crate) use policy::{
|
||||
AuthorizedProfileRef, AuthorizedTargetRef, CredentialRef, Deployment, ProfileSource, RouteDecision,
|
||||
RouteDecisionReason, RoutePolicyInput, TargetOverride, decide,
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
use llm_adapter::capability::declared_model_matches;
|
||||
|
||||
use super::CatalogSlot;
|
||||
use crate::llm::byok::ByokProfileDefinition;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Deployment {
|
||||
Cloud,
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ProfileSource {
|
||||
Server,
|
||||
Local,
|
||||
Managed,
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizedProfileRef {
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) source: ProfileSource,
|
||||
pub(crate) provider: String,
|
||||
pub(crate) definition: ByokProfileDefinition,
|
||||
pub(crate) sort_order: i32,
|
||||
pub(crate) credential_ref: CredentialRef,
|
||||
}
|
||||
|
||||
pub(crate) enum CredentialRef {
|
||||
Envelope { encrypted: String, aad: Vec<u8> },
|
||||
Managed { profile_id: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TargetOverride {
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RouteDecisionReason {
|
||||
ByokDisabled,
|
||||
AccessUnavailable,
|
||||
ExplicitTargetUnavailable,
|
||||
NoCompatibleTarget,
|
||||
ManagedPresetUnavailable,
|
||||
}
|
||||
|
||||
pub(crate) enum RouteDecision {
|
||||
Ready(Vec<AuthorizedTargetRef>),
|
||||
Denied(RouteDecisionReason),
|
||||
NoRoute(RouteDecisionReason),
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizedTargetRef {
|
||||
pub(crate) profile_index: usize,
|
||||
pub(crate) model_index: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct RoutePolicyInput<'a> {
|
||||
pub(crate) slot: &'a CatalogSlot,
|
||||
pub(crate) deployment: Deployment,
|
||||
pub(crate) byok_enabled: bool,
|
||||
pub(crate) access_available: bool,
|
||||
pub(crate) profiles: &'a [AuthorizedProfileRef],
|
||||
pub(crate) target_override: Option<&'a TargetOverride>,
|
||||
pub(crate) target_override_managed: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn decide(input: RoutePolicyInput<'_>) -> RouteDecision {
|
||||
if input.deployment == Deployment::SelfHosted && !input.byok_enabled {
|
||||
return RouteDecision::NoRoute(RouteDecisionReason::ByokDisabled);
|
||||
}
|
||||
|
||||
if input.target_override_managed {
|
||||
if input.deployment != Deployment::Cloud {
|
||||
return RouteDecision::Denied(RouteDecisionReason::ExplicitTargetUnavailable);
|
||||
}
|
||||
if !input.access_available {
|
||||
return RouteDecision::Denied(RouteDecisionReason::AccessUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(target) = input.target_override {
|
||||
let mut selected = compatible_targets(&input, input.target_override_managed);
|
||||
selected.retain(|candidate| {
|
||||
let profile = &input.profiles[candidate.profile_index];
|
||||
let model = &profile.definition.models[candidate.model_index];
|
||||
profile.profile_id == target.profile_id && model.model_id == target.model_id
|
||||
});
|
||||
return if selected.is_empty() {
|
||||
RouteDecision::Denied(RouteDecisionReason::ExplicitTargetUnavailable)
|
||||
} else {
|
||||
RouteDecision::Ready(selected)
|
||||
};
|
||||
}
|
||||
let byok = compatible_targets(&input, false);
|
||||
if !byok.is_empty() {
|
||||
return RouteDecision::Ready(byok);
|
||||
}
|
||||
if !input.access_available {
|
||||
return RouteDecision::Denied(RouteDecisionReason::AccessUnavailable);
|
||||
}
|
||||
if input.deployment == Deployment::Cloud {
|
||||
let managed = compatible_targets(&input, true);
|
||||
if managed.is_empty() {
|
||||
RouteDecision::NoRoute(RouteDecisionReason::ManagedPresetUnavailable)
|
||||
} else {
|
||||
RouteDecision::Ready(managed)
|
||||
}
|
||||
} else {
|
||||
RouteDecision::NoRoute(RouteDecisionReason::NoCompatibleTarget)
|
||||
}
|
||||
}
|
||||
|
||||
fn compatible_targets(input: &RoutePolicyInput<'_>, managed: bool) -> Vec<AuthorizedTargetRef> {
|
||||
let mut profiles = input
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, profile)| (profile.source == ProfileSource::Managed) == managed)
|
||||
.collect::<Vec<_>>();
|
||||
profiles.sort_by_key(|(_, profile)| profile.sort_order);
|
||||
profiles
|
||||
.into_iter()
|
||||
.flat_map(|(profile_index, profile)| {
|
||||
profile
|
||||
.definition
|
||||
.models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, model)| model.enabled && declared_model_matches(&model.capabilities, &input.slot.requirements))
|
||||
.map(move |(model_index, _)| AuthorizedTargetRef {
|
||||
profile_index,
|
||||
model_index,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use llm_adapter::capability::{DeclaredModelCapability, ModelInput, ModelOutput};
|
||||
|
||||
use super::*;
|
||||
use crate::llm::{
|
||||
byok::{ByokEndpoint, ByokModelDeclaration},
|
||||
route::catalog,
|
||||
};
|
||||
|
||||
fn profile(id: &str, source: ProfileSource, model: &str, output: ModelOutput) -> AuthorizedProfileRef {
|
||||
AuthorizedProfileRef {
|
||||
profile_id: id.to_string(),
|
||||
source,
|
||||
provider: "openai".to_string(),
|
||||
definition: ByokProfileDefinition {
|
||||
version: 1,
|
||||
endpoint: ByokEndpoint::Custom {
|
||||
url: "https://example.test/v1".to_string(),
|
||||
},
|
||||
models: vec![ByokModelDeclaration {
|
||||
model_id: model.to_string(),
|
||||
enabled: true,
|
||||
capabilities: vec![DeclaredModelCapability {
|
||||
input: vec![ModelInput::Text],
|
||||
output: vec![output],
|
||||
features: vec![],
|
||||
attachment_kinds: vec![],
|
||||
attachment_sources: vec![],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
sort_order: 0,
|
||||
credential_ref: CredentialRef::Managed {
|
||||
profile_id: id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployment_matrix_and_unsupported_only_fallback() {
|
||||
let slot = catalog::slot("chat.default").unwrap();
|
||||
let cases = [
|
||||
(
|
||||
Deployment::Cloud,
|
||||
true,
|
||||
vec![profile("managed", ProfileSource::Managed, "A", ModelOutput::Text)],
|
||||
true,
|
||||
),
|
||||
(
|
||||
Deployment::SelfHosted,
|
||||
false,
|
||||
vec![profile("managed", ProfileSource::Managed, "A", ModelOutput::Text)],
|
||||
false,
|
||||
),
|
||||
(Deployment::SelfHosted, true, vec![], false),
|
||||
(
|
||||
Deployment::Cloud,
|
||||
true,
|
||||
vec![
|
||||
profile("byok", ProfileSource::Server, "B", ModelOutput::Image),
|
||||
profile("managed", ProfileSource::Managed, "A", ModelOutput::Text),
|
||||
],
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (deployment, byok_enabled, profiles, ready) in cases {
|
||||
let decision = decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment,
|
||||
byok_enabled,
|
||||
access_available: true,
|
||||
profiles: &profiles,
|
||||
target_override: None,
|
||||
target_override_managed: false,
|
||||
});
|
||||
assert_eq!(matches!(decision, RouteDecision::Ready(_)), ready);
|
||||
}
|
||||
|
||||
let profiles = vec![
|
||||
profile("byok", ProfileSource::Server, "B", ModelOutput::Text),
|
||||
profile("managed", ProfileSource::Managed, "A", ModelOutput::Text),
|
||||
];
|
||||
assert!(matches!(
|
||||
decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment: Deployment::Cloud,
|
||||
byok_enabled: true,
|
||||
access_available: false,
|
||||
profiles: &profiles,
|
||||
target_override: None,
|
||||
target_override_managed: false,
|
||||
}),
|
||||
RouteDecision::Ready(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment: Deployment::Cloud,
|
||||
byok_enabled: true,
|
||||
access_available: false,
|
||||
profiles: &profiles[1..],
|
||||
target_override: None,
|
||||
target_override_managed: false,
|
||||
}),
|
||||
RouteDecision::Denied(RouteDecisionReason::AccessUnavailable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_is_complete_and_custom_model_is_not_replaced_by_managed_model() {
|
||||
let slot = catalog::slot("chat.default").unwrap();
|
||||
let profiles = vec![
|
||||
profile("byok", ProfileSource::Server, "vendor/model:B", ModelOutput::Text),
|
||||
profile("managed", ProfileSource::Managed, "model:A", ModelOutput::Text),
|
||||
];
|
||||
let target = TargetOverride {
|
||||
profile_id: "byok".to_string(),
|
||||
model_id: "vendor/model:B".to_string(),
|
||||
};
|
||||
let RouteDecision::Ready(candidates) = decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment: Deployment::Cloud,
|
||||
byok_enabled: true,
|
||||
access_available: true,
|
||||
profiles: &profiles,
|
||||
target_override: Some(&target),
|
||||
target_override_managed: false,
|
||||
}) else {
|
||||
panic!("override should resolve");
|
||||
};
|
||||
assert_eq!(
|
||||
profiles[candidates[0].profile_index].definition.models[candidates[0].model_index].model_id,
|
||||
"vendor/model:B"
|
||||
);
|
||||
|
||||
let managed_target = TargetOverride {
|
||||
profile_id: "managed".to_string(),
|
||||
model_id: "model:A".to_string(),
|
||||
};
|
||||
let RouteDecision::Ready(candidates) = decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment: Deployment::Cloud,
|
||||
byok_enabled: true,
|
||||
access_available: true,
|
||||
profiles: &profiles,
|
||||
target_override: Some(&managed_target),
|
||||
target_override_managed: true,
|
||||
}) else {
|
||||
panic!("managed selection should resolve");
|
||||
};
|
||||
assert!(matches!(
|
||||
profiles[candidates[0].profile_index].source,
|
||||
ProfileSource::Managed
|
||||
));
|
||||
|
||||
let mut disabled = profile("disabled", ProfileSource::Server, "model:C", ModelOutput::Text);
|
||||
disabled.definition.models[0].enabled = false;
|
||||
assert!(matches!(
|
||||
decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment: Deployment::SelfHosted,
|
||||
byok_enabled: true,
|
||||
access_available: true,
|
||||
profiles: &[disabled],
|
||||
target_override: None,
|
||||
target_override_managed: false,
|
||||
}),
|
||||
RouteDecision::NoRoute(RouteDecisionReason::NoCompatibleTarget)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user