feat(copilot): managed profile testing and openaiCompatible provider support

- add native openaiCompatible provider type with flat baseUrl/apiKey/dialect
  config fields and declared per-model capabilities
- add admin-only probeManagedCopilotProfile GraphQL mutation that dispatches
  real probe requests against server-managed copilot profiles, reusing the
  BYOK probe engine with an AllowPrivate egress policy for self-hosted
  endpoints (Vertex profiles rejected; use workspace BYOK instead)
- expose providers.profiles app config descriptor to the admin console
- add 'AI Providers' settings group with JSON editor and per-profile Test
  action that probes declared capabilities and reports verified/failed
  status per model and operation
This commit is contained in:
2026-08-26 09:51:52 +07:00
parent ff599ac7f1
commit 177150086f
24 changed files with 1309 additions and 53 deletions

View File

@@ -87,6 +87,7 @@ export declare class BackendRuntime {
rotateByokCredential(input: RotateByokCredentialInput): Promise<ByokProfileOutput>
probeByokProfile(input: ProbeByokProfileInput): Promise<ByokProbeResultOutput>
probeByokDraft(input: ProbeByokDraftInput): Promise<ByokProbeResultOutput>
probeManagedCopilotProfile(profileId: string, checks: Array<ByokProbeCheckInput>): Promise<ByokProbeResultOutput>
deleteByokProfile(workspaceId: string, profileId: string): Promise<boolean>
reorderByokProfiles(input: ReorderByokProfilesInput): Promise<Array<ByokProfileOutput>>
createByokLocalLease(input: CreateByokLocalLeaseInput): Promise<ByokLocalLeaseOutput>

View File

@@ -3,6 +3,7 @@ mod probe;
mod profile;
pub(super) use local::{LocalLeasePayload, create as create_local_lease};
pub(in crate::runtime::backend_runtime) use probe::execute_probe_with_policy;
pub(super) use profile::{create, delete, list, probe_draft, probe_profile, reorder, replace, rotate};
use profile::{envelope_key, require_text};

View File

@@ -28,6 +28,26 @@ pub(super) async fn execute_probe(
credential: SensitiveCredential,
policy: &ByokPolicy,
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
execute_probe_with_policy(
provider,
definition,
credential,
policy.egress_policy(&definition.endpoint),
checks,
)
.await
}
/// Probe with an explicit egress policy. Managed (admin-configured) profiles
/// pass `AllowPrivate` for self-hosted endpoints; workspace BYOK always
/// derives the policy from [`ByokPolicy`].
pub(in crate::runtime::backend_runtime) async fn execute_probe_with_policy(
provider: &str,
definition: &ByokProfileDefinition,
credential: SensitiveCredential,
egress_policy: EgressPolicy,
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
let tested_at_ms = chrono::Utc::now().timestamp_millis();
let mut requested = Vec::new();
@@ -71,7 +91,6 @@ pub(super) async fn execute_probe(
let credential = String::from_utf8(credential.expose().to_vec())
.map_err(|_| RuntimeError::invalid_state("credential_unavailable"))?;
let operation_for_task = operation.clone();
let egress_policy = policy.egress_policy(&endpoint);
tokio::task::spawn_blocking(move || {
dispatch_check(
&provider,

View File

@@ -194,7 +194,7 @@ fn load_managed_profiles(
.providers
.profiles
.iter()
.filter(|profile| profile.enabled && profile.models.iter().any(|model| model == model_id))
.filter(|profile| profile.enabled && profile.models.iter().any(|model| model.id == *model_id))
.collect::<Vec<_>>();
let Some(profile) = matches.first() else {
return Ok(None);
@@ -204,15 +204,23 @@ fn load_managed_profiles(
"built-in managed route model matches multiple profiles",
));
}
let capabilities = provider_default_capability_upper_bound(&profile.provider, model_id)
.ok_or_else(|| RuntimeError::invalid_state("built-in managed route model is incompatible with its profile"))?;
let declared = matches
.first()
.and_then(|profile| profile.models.iter().find(|model| model.id == *model_id))
.and_then(|model| model.capabilities.clone());
let capabilities = match declared {
Some(capabilities) => capabilities,
None => provider_default_capability_upper_bound(&profile.provider, model_id).ok_or_else(|| {
RuntimeError::invalid_state("built-in managed route model is incompatible with its profile")
})?,
};
let endpoint = managed_endpoint(profile)?;
Ok(Some(AuthorizedProviderProfile {
profile_id: profile.id.clone(),
source: ProfileSource::Managed,
provider: profile.provider.clone(),
endpoint,
openai_dialect: (profile.provider == "openai").then_some(OpenAiDialect::Responses),
openai_dialect: openai_dialect_for(profile),
egress_policy: llm_adapter::target::EgressPolicy::PublicOnly,
models: vec![crate::llm::byok::ByokModelDeclaration {
model_id: model_id.clone(),
@@ -229,6 +237,19 @@ fn load_managed_profiles(
.collect()
}
fn openai_dialect_for(profile: &CopilotManagedProfileConfig) -> Option<OpenAiDialect> {
match profile.provider.as_str() {
"openai" => Some(OpenAiDialect::Responses),
"openaiCompatible" => Some(
match profile.config.get("dialect").and_then(serde_json::Value::as_str) {
Some("responses") => OpenAiDialect::Responses,
_ => OpenAiDialect::ChatCompletions,
},
),
_ => None,
}
}
fn managed_endpoint(profile: &CopilotManagedProfileConfig) -> RuntimeResult<BackendEndpoint> {
if let Some(base_url) = profile.config.get("baseURL").and_then(serde_json::Value::as_str) {
return llm_adapter::target::canonicalize_endpoint(base_url)
@@ -315,14 +336,18 @@ pub(super) fn required_config_text<'a>(
mod tests {
use serde_json::json;
use super::{BackendEndpoint, CopilotManagedProfileConfig, managed_endpoint};
use super::{BackendEndpoint, CopilotManagedProfileConfig, managed_endpoint, openai_dialect_for};
use crate::runtime::config::CopilotManagedModel;
fn vertex_profile(location: &str) -> CopilotManagedProfileConfig {
CopilotManagedProfileConfig {
id: "vertex".to_string(),
provider: "geminiVertex".to_string(),
enabled: true,
models: vec!["gemini-3.7-flash".to_string()],
models: vec![CopilotManagedModel {
id: "gemini-3.7-flash".to_string(),
capabilities: None,
}],
config: json!({ "project": "affine-us", "location": location }),
}
}
@@ -347,4 +372,32 @@ mod tests {
)
);
}
#[test]
fn openai_compatible_dialect_defaults_to_chat_completions() {
let profile = CopilotManagedProfileConfig {
id: "vllm".to_string(),
provider: "openaiCompatible".to_string(),
enabled: true,
models: vec![CopilotManagedModel {
id: "qwen3-32b".to_string(),
capabilities: None,
}],
config: json!({ "baseURL": "http://127.0.0.1:8000/v1", "apiKey": "x" }),
};
assert!(managed_endpoint(&profile).is_ok());
assert_eq!(
openai_dialect_for(&profile),
Some(llm_adapter::target::OpenAiDialect::ChatCompletions)
);
let profile = CopilotManagedProfileConfig {
config: json!({ "dialect": "responses" }),
..profile
};
assert_eq!(
openai_dialect_for(&profile),
Some(llm_adapter::target::OpenAiDialect::Responses)
);
}
}

View File

@@ -344,6 +344,7 @@ pub(super) async fn create_vertex_token_provider(
pub(in crate::runtime::backend_runtime) fn provider(value: &str) -> RuntimeResult<BackendProvider> {
match value {
"openai" => Ok(BackendProvider::OpenAi),
"openaiCompatible" => Ok(BackendProvider::OpenAi),
"anthropic" => Ok(BackendProvider::Anthropic),
"anthropicVertex" => Ok(BackendProvider::AnthropicVertex),
"gemini" => Ok(BackendProvider::Gemini),

View File

@@ -0,0 +1,93 @@
use std::collections::HashSet;
use llm_adapter::target::EgressPolicy;
use crate::{
llm::{
ByokProbeCheckInput, ByokProbeResultOutput,
byok::{ByokEndpoint, SensitiveCredential},
},
runtime::{BackendRuntimeConfig, CopilotManagedProfileConfig, RuntimeError, RuntimeResult},
};
/// Build a BYOK-equivalent definition from a managed profile so the shared
/// probe engine can compile targets and dispatch real requests. Managed
/// profiles are admin-controlled, so private endpoints (self-hosted vLLM,
/// Ollama, LiteLLM) are allowed and no DNS admission check applies.
pub(super) fn managed_definition(
managed: &CopilotManagedProfileConfig,
) -> RuntimeResult<(crate::llm::byok::ByokProfileDefinition, EgressPolicy)> {
use crate::llm::byok::{ByokModelDeclaration, ByokProfileDefinition};
let endpoint = if let Some(base_url) = managed.config.get("baseURL").and_then(serde_json::Value::as_str) {
ByokEndpoint::OpenAiCompatible {
url: llm_adapter::target::canonicalize_endpoint(base_url)
.map_err(|error| RuntimeError::invalid_state(error.to_string()))?,
dialect: openai_dialect(managed).unwrap_or(llm_adapter::target::OpenAiDialect::ChatCompletions),
}
} else {
ByokEndpoint::ProviderDefault
};
let mut ids = HashSet::new();
let models = managed
.models
.iter()
.map(|model| {
if !ids.insert(model.id.clone()) {
return Err(RuntimeError::invalid_state(
"managed copilot profile models must be unique",
));
}
Ok(ByokModelDeclaration {
model_id: model.id.clone(),
enabled: true,
capabilities: model.capabilities.clone().unwrap_or_default(),
})
})
.collect::<RuntimeResult<Vec<_>>>()?;
let egress_policy = if matches!(endpoint, ByokEndpoint::OpenAiCompatible { .. }) {
EgressPolicy::AllowPrivate
} else {
EgressPolicy::PublicOnly
};
Ok((ByokProfileDefinition { endpoint, models }, egress_policy))
}
fn openai_dialect(managed: &CopilotManagedProfileConfig) -> Option<llm_adapter::target::OpenAiDialect> {
match managed.provider.as_str() {
"openai" => Some(llm_adapter::target::OpenAiDialect::Responses),
"openaiCompatible" => Some(
match managed.config.get("dialect").and_then(serde_json::Value::as_str) {
Some("responses") => llm_adapter::target::OpenAiDialect::Responses,
_ => llm_adapter::target::OpenAiDialect::ChatCompletions,
},
),
_ => None,
}
}
/// Probe a managed profile from the active runtime config without touching
/// per-workspace BYOK storage. Used by the admin console "Test" action.
pub(in crate::runtime::backend_runtime) async fn probe_managed(
config: &BackendRuntimeConfig,
profile_id: &str,
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
let managed = super::context::managed_profile(&config.copilot, profile_id)?;
let (definition, egress_policy) = managed_definition(managed)?;
// Vertex credentials need a token provider; static providers read config.
if matches!(managed.provider.as_str(), "geminiVertex" | "anthropicVertex") {
return Err(RuntimeError::invalid_input(
"probe for vertex managed profiles is not supported; use workspace BYOK",
));
}
let credential = super::dispatch::managed_credential(managed, None).await?;
crate::runtime::backend_runtime::byok::execute_probe_with_policy(
&managed.provider,
&definition,
SensitiveCredential::new(credential.into_bytes()),
egress_policy,
checks,
)
.await
}

View File

@@ -1,5 +1,6 @@
mod context;
mod dispatch;
pub(in crate::runtime::backend_runtime) mod managed_probe;
mod stream;
use std::{

View File

@@ -45,9 +45,9 @@ pub(super) use super::{
napi_error, to_napi_error, webpki_tls_config,
};
use crate::llm::{
ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeResultOutput, ByokProfileOutput, CreateByokLocalLeaseInput,
CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput,
ReplaceByokProfileInput, RotateByokCredentialInput,
ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeCheckInput, ByokProbeResultOutput, ByokProfileOutput,
CreateByokLocalLeaseInput, CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput,
ReorderByokProfilesInput, ReplaceByokProfileInput, RotateByokCredentialInput,
};
pub(super) fn token_hash(token: &str) -> String {
@@ -710,6 +710,18 @@ impl BackendRuntime {
.map_err(to_napi_error)
}
#[napi]
pub async fn probe_managed_copilot_profile(
&self,
profile_id: String,
checks: Vec<ByokProbeCheckInput>,
) -> Result<ByokProbeResultOutput> {
let config = self.config()?;
copilot::managed_probe::probe_managed(&config, &profile_id, checks)
.await
.map_err(to_napi_error)
}
#[napi]
pub async fn delete_byok_profile(&self, workspace_id: String, profile_id: String) -> Result<bool> {
let deleted = byok::delete(&self.pool().await?, &workspace_id, &profile_id)

View File

@@ -5,7 +5,7 @@ use std::{
sync::Arc,
};
use llm_adapter::capability::provider_default_capability_upper_bound;
use llm_adapter::capability::{DeclaredModelCapability, ModelFeature, provider_default_capability_upper_bound};
use serde::Deserialize;
use serde_json::Map;
use sqlx::{PgPool, Row};
@@ -99,9 +99,7 @@ impl ConfigSource {
self.exact() || self.override_path.as_deref() == Some(path)
}
}
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[derive(Clone, Default)]
pub(crate) struct CopilotRuntimeConfig {
pub(crate) enabled: bool,
pub(crate) byok: CopilotByokRuntimeConfig,
@@ -135,25 +133,26 @@ fn default_allowed_providers() -> Vec<String> {
SUPPORTED_BYOK_PROVIDERS.into_iter().map(str::to_string).collect()
}
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[derive(Clone, Default)]
pub(crate) struct CopilotProvidersRuntimeConfig {
pub(crate) profiles: Vec<CopilotManagedProfileConfig>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Clone)]
pub(crate) struct CopilotManagedProfileConfig {
pub(crate) id: String,
#[serde(rename = "type")]
pub(crate) provider: String,
#[serde(default = "enabled_by_default")]
pub(crate) enabled: bool,
#[serde(default)]
pub(crate) models: Vec<String>,
pub(crate) models: Vec<CopilotManagedModel>,
pub(crate) config: serde_json::Value,
}
#[derive(Clone)]
pub(crate) struct CopilotManagedModel {
pub(crate) id: String,
pub(crate) capabilities: Option<Vec<DeclaredModelCapability>>,
}
fn enabled_by_default() -> bool {
true
}
@@ -182,11 +181,44 @@ pub(crate) struct CopilotManagedProfileConfigFile {
priority: Option<f64>,
#[serde(default = "enabled_by_default")]
enabled: bool,
models: Option<Vec<String>>,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
dialect: Option<String>,
models: Option<Vec<CopilotManagedModelConfigFile>>,
middleware: Option<CopilotProviderMiddlewareConfigFile>,
/// Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields.
#[serde(default)]
config: Map<String, serde_json::Value>,
}
#[derive(Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(untagged)]
pub(crate) enum CopilotManagedModelConfigFile {
Id(String),
Declared {
id: String,
#[serde(default)]
enabled: bool,
#[serde(default)]
capabilities: Vec<CopilotManagedCapabilityToken>,
},
}
#[derive(Clone, Copy, PartialEq, Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CopilotManagedCapabilityToken {
Chat,
Tools,
Vision,
Structured,
Embedding,
Rerank,
Image,
}
#[derive(Clone, Copy, Deserialize, serde::Serialize, schemars::JsonSchema)]
enum CopilotManagedProvider {
#[serde(rename = "anthropic")]
@@ -203,6 +235,8 @@ enum CopilotManagedProvider {
GeminiVertex,
#[serde(rename = "openai")]
OpenAi,
#[serde(rename = "openaiCompatible")]
OpenAiCompatible,
}
impl CopilotManagedProvider {
@@ -215,12 +249,14 @@ impl CopilotManagedProvider {
Self::Gemini => "gemini",
Self::GeminiVertex => "geminiVertex",
Self::OpenAi => "openai",
Self::OpenAiCompatible => "openaiCompatible",
}
}
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::OpenAiCompatible => &[],
Self::CloudflareWorkersAi => &["@cf/baai/bge-reranker-base"],
Self::Fal => &["lora/image-to-image", "workflowutils/teed"],
Self::Gemini => &["gemini-3.7-flash", "gemini-embedding-001"],
@@ -273,6 +309,65 @@ enum CopilotNodeTextMiddleware {
ThinkingFormat,
}
impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
type Error = RuntimeError;
fn try_from(value: CopilotManagedProfileConfigFile) -> Result<Self, Self::Error> {
if value.id.is_empty()
|| !value
.id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(RuntimeError::invalid_state(
"managed copilot profile id must contain only letters, numbers, hyphens, and underscores",
));
}
let models = value.models.unwrap_or_else(|| {
value
.provider
.legacy_models()
.into_iter()
.map(|id| CopilotManagedModelConfigFile::Id(id))
.collect()
});
let models = models
.into_iter()
.map(|model| match model {
CopilotManagedModelConfigFile::Id(id) => Ok(CopilotManagedModel { id, capabilities: None }),
CopilotManagedModelConfigFile::Declared {
id,
enabled: _,
capabilities,
} => {
let capabilities = (!capabilities.is_empty())
.then(|| capabilities.iter().map(managed_capability).collect())
.transpose()?;
Ok(CopilotManagedModel { id, capabilities })
}
})
.collect::<RuntimeResult<Vec<_>>>()?;
let mut config = value.config;
if let Some(base_url) = value.base_url {
config.insert("baseURL".to_string(), serde_json::Value::String(base_url));
}
if let Some(api_key) = value.api_key {
config.insert("apiKey".to_string(), serde_json::Value::String(api_key));
}
if let Some(dialect) = value.dialect {
config.insert("dialect".to_string(), serde_json::Value::String(dialect));
}
Ok(Self {
id: value.id,
provider: value.provider.as_str().to_string(),
enabled: value.enabled,
models,
config: serde_json::Value::Object(config),
})
}
}
impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
type Error = RuntimeError;
@@ -292,29 +387,62 @@ impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
}
}
impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
type Error = RuntimeError;
fn try_from(value: CopilotManagedProfileConfigFile) -> Result<Self, Self::Error> {
if value.id.is_empty()
|| !value
.id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(RuntimeError::invalid_state(
"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,
config: serde_json::Value::Object(value.config),
})
}
fn managed_capability(token: &CopilotManagedCapabilityToken) -> RuntimeResult<DeclaredModelCapability> {
use llm_adapter::capability::{AttachmentKind, AttachmentSource, ModelInput, ModelOutput};
let capability = match token {
CopilotManagedCapabilityToken::Chat => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Text],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Tools => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Text],
features: vec![ModelFeature::ToolCalling],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Vision => DeclaredModelCapability {
input: vec![ModelInput::Text, ModelInput::Image],
output: vec![ModelOutput::Text],
features: vec![],
attachment_kinds: vec![AttachmentKind::Image],
attachment_sources: vec![AttachmentSource::Url, AttachmentSource::Data, AttachmentSource::Bytes],
},
CopilotManagedCapabilityToken::Structured => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Structured],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Embedding => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Embedding],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Rerank => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Rerank],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
CopilotManagedCapabilityToken::Image => DeclaredModelCapability {
input: vec![ModelInput::Text],
output: vec![ModelOutput::Image],
features: vec![],
attachment_kinds: vec![],
attachment_sources: vec![],
},
};
llm_adapter::capability::validate_declared_capability(&capability)
.map(|_| capability)
.map_err(|error| RuntimeError::invalid_state(format!("managed copilot profile capability invalid: {error}")))
}
#[derive(Clone, Debug)]
@@ -459,12 +587,17 @@ pub(super) fn validate_copilot_config(config: &CopilotRuntimeConfig) -> RuntimeR
}
let mut models = std::collections::HashSet::new();
for model in &profile.models {
if model.trim().is_empty() || !models.insert(model.as_str()) {
if model.id.trim().is_empty() || !models.insert(model.id.as_str()) {
return Err(RuntimeError::invalid_state(
"managed copilot profile models must be non-empty and unique",
));
}
provider_default_capability_upper_bound(&profile.provider, model)
// openaiCompatible endpoints serve arbitrary model ids; declared or
// probed capabilities replace the built-in catalog lookup.
if profile.provider == "openaiCompatible" {
continue;
}
provider_default_capability_upper_bound(&profile.provider, &model.id)
.ok_or_else(|| RuntimeError::invalid_state("managed copilot profile model is unsupported"))?;
}
}
@@ -854,7 +987,14 @@ mod tests {
.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);
assert_eq!(
copilot.providers.profiles[0]
.models
.iter()
.map(|m| m.id.as_str())
.collect::<Vec<_>>(),
expected_models
);
}
let app_config = app_config_from_flat_overrides([(
@@ -1025,4 +1165,60 @@ mod tests {
Some("workspace_invitation")
);
}
#[test]
fn openai_compatible_profile_accepts_arbitrary_models_and_flat_fields() {
let app_config = app_config_from_module_json(serde_json::json!({
"copilot": {
"enabled": true,
"providers": {
"profiles": [{
"id": "my-vllm",
"type": "openaiCompatible",
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKey": "sk-test",
"models": [
"qwen3-32b",
{ "id": "bge-m3", "capabilities": ["embedding"] }
]
}]
}
}
}))
.unwrap();
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
validate_copilot_config(&copilot).unwrap();
let profile = &copilot.providers.profiles[0];
assert_eq!(profile.provider, "openaiCompatible");
assert_eq!(profile.models.len(), 2);
assert_eq!(profile.models[0].id, "qwen3-32b");
assert!(profile.models[0].capabilities.is_none());
assert_eq!(
profile.config.get("baseURL").and_then(serde_json::Value::as_str),
Some("http://127.0.0.1:8000/v1")
);
assert_eq!(
profile.config.get("apiKey").and_then(serde_json::Value::as_str),
Some("sk-test")
);
let declared = profile.models[1].capabilities.as_ref().unwrap();
use llm_adapter::capability::ModelOutput;
assert!(
declared
.iter()
.any(|capability| capability.output.contains(&ModelOutput::Embedding))
);
// unknown provider types are rejected at deserialization, not validation
let app_config = app_config_from_flat_overrides([(
"copilot.providers.profiles",
serde_json::json!([{ "id": "x", "type": "totally-unknown-provider", "models": ["any"] }]),
)]);
assert!(
app_config.is_err(),
"unknown provider type must be rejected at deserialization"
);
}
}

View File

@@ -69,7 +69,7 @@ fn descriptors() -> Vec<AppConfigDescriptor> {
description: "The profile list for copilot providers.".to_string(),
default_value: json!(defaults.providers.profiles),
schema: schema_for::<Vec<CopilotManagedProfileConfigFile>>(),
internal: true,
internal: false,
},
]
}
@@ -161,7 +161,7 @@ mod tests {
]
);
assert_eq!(descriptors[0].default_value, json!(true));
assert!(descriptors[4].internal);
assert!(!descriptors[4].internal);
assert!(
validate_app_config_value(
"copilot".to_string(),
@@ -218,4 +218,35 @@ mod tests {
);
}
}
#[test]
fn openai_compatible_profiles_validate_with_flat_fields_and_arbitrary_models() {
assert!(
validate_app_config_value(
"copilot".to_string(),
"providers.profiles".to_string(),
json!([{
"id": "my-vllm",
"type": "openaiCompatible",
"enabled": true,
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKey": "sk-test",
"dialect": "chat_completions",
"models": ["qwen3-32b", { "id": "bge-m3", "capabilities": ["embedding"] }]
}]),
)
.unwrap()
.is_empty()
);
// unknown model ids on catalog providers still fail
assert!(
!validate_app_config_value(
"copilot".to_string(),
"providers.profiles".to_string(),
json!([{ "id": "x", "type": "openai", "models": ["not-in-catalog"] }]),
)
.unwrap()
.is_empty()
);
}
}