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:
DarkSky
2026-08-05 00:40:13 +08:00
committed by GitHub
parent fdfb6df826
commit 965f4590ff
272 changed files with 12430 additions and 33824 deletions
+81 -274
View File
@@ -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 -95
View File
@@ -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, &params).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(), &params).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 -23
View File
@@ -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,
};
+2 -155
View File
@@ -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");
}
}
+33 -30
View File
@@ -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())
}
+249 -30
View File
@@ -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)
));
}
}