diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 67989ef1f..80a19e7e5 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -664,33 +664,22 @@ async fn run_icon_spec_extra_param_llm( for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS { match llm_client.run(request.clone()).await { - Ok(response) => { - let text = response.text.trim(); - if text.is_empty() { - if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( - json!({ - "provider": "editor-icon-spec-llm", - "operation": "complete-extra-param", - "message": "LLM 未返回可用文本", - }), - )); - } - } else { - match parse_extra_param(text) { - Ok(extra_param) => return Ok(extra_param), - Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} - Err(_) => { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY) - .with_details(json!({ - "provider": "editor-icon-spec-llm", - "operation": "complete-extra-param", - "message": "LLM 返回内容不符合图标规范参数契约", - }))); - } - } + Ok(response) => match parse_extra_param_response( + response.text.as_str(), + response.finish_reason.as_deref(), + ) { + Ok(extra_param) => return Ok(extra_param), + Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} + Err(error) => { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": "editor-icon-spec-llm", + "operation": "complete-extra-param", + "message": error.message(), + }), + )); } - } + }, Err(error) if is_retryable_llm_error(&error) => { if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { return Err(map_llm_error(error)); @@ -703,6 +692,39 @@ async fn run_icon_spec_extra_param_llm( unreachable!("icon spec extra param retry loop always returns") } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExtraParamResponseError { + Empty, + Incomplete, + Invalid, +} + +impl ExtraParamResponseError { + fn message(self) -> &'static str { + match self { + Self::Empty => "LLM 未返回可用文本", + Self::Incomplete => "LLM 返回内容未完整结束", + Self::Invalid => "LLM 返回内容不符合图标规范参数契约", + } + } +} + +fn parse_extra_param_response( + text: &str, + finish_reason: Option<&str>, +) -> Result { + if finish_reason.is_some_and(|reason| { + platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason) + }) { + return Err(ExtraParamResponseError::Incomplete); + } + let text = text.trim(); + if text.is_empty() { + return Err(ExtraParamResponseError::Empty); + } + parse_extra_param(text).map_err(|_| ExtraParamResponseError::Invalid) +} + fn parse_extra_param(text: &str) -> Result { let mut extra_param = serde_json::from_str::(text).map_err(|_| ())?; for value in [ @@ -2752,6 +2774,20 @@ mod tests { assert!(parse_extra_param(oversized.to_string().as_str()).is_err()); } + #[test] + fn extra_param_rejects_explicitly_incomplete_llm_responses() { + let complete_json = + r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家"}"#; + + for finish_reason in ["length", "content_filter"] { + assert_eq!( + parse_extra_param_response(complete_json, Some(finish_reason)), + Err(ExtraParamResponseError::Incomplete) + ); + } + assert!(parse_extra_param_response(complete_json, Some("stop")).is_ok()); + } + #[test] fn icon_spec_generation_text_enforces_prompt_max_length() { assert!(