snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
288
packages/backend/native/src/llm/route/catalog.rs
Normal file
288
packages/backend/native/src/llm/route/catalog.rs
Normal file
@@ -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.7-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
|
||||
));
|
||||
}
|
||||
}
|
||||
50
packages/backend/native/src/llm/route/contract.rs
Normal file
50
packages/backend/native/src/llm/route/contract.rs
Normal file
@@ -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,
|
||||
}
|
||||
15
packages/backend/native/src/llm/route/mod.rs
Normal file
15
packages/backend/native/src/llm/route/mod.rs
Normal file
@@ -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::{
|
||||
AuthorizedProviderProfile, AuthorizedTargetRef, CredentialRef, ProfileSource, RouteDecision, RouteDecisionReason,
|
||||
RoutePolicyInput, TargetOverride, decide,
|
||||
};
|
||||
304
packages/backend/native/src/llm/route/policy.rs
Normal file
304
packages/backend/native/src/llm/route/policy.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use llm_adapter::{
|
||||
capability::declared_model_matches,
|
||||
target::{BackendEndpoint, EgressPolicy, OpenAiDialect},
|
||||
};
|
||||
|
||||
use super::CatalogSlot;
|
||||
use crate::{llm::byok::ByokModelDeclaration, runtime::Deployment};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ProfileSource {
|
||||
Server,
|
||||
Local,
|
||||
Managed,
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizedProviderProfile {
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) source: ProfileSource,
|
||||
pub(crate) provider: String,
|
||||
pub(crate) endpoint: BackendEndpoint,
|
||||
pub(crate) openai_dialect: Option<OpenAiDialect>,
|
||||
pub(crate) egress_policy: EgressPolicy,
|
||||
pub(crate) models: Vec<ByokModelDeclaration>,
|
||||
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 [AuthorizedProviderProfile],
|
||||
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.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
|
||||
.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::ByokModelDeclaration, route::catalog};
|
||||
|
||||
fn profile(id: &str, source: ProfileSource, model: &str, output: ModelOutput) -> AuthorizedProviderProfile {
|
||||
AuthorizedProviderProfile {
|
||||
profile_id: id.to_string(),
|
||||
source,
|
||||
provider: "openai".to_string(),
|
||||
endpoint: BackendEndpoint::Custom("https://example.test/v1".to_string()),
|
||||
openai_dialect: Some(OpenAiDialect::Responses),
|
||||
egress_policy: EgressPolicy::PublicOnly,
|
||||
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].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.models[0].enabled = false;
|
||||
assert!(matches!(
|
||||
decide(RoutePolicyInput {
|
||||
slot: &slot,
|
||||
deployment: Deployment::SelfHosted,
|
||||
byok_enabled: true,
|
||||
access_available: true,
|
||||
profiles: &[disabled],
|
||||
target_override: None,
|
||||
target_override_managed: false,
|
||||
}),
|
||||
RouteDecision::NoRoute(RouteDecisionReason::NoCompatibleTarget)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user