snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
590
packages/backend/native/src/llm/byok/contract.rs
Normal file
590
packages/backend/native/src/llm/byok/contract.rs
Normal file
@@ -0,0 +1,590 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user