Files
upstream-affine/packages/backend/native/src/llm/byok/contract.rs
upstream-mirror 702cc698a7
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
2026-09-16 04:01:20 +05:00

591 lines
18 KiB
Rust

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::{OpenAiDialect, 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>,
pub dialect: Option<String>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[napi_derive::napi(object)]
pub struct ByokProfileDefinitionInput {
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,
OpenAiCompatible { url: String, dialect: OpenAiDialect },
}
#[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) endpoint: ByokEndpoint,
pub(crate) models: Vec<ByokModelDeclaration>,
}
#[derive(Debug, Error)]
pub(crate) enum ByokContractError {
#[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::OpenAiCompatible { url, .. } => url,
}
}
}
pub(crate) fn validate_definition(
provider: &str,
input: ByokProfileDefinitionInput,
) -> Result<ByokProfileDefinition, ByokContractError> {
if !matches!(provider, "openai" | "anthropic" | "gemini" | "fal") {
return Err(ByokContractError::Provider);
}
let endpoint = match (
input.endpoint.kind.as_str(),
input.endpoint.url,
input.endpoint.dialect.as_deref(),
) {
("provider_default", None, None) => ByokEndpoint::ProviderDefault,
("openai_compatible", Some(url), Some(dialect)) if provider == "openai" && !url.trim().is_empty() => {
ByokEndpoint::OpenAiCompatible {
url: canonicalize_endpoint(&url).map_err(|_| ByokContractError::Endpoint)?,
dialect: match dialect {
"responses" => OpenAiDialect::Responses,
"chat_completions" => OpenAiDialect::ChatCompletions,
_ => return 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 { 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::OpenAiCompatible { .. }) {
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 {
endpoint: match definition.endpoint {
ByokEndpoint::ProviderDefault => ByokEndpointInput {
kind: "provider_default".to_string(),
url: None,
dialect: None,
},
ByokEndpoint::OpenAiCompatible { url, dialect } => ByokEndpointInput {
kind: "openai_compatible".to_string(),
url: Some(url),
dialect: Some(
match dialect {
OpenAiDialect::Responses => "responses",
OpenAiDialect::ChatCompletions => "chat_completions",
}
.to_string(),
),
},
},
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 {
endpoint: ByokEndpointInput {
kind: "openai_compatible".to_string(),
url: Some("https://example.com/v1/".to_string()),
dialect: Some("responses".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()),
dialect: None,
},
ByokEndpointInput {
kind: "openai_compatible".to_string(),
url: None,
dialect: Some("responses".to_string()),
},
ByokEndpointInput {
kind: "openai_compatible".to_string(),
url: Some(" ".to_string()),
dialect: Some("responses".to_string()),
},
] {
let mut input = definition("model", vec![text_capability()]);
input.endpoint = endpoint;
assert!(validate_definition("openai", input).is_err());
}
}
}