mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-21 03:51:45 +08:00
feat(server): realtime handle & migration (#15487)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Retry failed Copilot transcription tasks in real time. * Retried tasks resume processing and report updated status. * Managed Copilot provider models can be omitted to use provider defaults. * **Bug Fixes** * Improved handling of incomplete BYOK profiles, including safe replacement of legacy records. * Duplicate profile creation now returns a clear validation error. * Improved transcript processing reliability by preventing duplicate or stale dispatches. * **Migration** * Consolidated legacy managed-provider settings while preserving existing profiles and defaults. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -103,6 +103,21 @@ pub(in super::super) async fn create(
|
||||
definition, sort_order, enabled, created_by, updated_by, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (workspace_id, provider, name) DO UPDATE
|
||||
SET id = EXCLUDED.id,
|
||||
description = EXCLUDED.description,
|
||||
encrypted_api_key = EXCLUDED.encrypted_api_key,
|
||||
definition = EXCLUDED.definition,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
enabled = EXCLUDED.enabled,
|
||||
revision = 1,
|
||||
credential_generation = 1,
|
||||
validation = NULL,
|
||||
created_by = EXCLUDED.created_by,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
created_at = EXCLUDED.created_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE ai_workspace_byok_configs.definition = '{}'::jsonb
|
||||
RETURNING id, workspace_id, provider, name, description, encrypted_api_key,
|
||||
definition, sort_order, enabled, revision, credential_generation, validation
|
||||
"#,
|
||||
@@ -117,9 +132,10 @@ pub(in super::super) async fn create(
|
||||
.bind(sort_order)
|
||||
.bind(input.enabled)
|
||||
.bind(&input.actor_user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::database("create BYOK profile failed", error))?;
|
||||
.map_err(|error| RuntimeError::database("create BYOK profile failed", error))?
|
||||
.ok_or_else(|| RuntimeError::invalid_input("BYOK profile name already exists"))?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|error| RuntimeError::database("create BYOK profile commit failed", error))?;
|
||||
@@ -578,7 +594,14 @@ pub(super) fn require_text(value: &str, field: &'static str) -> RuntimeResult<()
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PgPool, Uuid, list};
|
||||
use super::{ByokPolicy, PgPool, Uuid, create, list};
|
||||
use crate::{
|
||||
llm::{
|
||||
ByokCapabilityInput, ByokEndpointInput, ByokModelDeclarationInput, ByokProfileDefinitionInput,
|
||||
CreateByokProfileInput, Deployment,
|
||||
},
|
||||
runtime::config::CopilotByokRuntimeConfig,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_skips_rows_with_unparseable_legacy_definition() {
|
||||
@@ -619,6 +642,40 @@ mod tests {
|
||||
assert_eq!(profiles.len(), 1);
|
||||
assert_eq!(profiles[0].name, "valid");
|
||||
|
||||
let input = || CreateByokProfileInput {
|
||||
workspace_id: workspace_id.clone(),
|
||||
provider: "openai".to_string(),
|
||||
name: "legacy".to_string(),
|
||||
description: None,
|
||||
credential: "replacement-key".to_string(),
|
||||
definition: ByokProfileDefinitionInput {
|
||||
endpoint: ByokEndpointInput {
|
||||
kind: "provider_default".to_string(),
|
||||
url: None,
|
||||
dialect: None,
|
||||
},
|
||||
models: vec![ByokModelDeclarationInput {
|
||||
model_id: "gpt-4o-mini".to_string(),
|
||||
enabled: true,
|
||||
capabilities: vec![ByokCapabilityInput {
|
||||
input: vec!["text".to_string()],
|
||||
output: vec!["text".to_string()],
|
||||
features: vec![],
|
||||
attachment_kinds: vec![],
|
||||
attachment_sources: vec![],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
enabled: true,
|
||||
actor_user_id: "user-1".to_string(),
|
||||
};
|
||||
let policy = ByokPolicy::from(Deployment::Cloud, &CopilotByokRuntimeConfig::default());
|
||||
create(&pool, &[7; 32], &policy, input()).await.unwrap();
|
||||
let profiles = list(&pool, &workspace_id).await.unwrap();
|
||||
assert_eq!(profiles.len(), 2);
|
||||
assert!(profiles.iter().any(|profile| profile.name == "legacy"));
|
||||
assert!(create(&pool, &[7; 32], &policy, input()).await.is_err());
|
||||
|
||||
sqlx::query("DELETE FROM ai_workspace_byok_configs WHERE workspace_id = $1")
|
||||
.bind(&workspace_id)
|
||||
.execute(&pool)
|
||||
|
||||
@@ -158,7 +158,7 @@ pub(crate) struct CopilotManagedProfileConfigFile {
|
||||
priority: Option<f64>,
|
||||
#[serde(default = "enabled_by_default")]
|
||||
enabled: bool,
|
||||
models: Vec<String>,
|
||||
models: Option<Vec<String>>,
|
||||
middleware: Option<CopilotProviderMiddlewareConfigFile>,
|
||||
config: Map<String, serde_json::Value>,
|
||||
}
|
||||
@@ -193,6 +193,18 @@ impl CopilotManagedProvider {
|
||||
Self::OpenAi => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_models(self) -> Vec<String> {
|
||||
let models: &[&str] = match self {
|
||||
Self::OpenAi => &["gpt-5.6-luna", "gpt-5.6-terra", "gpt-image-1", "gpt-4o-mini"],
|
||||
Self::CloudflareWorkersAi => &["@cf/baai/bge-reranker-base"],
|
||||
Self::Fal => &["lora/image-to-image", "workflowutils/teed"],
|
||||
Self::Gemini => &["gemini-3.7-flash", "gemini-embedding-001"],
|
||||
Self::GeminiVertex => &["gemini-3.7-flash"],
|
||||
Self::Anthropic | Self::AnthropicVertex => &["claude-sonnet-4-6"],
|
||||
};
|
||||
models.iter().map(|model| (*model).to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
|
||||
@@ -270,11 +282,12 @@ impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
|
||||
"managed copilot profile id must contain only letters, numbers, hyphens, and underscores",
|
||||
));
|
||||
}
|
||||
let models = value.models.unwrap_or_else(|| value.provider.legacy_models());
|
||||
Ok(Self {
|
||||
id: value.id,
|
||||
provider: value.provider.as_str().to_string(),
|
||||
enabled: value.enabled,
|
||||
models: value.models,
|
||||
models,
|
||||
config: serde_json::Value::Object(value.config),
|
||||
})
|
||||
}
|
||||
@@ -740,6 +753,45 @@ mod tests {
|
||||
assert_eq!(copilot.providers.profiles.len(), 1);
|
||||
assert_eq!(copilot.providers.profiles[0].id, "managed-openai");
|
||||
|
||||
for (provider, expected_models) in [
|
||||
(
|
||||
"openai",
|
||||
vec!["gpt-5.6-luna", "gpt-5.6-terra", "gpt-image-1", "gpt-4o-mini"],
|
||||
),
|
||||
("cloudflareWorkersAi", vec!["@cf/baai/bge-reranker-base"]),
|
||||
("fal", vec!["lora/image-to-image", "workflowutils/teed"]),
|
||||
("gemini", vec!["gemini-3.7-flash", "gemini-embedding-001"]),
|
||||
("geminiVertex", vec!["gemini-3.7-flash"]),
|
||||
("anthropic", vec!["claude-sonnet-4-6"]),
|
||||
("anthropicVertex", vec!["claude-sonnet-4-6"]),
|
||||
] {
|
||||
let app_config = app_config_from_flat_overrides([(
|
||||
"copilot.providers.profiles",
|
||||
serde_json::json!([{
|
||||
"id": format!("{provider}-default"),
|
||||
"type": provider,
|
||||
"config": {}
|
||||
}]),
|
||||
)])
|
||||
.unwrap();
|
||||
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
|
||||
validate_copilot_config(&copilot).unwrap();
|
||||
assert_eq!(copilot.providers.profiles[0].models, expected_models);
|
||||
}
|
||||
|
||||
let app_config = app_config_from_flat_overrides([(
|
||||
"copilot.providers.profiles",
|
||||
serde_json::json!([{
|
||||
"id": "managed-openai",
|
||||
"type": "openai",
|
||||
"models": [],
|
||||
"config": {}
|
||||
}]),
|
||||
)])
|
||||
.unwrap();
|
||||
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
|
||||
assert!(validate_copilot_config(&copilot).is_err());
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let base_path = directory.path().join("base.json");
|
||||
let override_path = directory.path().join("override.json");
|
||||
|
||||
Reference in New Issue
Block a user