mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(openai): align GPT-5.6 and Codex request contracts
This commit is contained in:
@@ -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},
|
||||
@@ -281,6 +282,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;
|
||||
@@ -289,12 +291,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::{
|
||||
@@ -439,11 +488,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,
|
||||
@@ -492,7 +539,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 {
|
||||
@@ -517,20 +568,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,14 +828,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2095,8 +2093,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;
|
||||
@@ -2107,11 +2115,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,
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
@@ -2130,29 +2145,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)
|
||||
@@ -2184,10 +2220,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,
|
||||
@@ -2212,7 +2250,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,
|
||||
@@ -2251,13 +2289,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(),
|
||||
@@ -3034,6 +3068,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"
|
||||
) && 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"
|
||||
{
|
||||
@@ -3256,8 +3321,8 @@ 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::apply_codex_openai_special_headers(
|
||||
&mut request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
@@ -3266,9 +3331,17 @@ 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_with_capabilities(
|
||||
&mut request_headers,
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
final_provider_model,
|
||||
source_model,
|
||||
codex_model_capabilities.as_ref(),
|
||||
);
|
||||
}
|
||||
if !uses_vertex_query_auth {
|
||||
|
||||
+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))
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
@@ -997,7 +1002,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 +1070,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::{
|
||||
@@ -260,8 +260,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
|
||||
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!(
|
||||
|
||||
Reference in New Issue
Block a user