mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714
# Conflicts: # apps/aether-gateway/src/handlers/admin/request/provider/tasks.rs # frontend/src/api/endpoints/pool.ts
This commit is contained in:
@@ -104,6 +104,7 @@ fn is_known_admin_monitoring_api_format(value: &str) -> bool {
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "openai:image"
|
||||
| "openai:video"
|
||||
| "openai:embedding"
|
||||
@@ -492,6 +493,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_v2_scheduler_affinity_key_for_openai_search() {
|
||||
let parsed = parse_admin_monitoring_scheduler_affinity_key(
|
||||
"scheduler_affinity:v2:user-key-1:openai:search:gpt-5.6-sol:codex:sessionhash",
|
||||
)
|
||||
.expect("Search scheduler key should parse");
|
||||
|
||||
assert_eq!(parsed.affinity_key, "user-key-1");
|
||||
assert_eq!(parsed.api_format, "openai:search");
|
||||
assert_eq!(parsed.model_name, "gpt-5.6-sol");
|
||||
assert_eq!(parsed.client_family.as_deref(), Some("codex"));
|
||||
assert_eq!(parsed.session_hash.as_deref(), Some("sessionhash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_v2_scheduler_affinity_key_with_three_segment_api_format() {
|
||||
let parsed = parse_admin_monitoring_scheduler_affinity_key(
|
||||
|
||||
@@ -259,6 +259,11 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
||||
"has_format_conversion",
|
||||
),
|
||||
slow_threshold_ms,
|
||||
include_timeline: query_param_optional_bool(
|
||||
request_context.query_string(),
|
||||
"include_timeline",
|
||||
)
|
||||
.unwrap_or(true),
|
||||
})
|
||||
.await?;
|
||||
return Ok(Some(build_admin_stats_provider_performance_response(
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderCreateRequest, AdminProviderUpdatePatch,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::provider::{
|
||||
reconcile_admin_fixed_provider_template_endpoints, reconcile_admin_fixed_provider_template_keys,
|
||||
reconcile_admin_fixed_provider_template_endpoints,
|
||||
reconcile_admin_fixed_provider_template_endpoints_after_update,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
@@ -80,7 +81,6 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
|
||||
.is_some()
|
||||
{
|
||||
reconcile_admin_fixed_provider_template_endpoints(state, &created_provider).await?;
|
||||
reconcile_admin_fixed_provider_template_keys(state, &created_provider).await?;
|
||||
}
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
@@ -161,8 +161,12 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
|
||||
.fixed_provider_template(&updated_record.provider_type)
|
||||
.is_some()
|
||||
{
|
||||
reconcile_admin_fixed_provider_template_endpoints(state, &updated_record).await?;
|
||||
reconcile_admin_fixed_provider_template_keys(state, &updated_record).await?;
|
||||
reconcile_admin_fixed_provider_template_endpoints_after_update(
|
||||
state,
|
||||
&existing_provider,
|
||||
&updated_record,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
return Ok(Some(
|
||||
match state
|
||||
|
||||
@@ -51,14 +51,6 @@ fn merge_codex_quota_metadata(
|
||||
serde_json::Value::Object(merged)
|
||||
}
|
||||
|
||||
fn codex_reset_credits_available_count(metadata: &Map<String, Value>) -> Option<u64> {
|
||||
metadata
|
||||
.get("reset_credits")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|reset_credits| reset_credits.get("available_count"))
|
||||
.and_then(aether_admin::provider::quota::coerce_json_u64)
|
||||
}
|
||||
|
||||
fn truncate_codex_reset_credit_detail_error(message: impl Into<String>) -> String {
|
||||
let message = message.into();
|
||||
let mut sanitized = message.replace('\n', " ");
|
||||
@@ -85,11 +77,7 @@ fn merge_codex_reset_credit_detail_metadata(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let has_usage_available_count = reset_credits.contains_key("available_count");
|
||||
for (key, value) in detail_reset_credits {
|
||||
if key == "available_count" && has_usage_available_count {
|
||||
continue;
|
||||
}
|
||||
reset_credits.insert(key.clone(), value.clone());
|
||||
}
|
||||
codex_metadata.insert("reset_credits".to_string(), Value::Object(reset_credits));
|
||||
@@ -97,6 +85,7 @@ fn merge_codex_reset_credit_detail_metadata(
|
||||
|
||||
fn mark_codex_reset_credit_detail_failed(
|
||||
codex_metadata: &mut Map<String, Value>,
|
||||
updated_at_unix_secs: u64,
|
||||
detail_error: impl Into<String>,
|
||||
) {
|
||||
let mut reset_credits = codex_metadata
|
||||
@@ -104,6 +93,7 @@ fn mark_codex_reset_credit_detail_failed(
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
reset_credits.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
reset_credits.insert("detail_source".to_string(), json!("wham_readonly"));
|
||||
reset_credits.insert("detail_status".to_string(), json!("failed"));
|
||||
reset_credits.insert(
|
||||
@@ -124,16 +114,11 @@ async fn enrich_codex_reset_credit_details(
|
||||
codex_metadata: &mut Map<String, Value>,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<(), GatewayError> {
|
||||
let available_count = codex_reset_credits_available_count(codex_metadata).unwrap_or(0);
|
||||
if available_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let request_spec = match build_codex_reset_credits_request_spec(transport, resolved_oauth_auth)
|
||||
{
|
||||
Ok(request_spec) => request_spec,
|
||||
Err(message) => {
|
||||
mark_codex_reset_credit_detail_failed(codex_metadata, message);
|
||||
mark_codex_reset_credit_detail_failed(codex_metadata, now_unix_secs, message);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -146,6 +131,7 @@ async fn enrich_codex_reset_credit_details(
|
||||
ProviderQuotaExecutionOutcome::Failure(detail) => {
|
||||
mark_codex_reset_credit_detail_failed(
|
||||
codex_metadata,
|
||||
now_unix_secs,
|
||||
format!("reset credit detail 请求执行失败: {detail}"),
|
||||
);
|
||||
return Ok(());
|
||||
@@ -157,6 +143,7 @@ async fn enrich_codex_reset_credit_details(
|
||||
.unwrap_or_else(|| format!("HTTP {}", result.status_code));
|
||||
mark_codex_reset_credit_detail_failed(
|
||||
codex_metadata,
|
||||
now_unix_secs,
|
||||
format!(
|
||||
"reset credit detail 返回状态码 {}: {detail}",
|
||||
result.status_code
|
||||
@@ -170,7 +157,11 @@ async fn enrich_codex_reset_credit_details(
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
else {
|
||||
mark_codex_reset_credit_detail_failed(codex_metadata, "无法解析 reset credit detail 响应");
|
||||
mark_codex_reset_credit_detail_failed(
|
||||
codex_metadata,
|
||||
now_unix_secs,
|
||||
"无法解析 reset credit detail 响应",
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(detail_metadata) =
|
||||
@@ -178,7 +169,11 @@ async fn enrich_codex_reset_credit_details(
|
||||
{
|
||||
merge_codex_reset_credit_detail_metadata(codex_metadata, &detail_metadata);
|
||||
} else {
|
||||
mark_codex_reset_credit_detail_failed(codex_metadata, "reset credit detail 响应为空");
|
||||
mark_codex_reset_credit_detail_failed(
|
||||
codex_metadata,
|
||||
now_unix_secs,
|
||||
"reset credit detail 响应为空",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -776,3 +771,52 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
"auto_removed_hard_banned": auto_removed_hard_banned_count,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codex_reset_credit_detail_count_overrides_usage_count() {
|
||||
let mut metadata = json!({
|
||||
"reset_credits": {
|
||||
"available_count": 0,
|
||||
"detail_source": "wham_usage"
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("metadata object");
|
||||
let detail = json!({
|
||||
"reset_credits": {
|
||||
"available_count": 2,
|
||||
"detail_source": "wham_readonly"
|
||||
}
|
||||
});
|
||||
|
||||
merge_codex_reset_credit_detail_metadata(&mut metadata, &detail);
|
||||
|
||||
assert_eq!(
|
||||
metadata
|
||||
.get("reset_credits")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|credits| credits.get("available_count")),
|
||||
Some(&json!(2u64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reset_credit_detail_failure_records_attempt_time() {
|
||||
let mut metadata = Map::new();
|
||||
|
||||
mark_codex_reset_credit_detail_failed(&mut metadata, 1_777_000_000, "request failed");
|
||||
|
||||
assert_eq!(
|
||||
metadata
|
||||
.get("reset_credits")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|credits| credits.get("updated_at")),
|
||||
Some(&json!(1_777_000_000u64))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,7 +600,7 @@ mod tests {
|
||||
};
|
||||
use crate::AppState;
|
||||
use aether_runtime_state::{RedisClientConfig, RuntimeState, RuntimeStateConfig};
|
||||
use aether_testkit::ManagedRedisServer;
|
||||
use aether_test_support::ManagedRedisServer;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
|
||||
|
||||
@@ -55,13 +55,6 @@ pub(super) async fn build_admin_pool_batch_import_response(
|
||||
}
|
||||
};
|
||||
|
||||
if payload.keys.len() > 500 {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"keys length must be less than or equal to 500",
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.build_admin_pool_batch_import_response(&provider_id, payload)
|
||||
.await
|
||||
|
||||
@@ -64,7 +64,8 @@ use aether_data_contracts::repository::provider_catalog::{
|
||||
};
|
||||
use aether_model_fetch::{
|
||||
aggregate_models_for_cache, fetch_models_from_transports, json_string_list,
|
||||
merge_upstream_metadata, preset_models_for_provider, selected_models_fetch_endpoints,
|
||||
model_catalog_upstream_metadata, preset_models_for_provider, selected_models_fetch_endpoints,
|
||||
upstream_metadata_namespace_updates,
|
||||
};
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
@@ -301,6 +302,7 @@ fn provider_query_attach_model_test_capabilities(
|
||||
|
||||
fn provider_query_codex_preset_fallback(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
fetch_error: &str,
|
||||
) -> Option<ProviderQueryKeyFetchResult> {
|
||||
if !provider.provider_type.trim().eq_ignore_ascii_case("codex") {
|
||||
return None;
|
||||
@@ -309,12 +311,59 @@ fn provider_query_codex_preset_fallback(
|
||||
Some(ProviderQueryKeyFetchResult {
|
||||
models: aggregate_models_for_cache(&models),
|
||||
error: None,
|
||||
warning: None,
|
||||
warning: Some(format!(
|
||||
"Codex 动态模型目录不可用,已使用内置模型卡:{fetch_error}"
|
||||
)),
|
||||
from_cache: false,
|
||||
has_success: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn provider_query_persist_preset_models(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
key: &StoredProviderCatalogKey,
|
||||
models: &[Value],
|
||||
) -> Result<(), GatewayError> {
|
||||
if models.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
<AppState as ModelFetchRuntimeState>::write_upstream_models_cache(
|
||||
state.app(),
|
||||
&provider.id,
|
||||
&key.id,
|
||||
models,
|
||||
)
|
||||
.await;
|
||||
if let Some(catalog_metadata) = model_catalog_upstream_metadata(&provider.provider_type, models)
|
||||
{
|
||||
provider_query_persist_upstream_metadata(state, key, &catalog_metadata).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn provider_query_persist_upstream_metadata(
|
||||
state: &AdminAppState<'_>,
|
||||
key: &StoredProviderCatalogKey,
|
||||
upstream_metadata: &Value,
|
||||
) -> Result<(), GatewayError> {
|
||||
let updated_at = current_unix_secs();
|
||||
for (namespace, value) in
|
||||
upstream_metadata_namespace_updates(key.upstream_metadata.as_ref(), upstream_metadata)
|
||||
{
|
||||
state
|
||||
.app()
|
||||
.upsert_provider_catalog_key_upstream_metadata_namespace(
|
||||
&key.id,
|
||||
&namespace,
|
||||
&value,
|
||||
Some(updated_at),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod model_test;
|
||||
|
||||
pub(crate) use self::model_test::{
|
||||
@@ -459,11 +508,9 @@ async fn provider_query_fetch_models_for_key(
|
||||
let selected_endpoints = selected_models_fetch_endpoints(endpoints, key);
|
||||
if selected_endpoints.is_empty() {
|
||||
if let Some(models) = preset_models_for_provider(&provider.provider_type) {
|
||||
let models = provider_query_filter_models_for_key(
|
||||
provider,
|
||||
key,
|
||||
aggregate_models_for_cache(&models),
|
||||
);
|
||||
let models = aggregate_models_for_cache(&models);
|
||||
provider_query_persist_preset_models(state, provider, key, &models).await?;
|
||||
let models = provider_query_filter_models_for_key(provider, key, models);
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
models,
|
||||
error: None,
|
||||
@@ -512,7 +559,11 @@ async fn provider_query_fetch_models_for_key(
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
all_errors.push(err);
|
||||
if let Some(fallback) = provider_query_codex_preset_fallback(provider) {
|
||||
if let Some(fallback) =
|
||||
provider_query_codex_preset_fallback(provider, &all_errors.join("; "))
|
||||
{
|
||||
provider_query_persist_preset_models(state, provider, key, &fallback.models)
|
||||
.await?;
|
||||
return Ok(fallback);
|
||||
}
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
@@ -537,20 +588,14 @@ async fn provider_query_fetch_models_for_key(
|
||||
.await;
|
||||
}
|
||||
if let Some(upstream_metadata) = outcome.upstream_metadata.as_ref() {
|
||||
let merged_metadata =
|
||||
merge_upstream_metadata(key.upstream_metadata.as_ref(), upstream_metadata);
|
||||
state
|
||||
.app()
|
||||
.update_provider_catalog_key_upstream_metadata(
|
||||
&key.id,
|
||||
Some(&merged_metadata),
|
||||
Some(current_unix_secs()),
|
||||
)
|
||||
.await?;
|
||||
provider_query_persist_upstream_metadata(state, key, upstream_metadata).await?;
|
||||
}
|
||||
|
||||
if unique_models.is_empty() && !all_errors.is_empty() {
|
||||
if let Some(fallback) = provider_query_codex_preset_fallback(provider) {
|
||||
if let Some(fallback) =
|
||||
provider_query_codex_preset_fallback(provider, &all_errors.join("; "))
|
||||
{
|
||||
provider_query_persist_preset_models(state, provider, key, &fallback.models).await?;
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,7 @@ use crate::handlers::shared::{
|
||||
provider_key_status_snapshot_payload,
|
||||
};
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::provider_key_auth::{
|
||||
provider_key_auth_semantics, provider_key_configured_api_formats,
|
||||
provider_key_inherits_provider_api_formats,
|
||||
};
|
||||
use crate::provider_key_auth::provider_key_auth_semantics;
|
||||
use crate::provider_transport::antigravity::{
|
||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||
@@ -543,6 +540,22 @@ fn provider_query_build_test_request_body_for_api_format(
|
||||
model: &str,
|
||||
route_path: &str,
|
||||
client_api_format: &str,
|
||||
) -> Value {
|
||||
provider_query_build_test_request_body_for_api_format_with_search_session(
|
||||
payload,
|
||||
model,
|
||||
route_path,
|
||||
client_api_format,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_query_build_test_request_body_for_api_format_with_search_session(
|
||||
payload: &Value,
|
||||
model: &str,
|
||||
route_path: &str,
|
||||
client_api_format: &str,
|
||||
search_session_id: Option<&str>,
|
||||
) -> Value {
|
||||
let client_api_format = provider_query_normalize_api_format_alias(client_api_format);
|
||||
let override_custom_model = route_path.ends_with("/test-model-failover")
|
||||
@@ -568,7 +581,7 @@ fn provider_query_build_test_request_body_for_api_format(
|
||||
);
|
||||
} else if matches!(
|
||||
client_api_format.as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search"
|
||||
) && !value_has_non_empty_text(object.get("input"))
|
||||
{
|
||||
if let Some(prompt) = object
|
||||
@@ -580,7 +593,7 @@ fn provider_query_build_test_request_body_for_api_format(
|
||||
}
|
||||
if matches!(
|
||||
client_api_format.as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search"
|
||||
) && value_has_non_empty_text(object.get("input"))
|
||||
{
|
||||
object.remove("prompt");
|
||||
@@ -590,6 +603,9 @@ fn provider_query_build_test_request_body_for_api_format(
|
||||
{
|
||||
object.remove("messages");
|
||||
}
|
||||
if client_api_format == "openai:search" {
|
||||
provider_query_ensure_search_test_fields(object, payload, search_session_id);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -620,6 +636,15 @@ fn provider_query_build_test_request_body_for_api_format(
|
||||
"temperature": 0.7,
|
||||
"stream": true,
|
||||
}),
|
||||
"openai:search" => json!({
|
||||
"id": provider_query_search_test_session_id(search_session_id),
|
||||
"model": model,
|
||||
"input": message,
|
||||
"commands": {
|
||||
"search_query": [{"q": message}]
|
||||
},
|
||||
"max_output_tokens": 256,
|
||||
}),
|
||||
"claude:messages" => json!({
|
||||
"model": model,
|
||||
"messages": [{
|
||||
@@ -680,7 +705,7 @@ fn provider_query_insert_default_test_conversation(
|
||||
.entry("top_n".to_string())
|
||||
.or_insert_with(|| Value::from(4_u64));
|
||||
}
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search" => {
|
||||
object.insert("input".to_string(), Value::String(message));
|
||||
}
|
||||
"claude:messages" => {
|
||||
@@ -698,6 +723,48 @@ fn provider_query_insert_default_test_conversation(
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_query_search_test_session_id(search_session_id: Option<&str>) -> String {
|
||||
search_session_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("aether-model-test-{value}"))
|
||||
.unwrap_or_else(|| format!("aether-model-test-{}", Uuid::new_v4().simple()))
|
||||
}
|
||||
|
||||
fn provider_query_ensure_search_test_fields(
|
||||
object: &mut Map<String, Value>,
|
||||
payload: &Value,
|
||||
search_session_id: Option<&str>,
|
||||
) {
|
||||
let query = object
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| provider_query_extract_message(payload))
|
||||
.unwrap_or_else(|| DEFAULT_PROVIDER_QUERY_TEST_MESSAGE.to_string());
|
||||
let has_session_id = object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !has_session_id {
|
||||
object.insert(
|
||||
"id".to_string(),
|
||||
provider_query_search_test_session_id(search_session_id).into(),
|
||||
);
|
||||
}
|
||||
object
|
||||
.entry("input".to_string())
|
||||
.or_insert_with(|| Value::String(query.clone()));
|
||||
object
|
||||
.entry("commands".to_string())
|
||||
.or_insert_with(|| json!({"search_query": [{"q": query}]}));
|
||||
object
|
||||
.entry("max_output_tokens".to_string())
|
||||
.or_insert_with(|| Value::from(256_u64));
|
||||
}
|
||||
|
||||
fn provider_query_grok_test_client_api_format(provider_api_format: &str) -> &'static str {
|
||||
match provider_query_normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => "openai:responses",
|
||||
@@ -767,7 +834,7 @@ fn provider_query_request_body_has_conversation_for_api_format(
|
||||
client_api_format: &str,
|
||||
) -> bool {
|
||||
match provider_query_normalize_api_format_alias(client_api_format).as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search" => {
|
||||
value_has_non_empty_text(body.get("input"))
|
||||
|| value_has_non_empty_text(body.get("prompt"))
|
||||
}
|
||||
@@ -828,14 +895,12 @@ fn provider_query_resolve_standard_test_upstream_is_stream(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
let hard_requires_streaming = crate::ai_serving::force_upstream_streaming_for_provider(
|
||||
crate::ai_serving::resolve_upstream_is_stream_for_provider(
|
||||
endpoint_config,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
);
|
||||
crate::ai_serving::resolve_upstream_is_stream_from_endpoint_config(
|
||||
endpoint_config,
|
||||
false,
|
||||
hard_requires_streaming,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -876,15 +941,11 @@ fn provider_query_key_supports_endpoint(
|
||||
provider_type: &str,
|
||||
endpoint_api_format: &str,
|
||||
) -> bool {
|
||||
if provider_key_inherits_provider_api_formats(key, provider_type) {
|
||||
return true;
|
||||
}
|
||||
let formats = provider_key_configured_api_formats(key);
|
||||
let endpoint_api_format = provider_query_normalize_api_format_alias(endpoint_api_format);
|
||||
formats.is_empty()
|
||||
|| formats
|
||||
.iter()
|
||||
.any(|value| provider_query_normalize_api_format_alias(value) == endpoint_api_format)
|
||||
crate::handlers::shared::provider_catalog_key_supports_format(
|
||||
key,
|
||||
provider_type,
|
||||
endpoint_api_format,
|
||||
)
|
||||
}
|
||||
|
||||
async fn provider_query_select_preferred_non_kiro_endpoint(
|
||||
@@ -1626,6 +1687,15 @@ fn provider_query_standard_execution_response_body(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if result.status_code < 400
|
||||
&& provider_query_normalize_api_format_alias(provider_api_format) == "openai:search"
|
||||
&& !body
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(body)
|
||||
}
|
||||
|
||||
@@ -2104,8 +2174,18 @@ async fn provider_query_execute_openai_image_test_candidate(
|
||||
route_path,
|
||||
);
|
||||
let incoming_request_headers = provider_query_extract_request_headers(payload);
|
||||
let image_request_path = if request_body.get("image").is_some()
|
||||
|| request_body
|
||||
.get("images")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|images| !images.is_empty())
|
||||
{
|
||||
"/v1/images/edits"
|
||||
} else {
|
||||
"/v1/images/generations"
|
||||
};
|
||||
let mut synthetic_request = http::Request::builder()
|
||||
.uri("/v1/images/generations")
|
||||
.uri(image_request_path)
|
||||
.body(())
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
*synthetic_request.headers_mut() = incoming_request_headers;
|
||||
@@ -2116,11 +2196,18 @@ async fn provider_query_execute_openai_image_test_candidate(
|
||||
&parts,
|
||||
&request_body,
|
||||
None,
|
||||
provider_query_openai_image_normalize_options(provider_type),
|
||||
provider_query_openai_image_normalize_options(
|
||||
provider_type,
|
||||
Some(candidate.effective_model.as_str()),
|
||||
),
|
||||
) else {
|
||||
return Ok(provider_query_skipped_execution_outcome(
|
||||
request_body.clone(),
|
||||
provider_query_openai_image_normalize_failure_message(provider_type, &request_body),
|
||||
provider_query_openai_image_normalize_failure_message(
|
||||
provider_type,
|
||||
Some(candidate.effective_model.as_str()),
|
||||
&request_body,
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
@@ -2139,29 +2226,50 @@ async fn provider_query_execute_openai_image_test_candidate(
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex");
|
||||
let mut provider_request_body = if is_chatgpt_web {
|
||||
match crate::ai_serving::build_chatgpt_web_image_request_body(&parts, &request_body, None) {
|
||||
Ok(body) => body,
|
||||
Err(err) => err.to_error_json(),
|
||||
}
|
||||
} else if is_codex || is_grok {
|
||||
crate::ai_serving::build_openai_image_provider_request_body(&normalized_request)
|
||||
let upstream_is_stream = crate::ai_serving::resolve_upstream_is_stream_for_provider(
|
||||
transport.endpoint.config.as_ref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
"openai:image",
|
||||
request_body
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
false,
|
||||
);
|
||||
let provider_request_body = if is_chatgpt_web {
|
||||
Some(
|
||||
match crate::ai_serving::build_chatgpt_web_image_request_body(
|
||||
&parts,
|
||||
&request_body,
|
||||
None,
|
||||
) {
|
||||
Ok(body) => body,
|
||||
Err(err) => err.to_error_json(),
|
||||
},
|
||||
)
|
||||
} else if is_codex {
|
||||
crate::ai_serving::build_codex_openai_image_api_provider_request_body(
|
||||
&normalized_request,
|
||||
Some(candidate.effective_model.as_str()),
|
||||
upstream_is_stream,
|
||||
)
|
||||
} else if is_grok {
|
||||
Some(crate::ai_serving::build_openai_image_provider_request_body(
|
||||
&normalized_request,
|
||||
))
|
||||
} else {
|
||||
crate::ai_serving::build_openai_image_api_provider_request_body(
|
||||
&normalized_request,
|
||||
Some(candidate.effective_model.as_str()),
|
||||
upstream_is_stream,
|
||||
)
|
||||
};
|
||||
if !is_chatgpt_web {
|
||||
crate::ai_serving::apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
"openai:image",
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
Some(candidate.key.id.as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(provider_request_body) = provider_request_body else {
|
||||
return Ok(provider_query_skipped_execution_outcome(
|
||||
request_body,
|
||||
"Provider request is outside the Codex Images contract",
|
||||
));
|
||||
};
|
||||
let oauth_auth = state.resolve_local_oauth_header_auth(&transport).await?;
|
||||
let Some((auth_header, auth_value)) =
|
||||
crate::provider_transport::resolve_openai_image_auth(&transport).or(oauth_auth)
|
||||
@@ -2193,10 +2301,12 @@ async fn provider_query_execute_openai_image_test_candidate(
|
||||
headers: &parts.headers,
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
accept: if is_codex || is_chatgpt_web {
|
||||
"text/event-stream"
|
||||
accept: if is_codex {
|
||||
None
|
||||
} else if upstream_is_stream {
|
||||
Some("text/event-stream")
|
||||
} else {
|
||||
"application/json"
|
||||
Some("application/json")
|
||||
},
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
@@ -2221,7 +2331,7 @@ async fn provider_query_execute_openai_image_test_candidate(
|
||||
request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
|
||||
} else if is_grok {
|
||||
} else {
|
||||
crate::ai_serving::apply_codex_openai_responses_special_headers(
|
||||
crate::ai_serving::apply_codex_openai_special_headers(
|
||||
&mut request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
@@ -2260,13 +2370,9 @@ async fn provider_query_execute_openai_image_test_candidate(
|
||||
};
|
||||
let request_url = provider_query_openai_image_test_upstream_url(
|
||||
&transport,
|
||||
Some(parts.uri.path()),
|
||||
Some(image_request_path),
|
||||
parts.uri.query(),
|
||||
);
|
||||
let upstream_is_stream = provider_request_body
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
|
||||
let plan = ExecutionPlan {
|
||||
request_id: trace_id.to_string(),
|
||||
@@ -2842,12 +2948,14 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
crate::ai_serving::normalize_api_format_alias(provider_api_format);
|
||||
let client_api_format =
|
||||
provider_query_standard_test_client_api_format(normalized_provider_api_format.as_str());
|
||||
let original_request_body = provider_query_build_test_request_body_for_api_format(
|
||||
payload,
|
||||
&candidate.effective_model,
|
||||
route_path,
|
||||
client_api_format,
|
||||
);
|
||||
let original_request_body =
|
||||
provider_query_build_test_request_body_for_api_format_with_search_session(
|
||||
payload,
|
||||
&candidate.effective_model,
|
||||
route_path,
|
||||
client_api_format,
|
||||
Some(trace_id),
|
||||
);
|
||||
if crate::provider_transport::is_windsurf_provider_transport(&transport)
|
||||
&& provider_query_normalize_api_format_alias(candidate.endpoint.api_format.as_str())
|
||||
== "openai:chat"
|
||||
@@ -2992,6 +3100,45 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
);
|
||||
provider_request_body
|
||||
}
|
||||
"openai:search" => {
|
||||
let Some(mut provider_request_body) =
|
||||
crate::provider_transport::build_same_format_provider_request_body(
|
||||
crate::provider_transport::SameFormatProviderRequestBodyInput {
|
||||
body_json: &request_body,
|
||||
mapped_model: request_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
source_model: request_body.get("model").and_then(Value::as_str),
|
||||
family: crate::provider_transport::SameFormatProviderFamily::Standard,
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
request_headers: Some(&incoming_request_headers),
|
||||
upstream_is_stream,
|
||||
force_body_stream_field: require_body_stream_field,
|
||||
kiro_auth_config: None,
|
||||
is_claude_code: false,
|
||||
enable_model_directives: false,
|
||||
},
|
||||
)
|
||||
else {
|
||||
return Ok(provider_query_skipped_execution_outcome(
|
||||
request_body.clone(),
|
||||
format!("Provider request body could not be built for {provider_api_format}"),
|
||||
));
|
||||
};
|
||||
if let Err(err) = crate::provider_transport::apply_transport_request_body_semantics(
|
||||
&mut provider_request_body,
|
||||
&transport,
|
||||
normalized_provider_api_format.as_str(),
|
||||
) {
|
||||
return Ok(provider_query_skipped_execution_outcome(
|
||||
provider_request_body,
|
||||
format!(
|
||||
"Provider request body is not compatible with transport semantics: {err}"
|
||||
),
|
||||
));
|
||||
}
|
||||
provider_request_body
|
||||
}
|
||||
"openai:embedding"
|
||||
| "gemini:embedding"
|
||||
| "jina:embedding"
|
||||
@@ -3046,6 +3193,37 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
);
|
||||
let source_model = provider_query_request_body_model(&request_body, request_model);
|
||||
let codex_model_capabilities = crate::ai_serving::codex_model_capabilities_for_transport(
|
||||
&transport,
|
||||
provider_api_format,
|
||||
request_model,
|
||||
source_model,
|
||||
);
|
||||
if matches!(
|
||||
normalized_provider_api_format.as_str(),
|
||||
"openai:chat" | "openai:responses" | "openai:responses:compact" | "openai:search"
|
||||
) && crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut provider_request_body,
|
||||
crate::ai_serving::OpenAiProviderRequestFinalization {
|
||||
source_api_format: client_api_format,
|
||||
provider_api_format,
|
||||
provider_type: transport.provider.provider_type.as_str(),
|
||||
provider_model: request_model,
|
||||
source_model,
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
},
|
||||
codex_model_capabilities.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Ok(provider_query_skipped_execution_outcome(
|
||||
provider_request_body,
|
||||
"Provider request body violates the OpenAI provider contract",
|
||||
));
|
||||
}
|
||||
if crate::provider_transport::is_gemini_cli_provider_transport(&transport)
|
||||
&& normalized_provider_api_format == "gemini:generate_content"
|
||||
{
|
||||
@@ -3122,6 +3300,7 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
| "openai:embedding"
|
||||
@@ -3137,6 +3316,7 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "openai:embedding"
|
||||
| "jina:embedding"
|
||||
| "doubao:embedding"
|
||||
@@ -3202,7 +3382,7 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
),
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search" => {
|
||||
crate::provider_transport::auth::build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
auth_header.as_deref().unwrap_or_default(),
|
||||
@@ -3268,8 +3448,10 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
response_body: None,
|
||||
});
|
||||
}
|
||||
if crate::ai_serving::is_openai_responses_format(provider_api_format) {
|
||||
crate::ai_serving::apply_codex_openai_responses_special_headers(
|
||||
if crate::ai_serving::is_openai_responses_family_format(provider_api_format)
|
||||
|| crate::ai_serving::api_format_alias_matches(provider_api_format, "openai:search")
|
||||
{
|
||||
crate::ai_serving::apply_codex_openai_special_headers(
|
||||
&mut request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
@@ -3278,9 +3460,18 @@ async fn provider_query_execute_standard_test_candidate(
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
crate::provider_transport::apply_local_auth_config_header_overrides(
|
||||
let final_provider_model = provider_request_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(request_model);
|
||||
crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities(
|
||||
&mut request_headers,
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
Some(&provider_request_body),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
final_provider_model,
|
||||
source_model,
|
||||
codex_model_capabilities.as_ref(),
|
||||
);
|
||||
}
|
||||
if !uses_vertex_query_auth {
|
||||
|
||||
@@ -33,6 +33,8 @@ pub(super) fn provider_query_standard_test_client_api_format(
|
||||
let normalized_api_format = crate::ai_serving::normalize_api_format_alias(provider_api_format);
|
||||
if normalized_api_format == "openai:responses:compact" {
|
||||
"openai:responses:compact"
|
||||
} else if normalized_api_format == "openai:search" {
|
||||
"openai:search"
|
||||
} else if crate::ai_serving::is_embedding_api_format(&normalized_api_format) {
|
||||
"openai:embedding"
|
||||
} else if crate::ai_serving::is_rerank_api_format(&normalized_api_format) {
|
||||
@@ -71,6 +73,7 @@ pub(super) fn provider_query_standard_test_unsupported_reason(
|
||||
}
|
||||
"openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "claude:messages"
|
||||
| "openai:embedding"
|
||||
| "jina:embedding"
|
||||
@@ -261,6 +264,7 @@ pub(super) fn provider_query_test_adapter_for_provider_api_format(
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
| "gemini:interactions"
|
||||
@@ -365,6 +369,7 @@ pub(super) fn provider_query_transport_supports_model_test_execution(
|
||||
}
|
||||
"openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "openai:embedding"
|
||||
| "jina:embedding"
|
||||
| "doubao:embedding"
|
||||
|
||||
+6
-2
@@ -9,16 +9,19 @@ pub(super) struct ProviderQueryOpenAiImageTestCapability(AdminProviderOpenAiImag
|
||||
|
||||
pub(super) fn provider_query_openai_image_test_capability(
|
||||
provider_type: &str,
|
||||
provider_model: Option<&str>,
|
||||
) -> ProviderQueryOpenAiImageTestCapability {
|
||||
ProviderQueryOpenAiImageTestCapability(admin_provider_openai_image_test_capability(
|
||||
provider_type,
|
||||
provider_model,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn provider_query_openai_image_normalize_options(
|
||||
provider_type: &str,
|
||||
provider_model: Option<&str>,
|
||||
) -> crate::ai_serving::OpenAiImageNormalizeOptions {
|
||||
admin_provider_openai_image_normalize_options(provider_type)
|
||||
admin_provider_openai_image_normalize_options(provider_type, provider_model)
|
||||
}
|
||||
|
||||
pub(super) fn provider_query_openai_image_requested_count(request_body: &Value) -> Option<u64> {
|
||||
@@ -35,9 +38,10 @@ pub(super) fn provider_query_openai_image_requested_count(request_body: &Value)
|
||||
|
||||
pub(super) fn provider_query_openai_image_normalize_failure_message(
|
||||
provider_type: &str,
|
||||
provider_model: Option<&str>,
|
||||
request_body: &Value,
|
||||
) -> String {
|
||||
let capability = provider_query_openai_image_test_capability(provider_type);
|
||||
let capability = provider_query_openai_image_test_capability(provider_type, provider_model);
|
||||
if provider_query_openai_image_requested_count(request_body)
|
||||
.is_some_and(|value| !capability.0.supports_generation_count(value))
|
||||
{
|
||||
|
||||
+57
-2
@@ -101,8 +101,7 @@ fn provider_query_model_mapping_matches_endpoint(
|
||||
) -> bool {
|
||||
let api_format_matches = mapping.api_formats.as_ref().is_none_or(|api_formats| {
|
||||
api_formats.iter().any(|value| {
|
||||
aether_scheduler_core::normalize_api_format(value)
|
||||
== aether_scheduler_core::normalize_api_format(&endpoint.api_format)
|
||||
crate::ai_serving::api_format_permission_covers(value, &endpoint.api_format)
|
||||
})
|
||||
});
|
||||
if !api_format_matches {
|
||||
@@ -214,6 +213,7 @@ fn provider_query_parse_embedded_provider_model_mappings(
|
||||
priority: 1,
|
||||
api_formats: None,
|
||||
endpoint_ids: None,
|
||||
operations: None,
|
||||
}]))
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ fn provider_query_parse_provider_model_mappings_array(
|
||||
priority: 1,
|
||||
api_formats: None,
|
||||
endpoint_ids: None,
|
||||
operations: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -294,6 +295,11 @@ fn provider_query_parse_provider_model_mapping_object_lenient(
|
||||
object.get("endpoint_ids"),
|
||||
"models.provider_model_mappings.endpoint_ids",
|
||||
)?;
|
||||
let operations = provider_query_parse_mapping_string_list(
|
||||
object.get("operations"),
|
||||
"models.provider_model_mappings.operations",
|
||||
)?
|
||||
.and_then(provider_query_normalize_request_operations);
|
||||
|
||||
Ok(Some(StoredProviderModelMapping {
|
||||
name: name.to_string(),
|
||||
@@ -304,9 +310,19 @@ fn provider_query_parse_provider_model_mapping_object_lenient(
|
||||
})?,
|
||||
api_formats,
|
||||
endpoint_ids,
|
||||
operations,
|
||||
}))
|
||||
}
|
||||
|
||||
fn provider_query_normalize_request_operations(values: Vec<String>) -> Option<Vec<String>> {
|
||||
let operations = values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(!operations.is_empty()).then_some(operations)
|
||||
}
|
||||
|
||||
fn provider_query_parse_mapping_string_list(
|
||||
value: Option<&Value>,
|
||||
field_name: &str,
|
||||
@@ -367,3 +383,42 @@ fn provider_query_parse_mapping_string_list_array(
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_endpoint(api_format: &str) -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
api_format.to_string(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
}
|
||||
|
||||
fn sample_mapping(api_format: &str) -> StoredProviderModelMapping {
|
||||
StoredProviderModelMapping {
|
||||
name: "gpt-5.6-luna".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec![api_format.to_string()]),
|
||||
endpoint_ids: None,
|
||||
operations: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_mapping_scope_covers_search_in_one_direction() {
|
||||
assert!(provider_query_model_mapping_matches_endpoint(
|
||||
&sample_mapping("openai:responses"),
|
||||
&sample_endpoint("openai:search"),
|
||||
));
|
||||
assert!(!provider_query_model_mapping_matches_endpoint(
|
||||
&sample_mapping("openai:search"),
|
||||
&sample_endpoint("openai:responses"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,11 @@ fn provider_query_standard_test_resolves_codex_responses_upstream_streaming() {
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
));
|
||||
assert!(!provider_query_resolve_standard_test_upstream_is_stream(
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
));
|
||||
assert!(!provider_query_resolve_standard_test_upstream_is_stream(
|
||||
None,
|
||||
"custom",
|
||||
@@ -470,6 +475,118 @@ fn provider_query_compact_test_request_body_defaults_to_responses_input() {
|
||||
assert!(body.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_query_search_test_request_body_defaults_to_typed_search_input() {
|
||||
let payload = json!({"message": "find current documentation"});
|
||||
|
||||
let client_api_format = provider_query_standard_test_client_api_format("openai:search");
|
||||
let body = provider_query_build_test_request_body_for_api_format(
|
||||
&payload,
|
||||
"gpt-5.6-sol",
|
||||
"/api/admin/provider-query/test-model",
|
||||
client_api_format,
|
||||
);
|
||||
|
||||
assert_eq!(client_api_format, "openai:search");
|
||||
assert!(body["id"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.starts_with("aether-model-test-")));
|
||||
assert_eq!(body["model"], json!("gpt-5.6-sol"));
|
||||
assert_eq!(body["input"], json!("find current documentation"));
|
||||
assert_eq!(
|
||||
body["commands"]["search_query"][0]["q"],
|
||||
json!("find current documentation")
|
||||
);
|
||||
assert_eq!(body["max_output_tokens"], json!(256));
|
||||
assert!(body.get("messages").is_none());
|
||||
assert!(body.get("stream").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_query_search_test_completes_missing_protocol_fields() {
|
||||
let payload = json!({
|
||||
"request_body": {
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "find release notes"
|
||||
}
|
||||
});
|
||||
|
||||
let body = provider_query_build_test_request_body_for_api_format_with_search_session(
|
||||
&payload,
|
||||
"gpt-5.6-luna",
|
||||
"/api/admin/provider-query/test-model",
|
||||
"openai:search",
|
||||
Some("trace-model-test-1"),
|
||||
);
|
||||
|
||||
assert_eq!(body["id"], json!("aether-model-test-trace-model-test-1"));
|
||||
assert_eq!(
|
||||
body["commands"]["search_query"][0]["q"],
|
||||
json!("find release notes")
|
||||
);
|
||||
assert_eq!(body["max_output_tokens"], json!(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_query_search_test_preserves_a_non_empty_client_session_id() {
|
||||
let payload = json!({
|
||||
"request_body": {
|
||||
"id": "client-search-session",
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "find release notes"
|
||||
}
|
||||
});
|
||||
|
||||
let body = provider_query_build_test_request_body_for_api_format_with_search_session(
|
||||
&payload,
|
||||
"gpt-5.6-luna",
|
||||
"/api/admin/provider-query/test-model",
|
||||
"openai:search",
|
||||
Some("trace-model-test-1"),
|
||||
);
|
||||
|
||||
assert_eq!(body["id"], json!("client-search-session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_query_search_success_requires_non_empty_output() {
|
||||
fn result(body: Value) -> aether_contracts::ExecutionResult {
|
||||
aether_contracts::ExecutionResult {
|
||||
request_id: "provider-search-test".to_string(),
|
||||
candidate_id: Some("candidate-0".to_string()),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(body),
|
||||
body_bytes_b64: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(provider_query_standard_execution_response_body(
|
||||
"openai:search",
|
||||
&result(json!({})),
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert!(provider_query_standard_execution_response_body(
|
||||
"openai:search",
|
||||
&result(json!({"output": " "})),
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
provider_query_standard_execution_response_body(
|
||||
"openai:search",
|
||||
&result(json!({"output": "search result", "encrypted_output": "ciphertext"})),
|
||||
None,
|
||||
),
|
||||
Some(json!({"output": "search result", "encrypted_output": "ciphertext"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_query_embedding_test_request_body_defaults_to_embedding_input() {
|
||||
let payload = json!({"message": "hello from embedding"});
|
||||
@@ -632,6 +749,10 @@ fn provider_query_test_adapter_routes_fixed_provider_endpoint_types() {
|
||||
provider_query_test_adapter_for_provider_api_format("codex", "openai:responses:compact"),
|
||||
Some(ProviderQueryTestAdapter::Standard)
|
||||
);
|
||||
assert_eq!(
|
||||
provider_query_test_adapter_for_provider_api_format("codex", "openai:search"),
|
||||
Some(ProviderQueryTestAdapter::Standard)
|
||||
);
|
||||
assert_eq!(
|
||||
provider_query_test_adapter_for_provider_api_format("chatgpt_web", "openai:image"),
|
||||
Some(ProviderQueryTestAdapter::OpenAiImage)
|
||||
@@ -717,6 +838,10 @@ fn provider_query_endpoint_priority_prefers_text_before_cli_and_image() {
|
||||
provider_query_model_test_endpoint_priority("codex", "openai:responses:compact"),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
provider_query_model_test_endpoint_priority("codex", "openai:search"),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
provider_query_model_test_endpoint_priority("chatgpt_web", "openai:image"),
|
||||
Some(2)
|
||||
@@ -997,7 +1122,7 @@ fn provider_query_grok_image_test_allows_multi_generation_count() {
|
||||
&parts,
|
||||
&body,
|
||||
None,
|
||||
provider_query_openai_image_normalize_options("grok"),
|
||||
provider_query_openai_image_normalize_options("grok", Some("grok-imagine-image")),
|
||||
)
|
||||
.expect("grok image model tests should allow multi-image generation");
|
||||
let provider_body = crate::ai_serving::build_openai_image_provider_request_body(&normalized);
|
||||
@@ -1065,12 +1190,48 @@ fn provider_query_non_grok_image_test_keeps_single_generation_boundary() {
|
||||
&parts,
|
||||
&body,
|
||||
None,
|
||||
provider_query_openai_image_normalize_options("chatgpt_web"),
|
||||
provider_query_openai_image_normalize_options("chatgpt_web", Some("gpt-image-2")),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
provider_query_openai_image_normalize_failure_message("chatgpt_web", &body),
|
||||
provider_query_openai_image_normalize_failure_message(
|
||||
"chatgpt_web",
|
||||
Some("gpt-image-2"),
|
||||
&body,
|
||||
),
|
||||
"Provider request body could not be normalized for openai:image: selected provider supports n=1..1 for generation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_query_dall_e_3_image_test_keeps_single_generation_boundary() {
|
||||
let request = http::Request::builder()
|
||||
.uri("/v1/images/generations")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body = json!({
|
||||
"model": "dall-e-3",
|
||||
"prompt": "draw",
|
||||
"n": 2
|
||||
});
|
||||
|
||||
assert!(
|
||||
crate::ai_serving::normalize_openai_image_request_with_options(
|
||||
&parts,
|
||||
&body,
|
||||
None,
|
||||
provider_query_openai_image_normalize_options("openai", Some("dall-e-3")),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
provider_query_openai_image_normalize_failure_message(
|
||||
"openai",
|
||||
Some("dall-e-3"),
|
||||
&body,
|
||||
),
|
||||
"Provider request body could not be normalized for openai:image: selected provider supports n=1..1 for generation"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::image_capabilities::{
|
||||
openai_image_normalize_options_for_provider, openai_image_provider_max_generation_count,
|
||||
openai_image_normalize_options_for_provider,
|
||||
openai_image_provider_max_generation_count_for_model,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -24,16 +25,21 @@ impl AdminProviderOpenAiImageTestCapability {
|
||||
|
||||
pub(crate) fn admin_provider_openai_image_test_capability(
|
||||
provider_type: &str,
|
||||
provider_model: Option<&str>,
|
||||
) -> AdminProviderOpenAiImageTestCapability {
|
||||
AdminProviderOpenAiImageTestCapability {
|
||||
max_generation_count: openai_image_provider_max_generation_count(provider_type),
|
||||
max_generation_count: openai_image_provider_max_generation_count_for_model(
|
||||
provider_type,
|
||||
provider_model,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_openai_image_normalize_options(
|
||||
provider_type: &str,
|
||||
provider_model: Option<&str>,
|
||||
) -> crate::ai_serving::OpenAiImageNormalizeOptions {
|
||||
openai_image_normalize_options_for_provider(provider_type)
|
||||
openai_image_normalize_options_for_provider(provider_type, provider_model)
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_model_test_capabilities_payload(
|
||||
@@ -47,7 +53,7 @@ pub(crate) fn admin_provider_model_test_capabilities_payload(
|
||||
provider_type.eq_ignore_ascii_case("grok") && model_id == GROK_IMAGE_EDIT_MODEL_ID;
|
||||
let openai_image = if supports_image_generation {
|
||||
Some(json!({
|
||||
"max_generation_count": admin_provider_openai_image_test_capability(provider_type).max_generation_count,
|
||||
"max_generation_count": admin_provider_openai_image_test_capability(provider_type, Some(model_id)).max_generation_count,
|
||||
"supports_generation": !is_grok_image_edit,
|
||||
"supports_edit": is_grok_image_edit,
|
||||
}))
|
||||
@@ -105,6 +111,13 @@ mod tests {
|
||||
assert!(payload["openai:image"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dall_e_3_reports_its_model_specific_generation_limit() {
|
||||
let payload = admin_provider_model_test_capabilities_payload("openai", "dall-e-3", true);
|
||||
|
||||
assert_eq!(payload["openai:image"]["max_generation_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_image_support_uses_catalog_model_ids_not_global_fallback() {
|
||||
assert!(admin_provider_model_supports_image_generation(
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRe
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
|
||||
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
|
||||
normalize_max_probe_interval_minutes, validate_vertex_api_formats,
|
||||
normalize_max_probe_interval_minutes, normalize_rate_multipliers, validate_vertex_api_formats,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::{
|
||||
@@ -165,7 +165,7 @@ pub(crate) async fn build_admin_create_provider_key_record(
|
||||
},
|
||||
encrypted_api_key,
|
||||
encrypted_auth_config,
|
||||
normalize_api_format_json_object_keys(payload.rate_multipliers, "rate_multipliers")?,
|
||||
normalize_rate_multipliers(payload.rate_multipliers)?,
|
||||
None,
|
||||
normalize_string_list(payload.allowed_models).map(|value| json!(value)),
|
||||
None,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePa
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
|
||||
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
|
||||
normalize_max_probe_interval_minutes, validate_vertex_api_formats,
|
||||
normalize_max_probe_interval_minutes, normalize_rate_multipliers, validate_vertex_api_formats,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::{
|
||||
@@ -276,8 +276,7 @@ pub(crate) fn build_admin_update_provider_key_record_with_existing_keys(
|
||||
updated.name = trimmed.to_string();
|
||||
}
|
||||
if fields.contains("rate_multipliers") {
|
||||
updated.rate_multipliers =
|
||||
normalize_api_format_json_object_keys(payload.rate_multipliers, "rate_multipliers")?;
|
||||
updated.rate_multipliers = normalize_rate_multipliers(payload.rate_multipliers)?;
|
||||
}
|
||||
if let Some(internal_priority) = payload.internal_priority {
|
||||
updated.internal_priority = internal_priority;
|
||||
|
||||
@@ -42,6 +42,31 @@ pub(crate) fn normalize_api_format_json_object_keys(
|
||||
Ok(Some(serde_json::Value::Object(normalized)))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_rate_multipliers(
|
||||
value: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(value) = normalize_json_like_object(value, "rate_multipliers")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let serde_json::Value::Object(map) = value else {
|
||||
return Ok(Some(value));
|
||||
};
|
||||
let mut normalized = serde_json::Map::new();
|
||||
for (key, value) in map {
|
||||
let canonical = crate::ai_serving::normalize_api_format_alias(&key);
|
||||
let multiplier = value
|
||||
.as_f64()
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.ok_or_else(|| format!("rate_multipliers.{canonical} 必须是大于或等于 0 的有限数值"))?;
|
||||
normalized.insert(canonical, serde_json::Value::from(multiplier));
|
||||
}
|
||||
if normalized.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(serde_json::Value::Object(normalized)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_auth_type_by_format(
|
||||
value: Option<serde_json::Value>,
|
||||
field_name: &str,
|
||||
@@ -212,7 +237,7 @@ mod tests {
|
||||
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
|
||||
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
|
||||
normalize_chat_pii_redaction_config, normalize_pool_advanced_config,
|
||||
normalize_provider_type_input, validate_vertex_api_formats,
|
||||
normalize_provider_type_input, normalize_rate_multipliers, validate_vertex_api_formats,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -224,6 +249,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_multipliers_require_non_negative_finite_numbers() {
|
||||
assert_eq!(
|
||||
normalize_rate_multipliers(Some(json!({" OPENAI:RESPONSES ": 1.25})))
|
||||
.expect("valid multiplier should normalize"),
|
||||
Some(json!({"openai:responses": 1.25}))
|
||||
);
|
||||
for value in [
|
||||
json!({"openai:responses": -0.1}),
|
||||
json!({"openai:responses": "1.0"}),
|
||||
] {
|
||||
assert!(normalize_rate_multipliers(Some(value)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_pool_advanced_rejects_legacy_booleans() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,11 +3,79 @@ mod endpoint;
|
||||
mod template;
|
||||
mod update;
|
||||
|
||||
fn normalize_provider_request_timeout(value: Option<f64>) -> Result<Option<f64>, String> {
|
||||
let max = aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS as f64;
|
||||
match value {
|
||||
Some(value) if (1.0..=max).contains(&value) => Ok(Some(value)),
|
||||
Some(_) => Err(format!(
|
||||
"request_timeout 必须是 1 到 {} 之间的数字",
|
||||
aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_provider_stream_first_byte_timeout(value: Option<f64>) -> Result<Option<f64>, String> {
|
||||
let max = aether_contracts::MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_SECS as f64;
|
||||
match value {
|
||||
Some(value) if (1.0..=max).contains(&value) => Ok(Some(value)),
|
||||
Some(_) => Err(format!(
|
||||
"stream_first_byte_timeout 必须是 1 到 {} 之间的数字",
|
||||
aether_contracts::MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_SECS
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use self::create::build_admin_create_provider_record;
|
||||
pub(crate) use self::endpoint::build_admin_fixed_provider_endpoint_record;
|
||||
pub(crate) use self::template::{
|
||||
apply_admin_fixed_provider_endpoint_template_overrides,
|
||||
reconcile_admin_fixed_provider_template_endpoints,
|
||||
reconcile_admin_fixed_provider_template_keys,
|
||||
reconcile_admin_fixed_provider_template_endpoints_after_update,
|
||||
};
|
||||
pub(crate) use self::update::build_admin_update_provider_record;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_provider_request_timeout, normalize_provider_stream_first_byte_timeout};
|
||||
|
||||
#[test]
|
||||
fn provider_request_timeout_accepts_the_execution_protocol_range() {
|
||||
assert_eq!(normalize_provider_request_timeout(Some(1.0)), Ok(Some(1.0)));
|
||||
assert_eq!(
|
||||
normalize_provider_request_timeout(Some(
|
||||
aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS as f64,
|
||||
)),
|
||||
Ok(Some(
|
||||
aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS as f64,
|
||||
))
|
||||
);
|
||||
assert!(normalize_provider_request_timeout(Some(0.0)).is_err());
|
||||
assert!(normalize_provider_request_timeout(Some(
|
||||
aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS as f64 + 1.0,
|
||||
))
|
||||
.is_err());
|
||||
assert!(normalize_provider_request_timeout(Some(f64::NAN)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_stream_first_byte_timeout_keeps_its_protocol_range() {
|
||||
assert_eq!(
|
||||
normalize_provider_stream_first_byte_timeout(Some(1.0)),
|
||||
Ok(Some(1.0))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_provider_stream_first_byte_timeout(Some(
|
||||
aether_contracts::MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_SECS as f64,
|
||||
)),
|
||||
Ok(Some(
|
||||
aether_contracts::MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_SECS as f64,
|
||||
))
|
||||
);
|
||||
assert!(normalize_provider_stream_first_byte_timeout(Some(
|
||||
aether_contracts::MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_SECS as f64 + 1.0,
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,16 +107,9 @@ pub(crate) async fn build_admin_create_provider_record(
|
||||
None => Some(2),
|
||||
};
|
||||
let proxy = normalize_json_object(payload.proxy, "proxy")?;
|
||||
let stream_first_byte_timeout_secs = match payload.stream_first_byte_timeout {
|
||||
Some(value) if (1.0..=300.0).contains(&value) => Some(value),
|
||||
Some(_) => return Err("stream_first_byte_timeout 必须是 1 到 300 之间的数字".to_string()),
|
||||
None => None,
|
||||
};
|
||||
let request_timeout_secs = match payload.request_timeout {
|
||||
Some(value) if (1.0..=600.0).contains(&value) => Some(value),
|
||||
Some(_) => return Err("request_timeout 必须是 1 到 600 之间的数字".to_string()),
|
||||
None => None,
|
||||
};
|
||||
let stream_first_byte_timeout_secs =
|
||||
super::normalize_provider_stream_first_byte_timeout(payload.stream_first_byte_timeout)?;
|
||||
let request_timeout_secs = super::normalize_provider_request_timeout(payload.request_timeout)?;
|
||||
|
||||
let mut config_map = normalize_json_object(payload.config, "config")?
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::endpoint::{
|
||||
build_admin_fixed_provider_endpoint_defaults, build_admin_fixed_provider_endpoint_record,
|
||||
AdminFixedProviderEndpointDefaults,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_provider_transport::provider_types::{
|
||||
fixed_provider_template, FixedProviderEndpointTemplate, FixedProviderTemplate,
|
||||
@@ -36,6 +36,30 @@ struct FixedProviderEndpointMetadata {
|
||||
pub(crate) async fn reconcile_admin_fixed_provider_template_endpoints(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
) -> Result<(), GatewayError> {
|
||||
reconcile_admin_fixed_provider_template_endpoints_with_adoption_provider(
|
||||
state, provider, provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_admin_fixed_provider_template_endpoints_after_update(
|
||||
state: &AdminAppState<'_>,
|
||||
previous_provider: &StoredProviderCatalogProvider,
|
||||
updated_provider: &StoredProviderCatalogProvider,
|
||||
) -> Result<(), GatewayError> {
|
||||
reconcile_admin_fixed_provider_template_endpoints_with_adoption_provider(
|
||||
state,
|
||||
updated_provider,
|
||||
previous_provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reconcile_admin_fixed_provider_template_endpoints_with_adoption_provider(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
adoption_provider: &StoredProviderCatalogProvider,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(template) = state.fixed_provider_template(&provider.provider_type) else {
|
||||
return Ok(());
|
||||
@@ -53,8 +77,9 @@ pub(crate) async fn reconcile_admin_fixed_provider_template_endpoints(
|
||||
match existing_endpoint {
|
||||
Some(existing_endpoint) => {
|
||||
matched_endpoint_ids.insert(existing_endpoint.id.clone());
|
||||
let updated = reconcile_fixed_provider_endpoint(
|
||||
let updated = reconcile_fixed_provider_endpoint_with_adoption_provider(
|
||||
provider,
|
||||
adoption_provider,
|
||||
existing_endpoint,
|
||||
template,
|
||||
endpoint_template,
|
||||
@@ -115,31 +140,6 @@ pub(crate) async fn reconcile_admin_fixed_provider_template_endpoints(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_admin_fixed_provider_template_keys(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(_) = state.fixed_provider_template(&provider.provider_type) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let existing_keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await?;
|
||||
for existing_key in existing_keys {
|
||||
let Some(updated_key) = reconcile_fixed_provider_key(provider, &existing_key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(_) = state.update_provider_catalog_key(&updated_key).await? else {
|
||||
return Err(GatewayError::Internal(
|
||||
"provider catalog key writer unavailable".to_string(),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_admin_fixed_provider_endpoint_template_overrides(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
existing_endpoint: &StoredProviderCatalogEndpoint,
|
||||
@@ -156,8 +156,14 @@ pub(crate) fn apply_admin_fixed_provider_endpoint_template_overrides(
|
||||
|
||||
let defaults =
|
||||
build_admin_fixed_provider_endpoint_defaults(provider, template, endpoint_template)?;
|
||||
let mut metadata = fixed_provider_endpoint_metadata(existing_endpoint)
|
||||
.unwrap_or_else(|| managed_fixed_provider_endpoint_metadata(template, endpoint_template));
|
||||
let mut metadata = fixed_provider_endpoint_metadata(existing_endpoint).unwrap_or_else(|| {
|
||||
adopt_fixed_provider_endpoint_metadata(
|
||||
existing_endpoint,
|
||||
&defaults,
|
||||
template,
|
||||
endpoint_template,
|
||||
)
|
||||
});
|
||||
let mut overrides = metadata.overrides.clone();
|
||||
|
||||
sync_override_if_changed(
|
||||
@@ -250,17 +256,48 @@ pub(crate) fn apply_admin_fixed_provider_endpoint_template_overrides(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reconcile_fixed_provider_endpoint(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
existing_endpoint: &StoredProviderCatalogEndpoint,
|
||||
template: &FixedProviderTemplate,
|
||||
endpoint_template: &FixedProviderEndpointTemplate,
|
||||
) -> Result<StoredProviderCatalogEndpoint, String> {
|
||||
reconcile_fixed_provider_endpoint_with_adoption_provider(
|
||||
provider,
|
||||
provider,
|
||||
existing_endpoint,
|
||||
template,
|
||||
endpoint_template,
|
||||
)
|
||||
}
|
||||
|
||||
fn reconcile_fixed_provider_endpoint_with_adoption_provider(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
adoption_provider: &StoredProviderCatalogProvider,
|
||||
existing_endpoint: &StoredProviderCatalogEndpoint,
|
||||
template: &FixedProviderTemplate,
|
||||
endpoint_template: &FixedProviderEndpointTemplate,
|
||||
) -> Result<StoredProviderCatalogEndpoint, String> {
|
||||
let defaults =
|
||||
build_admin_fixed_provider_endpoint_defaults(provider, template, endpoint_template)?;
|
||||
let mut updated = existing_endpoint.clone();
|
||||
let metadata = fixed_provider_endpoint_metadata(existing_endpoint)
|
||||
.unwrap_or_else(|| managed_fixed_provider_endpoint_metadata(template, endpoint_template));
|
||||
let metadata = match fixed_provider_endpoint_metadata(existing_endpoint) {
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
let adoption_defaults = build_admin_fixed_provider_endpoint_defaults(
|
||||
adoption_provider,
|
||||
template,
|
||||
endpoint_template,
|
||||
)?;
|
||||
adopt_fixed_provider_endpoint_metadata(
|
||||
existing_endpoint,
|
||||
&adoption_defaults,
|
||||
template,
|
||||
endpoint_template,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
updated.api_format = defaults.api_format.clone();
|
||||
updated.api_family = Some(defaults.api_family.clone());
|
||||
@@ -455,6 +492,77 @@ fn managed_fixed_provider_endpoint_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_fixed_provider_endpoint_metadata(
|
||||
existing_endpoint: &StoredProviderCatalogEndpoint,
|
||||
defaults: &AdminFixedProviderEndpointDefaults,
|
||||
template: &FixedProviderTemplate,
|
||||
endpoint_template: &FixedProviderEndpointTemplate,
|
||||
) -> FixedProviderEndpointMetadata {
|
||||
let mut metadata = managed_fixed_provider_endpoint_metadata(template, endpoint_template);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_BASE_URL,
|
||||
&existing_endpoint.base_url,
|
||||
&defaults.base_url,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_CUSTOM_PATH,
|
||||
&existing_endpoint.custom_path,
|
||||
&defaults.custom_path,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_HEADER_RULES,
|
||||
&existing_endpoint.header_rules,
|
||||
&defaults.header_rules,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_BODY_RULES,
|
||||
&existing_endpoint.body_rules,
|
||||
&defaults.body_rules,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_MAX_RETRIES,
|
||||
&existing_endpoint.max_retries,
|
||||
&defaults.max_retries,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_IS_ACTIVE,
|
||||
&existing_endpoint.is_active,
|
||||
&defaults.is_active,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_PROXY,
|
||||
&existing_endpoint.proxy,
|
||||
&defaults.proxy,
|
||||
);
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
OVERRIDE_FORMAT_ACCEPTANCE_CONFIG,
|
||||
&existing_endpoint.format_acceptance_config,
|
||||
&defaults.format_acceptance_config,
|
||||
);
|
||||
|
||||
let existing_config = endpoint_config_without_metadata(existing_endpoint.config.as_ref());
|
||||
for (key, desired) in fixed_provider_endpoint_config_defaults(endpoint_template) {
|
||||
let Some(actual) = existing_config.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
sync_override(
|
||||
&mut metadata.overrides,
|
||||
&config_override_key(&key),
|
||||
actual,
|
||||
&desired,
|
||||
);
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn upsert_fixed_provider_endpoint_metadata(
|
||||
endpoint: &mut StoredProviderCatalogEndpoint,
|
||||
metadata: &FixedProviderEndpointMetadata,
|
||||
@@ -513,22 +621,6 @@ fn current_unix_secs() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn reconcile_fixed_provider_key(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
existing_key: &StoredProviderCatalogKey,
|
||||
) -> Option<StoredProviderCatalogKey> {
|
||||
if !provider_key_is_oauth_managed(existing_key, &provider.provider_type)
|
||||
|| existing_key.api_formats.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut updated = existing_key.clone();
|
||||
updated.api_formats = None;
|
||||
updated.updated_at_unix_secs = Some(current_unix_secs());
|
||||
Some(updated)
|
||||
}
|
||||
|
||||
fn sync_override<T>(overrides: &mut BTreeSet<String>, key: &str, actual: &T, desired: &T)
|
||||
where
|
||||
T: PartialEq,
|
||||
@@ -624,4 +716,68 @@ mod tests {
|
||||
.expect("endpoint should reconcile");
|
||||
assert_eq!(reconciled.base_url, "http://127.0.0.1:18181/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_provider_endpoint_reconcile_adopts_existing_customization() {
|
||||
let provider = sample_codex_provider();
|
||||
let template = fixed_provider_template("codex").expect("codex template should exist");
|
||||
let endpoint_template = template
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|endpoint| endpoint.api_format == "openai:responses")
|
||||
.expect("responses endpoint template should exist");
|
||||
|
||||
let mut existing = sample_codex_endpoint("http://127.0.0.1:18181/backend-api/codex");
|
||||
existing.is_active = false;
|
||||
existing.max_retries = Some(9);
|
||||
existing.proxy = Some(serde_json::json!({"url": "http://proxy.internal:8080"}));
|
||||
existing.config = Some(serde_json::json!({
|
||||
"upstream_stream_policy": "force_non_stream",
|
||||
"custom_transport_option": true
|
||||
}));
|
||||
|
||||
let reconciled =
|
||||
reconcile_fixed_provider_endpoint(&provider, &existing, template, endpoint_template)
|
||||
.expect("endpoint should reconcile");
|
||||
assert_eq!(
|
||||
reconciled.base_url,
|
||||
"http://127.0.0.1:18181/backend-api/codex"
|
||||
);
|
||||
assert!(!reconciled.is_active);
|
||||
assert_eq!(reconciled.max_retries, Some(9));
|
||||
assert_eq!(
|
||||
reconciled.proxy,
|
||||
Some(serde_json::json!({"url": "http://proxy.internal:8080"}))
|
||||
);
|
||||
assert_eq!(
|
||||
reconciled
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("upstream_stream_policy")),
|
||||
Some(&serde_json::json!("force_non_stream"))
|
||||
);
|
||||
assert_eq!(
|
||||
reconciled
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("custom_transport_option")),
|
||||
Some(&serde_json::json!(true))
|
||||
);
|
||||
let metadata = fixed_provider_endpoint_metadata(&reconciled)
|
||||
.expect("fixed provider metadata should exist");
|
||||
for key in [
|
||||
"base_url",
|
||||
"is_active",
|
||||
"max_retries",
|
||||
"proxy",
|
||||
"config.upstream_stream_policy",
|
||||
] {
|
||||
assert!(metadata.overrides.contains(key), "missing override {key}");
|
||||
}
|
||||
|
||||
let second =
|
||||
reconcile_fixed_provider_endpoint(&provider, &reconciled, template, endpoint_template)
|
||||
.expect("endpoint should reconcile idempotently");
|
||||
assert_eq!(second, reconciled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,21 +202,13 @@ pub(crate) async fn build_admin_update_provider_record(
|
||||
}
|
||||
|
||||
if fields.contains("stream_first_byte_timeout") {
|
||||
updated.stream_first_byte_timeout_secs = match payload.stream_first_byte_timeout {
|
||||
Some(value) if (1.0..=300.0).contains(&value) => Some(value),
|
||||
Some(_) => {
|
||||
return Err("stream_first_byte_timeout 必须是 1 到 300 之间的数字".to_string());
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
updated.stream_first_byte_timeout_secs =
|
||||
super::normalize_provider_stream_first_byte_timeout(payload.stream_first_byte_timeout)?;
|
||||
}
|
||||
|
||||
if fields.contains("request_timeout") {
|
||||
updated.request_timeout_secs = match payload.request_timeout {
|
||||
Some(value) if (1.0..=600.0).contains(&value) => Some(value),
|
||||
Some(_) => return Err("request_timeout 必须是 1 到 600 之间的数字".to_string()),
|
||||
None => None,
|
||||
};
|
||||
updated.request_timeout_secs =
|
||||
super::normalize_provider_request_timeout(payload.request_timeout)?;
|
||||
}
|
||||
|
||||
if fields.contains("enable_format_conversion") {
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn normalize_provider_model_mappings_api_formats(
|
||||
fn normalize_provider_model_mapping_scopes(
|
||||
value: Option<serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let Some(mut value) = value else {
|
||||
@@ -36,6 +36,9 @@ fn normalize_provider_model_mappings_api_formats(
|
||||
normalize_provider_model_mapping_string_array_field(object, "endpoint_ids", |value| {
|
||||
value.trim().to_string()
|
||||
});
|
||||
normalize_provider_model_mapping_string_array_field(object, "operations", |value| {
|
||||
value.trim().to_ascii_lowercase()
|
||||
});
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
@@ -155,7 +158,7 @@ impl<'a> AdminAppState<'a> {
|
||||
"price_per_request",
|
||||
)?;
|
||||
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
|
||||
let provider_model_mappings = normalize_provider_model_mappings_api_formats(
|
||||
let provider_model_mappings = normalize_provider_model_mapping_scopes(
|
||||
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?,
|
||||
);
|
||||
let config = normalize_json_object(payload.config, "config")?;
|
||||
@@ -241,7 +244,7 @@ impl<'a> AdminAppState<'a> {
|
||||
existing.tiered_pricing.clone()
|
||||
};
|
||||
let provider_model_mappings = if fields.contains("provider_model_mappings") {
|
||||
normalize_provider_model_mappings_api_formats(normalize_json_array(
|
||||
normalize_provider_model_mapping_scopes(normalize_json_array(
|
||||
payload.provider_model_mappings,
|
||||
"provider_model_mappings",
|
||||
)?)
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
fn validate_admin_endpoint_stream_policy(
|
||||
api_format: &str,
|
||||
config: Option<&serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
if !crate::ai_serving::api_format_alias_matches(api_format, "openai:search") {
|
||||
return Ok(());
|
||||
}
|
||||
let requested = config
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|config| {
|
||||
config
|
||||
.get("upstream_stream_policy")
|
||||
.or_else(|| config.get("upstreamStreamPolicy"))
|
||||
.or_else(|| config.get("upstream_stream"))
|
||||
});
|
||||
if requested.is_some_and(crate::handlers::public::admin_requested_force_stream) {
|
||||
return Err("OpenAI Search 端点仅支持非流式上游请求".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'a> AdminAppState<'a> {
|
||||
pub(crate) async fn build_admin_keys_grouped_by_format_payload(
|
||||
&self,
|
||||
@@ -232,6 +253,7 @@ impl<'a> AdminAppState<'a> {
|
||||
let (normalized_api_format, api_family, endpoint_kind) =
|
||||
admin_endpoint_signature_parts(&payload.api_format)
|
||||
.ok_or_else(|| format!("无效的 api_format: {}", payload.api_format))?;
|
||||
validate_admin_endpoint_stream_policy(normalized_api_format, payload.config.as_ref())?;
|
||||
let base_url = normalize_admin_base_url(&payload.base_url)?;
|
||||
|
||||
let existing_endpoints = self
|
||||
@@ -337,6 +359,13 @@ impl<'a> AdminAppState<'a> {
|
||||
&update_fields,
|
||||
)?;
|
||||
|
||||
if fields.contains("config") {
|
||||
validate_admin_endpoint_stream_policy(
|
||||
existing_endpoint.api_format.as_str(),
|
||||
updated.config.as_ref(),
|
||||
)?;
|
||||
}
|
||||
|
||||
if provider_type == "codex"
|
||||
&& crate::ai_serving::is_openai_responses_format(&existing_endpoint.api_format)
|
||||
{
|
||||
@@ -389,3 +418,40 @@ impl<'a> AdminAppState<'a> {
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::validate_admin_endpoint_stream_policy;
|
||||
|
||||
#[test]
|
||||
fn search_endpoint_rejects_explicit_streaming_policy_for_all_config_keys() {
|
||||
for (api_format, key, value) in [
|
||||
(
|
||||
"openai:search",
|
||||
"upstream_stream_policy",
|
||||
json!("force_stream"),
|
||||
),
|
||||
("openai:search", "upstreamStreamPolicy", json!(true)),
|
||||
("/v1/alpha/search", "upstream_stream", json!("sse")),
|
||||
] {
|
||||
let config = json!({(key): value});
|
||||
assert!(validate_admin_endpoint_stream_policy(api_format, Some(&config),).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_endpoint_accepts_non_streaming_and_unrelated_config() {
|
||||
assert!(validate_admin_endpoint_stream_policy(
|
||||
"openai:search",
|
||||
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_admin_endpoint_stream_policy(
|
||||
"openai:responses",
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -107,9 +107,9 @@ impl<'a> AdminAppState<'a> {
|
||||
let existing_keys = self
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await?;
|
||||
let api_formats =
|
||||
let available_api_formats =
|
||||
admin_provider_pool_pure::admin_pool_resolved_api_formats(&endpoints, &existing_keys);
|
||||
if api_formats.is_empty() {
|
||||
if available_api_formats.is_empty() {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "Provider 没有可用 endpoint 或现有 key,无法推断 api_formats" })),
|
||||
@@ -117,8 +117,74 @@ impl<'a> AdminAppState<'a> {
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let proxy =
|
||||
admin_provider_pool_pure::admin_pool_key_proxy_value(payload.proxy_node_id.as_deref());
|
||||
let requested_api_formats = payload
|
||||
.api_formats
|
||||
.iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let available_api_format_set = available_api_formats
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let api_formats = if requested_api_formats.is_empty() {
|
||||
available_api_formats.clone()
|
||||
} else {
|
||||
if let Some(unsupported) = requested_api_formats
|
||||
.iter()
|
||||
.find(|value| !available_api_format_set.contains(*value))
|
||||
{
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": format!("Provider 不支持 api_format: {unsupported}") })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
requested_api_formats
|
||||
};
|
||||
|
||||
let mut settings_map = match payload.settings {
|
||||
Some(Value::Object(map)) => map,
|
||||
Some(_) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "settings payload must be an object" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
None => Map::new(),
|
||||
};
|
||||
if let Some(proxy_node_id) = payload.proxy_node_id {
|
||||
settings_map
|
||||
.entry("proxy_node_id".to_string())
|
||||
.or_insert(Value::String(proxy_node_id));
|
||||
}
|
||||
let shared_settings = (!settings_map.is_empty()).then_some(Value::Object(settings_map));
|
||||
if let Some(settings) = shared_settings.as_ref() {
|
||||
if let Err(detail) =
|
||||
admin_provider_pool_pure::validate_admin_pool_key_settings_payload(settings)
|
||||
{
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
|
||||
let mut known_names = existing_keys
|
||||
.iter()
|
||||
.map(|key| key.name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut known_api_keys = existing_keys
|
||||
.iter()
|
||||
.filter_map(|key| key.encrypted_api_key.as_deref())
|
||||
.filter_map(|ciphertext| self.decrypt_catalog_secret_with_fallbacks(ciphertext))
|
||||
.filter(|value| value != "__placeholder__")
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut imported = 0usize;
|
||||
let skipped = 0usize;
|
||||
let mut errors = Vec::new();
|
||||
@@ -138,6 +204,79 @@ impl<'a> AdminAppState<'a> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = item.name.trim();
|
||||
if name.is_empty() {
|
||||
errors.push(json!({
|
||||
"index": index,
|
||||
"reason": "name is empty",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if known_names.contains(name) {
|
||||
errors.push(json!({
|
||||
"index": index,
|
||||
"reason": "该名称已存在于当前 Provider 或本次导入中",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let auth_type = item.auth_type.trim().to_ascii_lowercase();
|
||||
let auth_type = if auth_type.is_empty() {
|
||||
"api_key".to_string()
|
||||
} else {
|
||||
auth_type
|
||||
};
|
||||
if !matches!(auth_type.as_str(), "api_key" | "bearer") {
|
||||
errors.push(json!({
|
||||
"index": index,
|
||||
"reason": "auth_type must be api_key or bearer",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let requested_item_api_formats = item
|
||||
.api_formats
|
||||
.iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let item_api_formats = if requested_item_api_formats.is_empty() {
|
||||
api_formats.clone()
|
||||
} else {
|
||||
if let Some(unsupported) = requested_item_api_formats
|
||||
.iter()
|
||||
.find(|value| !available_api_format_set.contains(*value))
|
||||
{
|
||||
errors.push(json!({
|
||||
"index": index,
|
||||
"reason": format!("Provider 不支持 api_format: {unsupported}"),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
requested_item_api_formats
|
||||
};
|
||||
let item_settings = match admin_provider_pool_pure::resolve_admin_pool_key_settings(
|
||||
shared_settings.as_ref(),
|
||||
item.settings.as_ref(),
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
errors.push(json!({
|
||||
"index": index,
|
||||
"reason": detail,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if known_api_keys.contains(api_key) {
|
||||
errors.push(json!({
|
||||
"index": index,
|
||||
"reason": "该 API Key 已存在于当前 Provider 或本次导入中",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(encrypted_api_key) = self.encrypt_catalog_secret_with_fallbacks(api_key)
|
||||
else {
|
||||
errors.push(json!({
|
||||
@@ -146,26 +285,15 @@ impl<'a> AdminAppState<'a> {
|
||||
}));
|
||||
continue;
|
||||
};
|
||||
|
||||
let auth_type = item.auth_type.trim().to_ascii_lowercase();
|
||||
let auth_type = if auth_type.is_empty() {
|
||||
"api_key".to_string()
|
||||
} else {
|
||||
auth_type
|
||||
};
|
||||
let name = item.name.trim();
|
||||
let record = match admin_provider_pool_pure::build_admin_pool_batch_import_key_record(
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
provider.id.clone(),
|
||||
if name.is_empty() {
|
||||
format!("imported-{index}")
|
||||
} else {
|
||||
name.to_string()
|
||||
},
|
||||
name.to_string(),
|
||||
auth_type,
|
||||
api_formats.clone(),
|
||||
item_api_formats,
|
||||
encrypted_api_key,
|
||||
proxy.clone(),
|
||||
None,
|
||||
item_settings.as_ref(),
|
||||
now_unix_secs,
|
||||
) {
|
||||
Ok(value) => value,
|
||||
@@ -177,7 +305,6 @@ impl<'a> AdminAppState<'a> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(_) = self.create_provider_catalog_key(&record).await? else {
|
||||
return Ok((
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -187,6 +314,8 @@ impl<'a> AdminAppState<'a> {
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
known_names.insert(name.to_string());
|
||||
known_api_keys.insert(api_key.to_string());
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
@@ -475,6 +604,12 @@ impl<'a> AdminAppState<'a> {
|
||||
AdminPoolBatchActionKind::Disable => key.is_active = false,
|
||||
AdminPoolBatchActionKind::ClearProxy => key.proxy = None,
|
||||
AdminPoolBatchActionKind::SetProxy => key.proxy = plan.proxy_payload.clone(),
|
||||
AdminPoolBatchActionKind::UpdateSettings => {
|
||||
if let Some(settings) = plan.settings_payload.as_ref() {
|
||||
admin_provider_pool_pure::apply_admin_pool_key_settings(&mut key, settings)
|
||||
.map_err(GatewayError::Internal)?;
|
||||
}
|
||||
}
|
||||
AdminPoolBatchActionKind::RegenerateFingerprint => {
|
||||
key.fingerprint =
|
||||
Some(aether_provider_transport::claude_code::generate_random_fingerprint())
|
||||
|
||||
Reference in New Issue
Block a user