diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 72bb741e2..8b4c0d288 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -336,7 +336,7 @@ for (const snippet of [ 'function writeStreamingChatCompletion', 'requestJson?.stream === true', `requestBodies.every((body) => body.includes('"stream":true'))`, - "GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL: 'chat_completions'", + "GENARRATIVE_GAME_CREATOR_LLM_API_KIND: 'openai_chat'", "GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true'", "method: 'HEAD'", 'previewAssetHead.contentLength === String(smokeAssetBytes.length)', diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index cc17f7935..d20063401 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -573,7 +573,7 @@ function runAgent(baseUrl) { GENARRATIVE_GAME_CREATOR_LLM_API_KEY: 'local-provider-key', GENARRATIVE_GAME_CREATOR_LLM_BASE_URL: baseUrl, GENARRATIVE_GAME_CREATOR_LLM_MODEL: 'local-game-creator-smoke', - GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL: 'chat_completions', + GENARRATIVE_GAME_CREATOR_LLM_API_KIND: 'openai_chat', GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true', }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ffbeb5612..bc82cd7dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -14,7 +14,7 @@ use platform_agent::{ route_game_creation_repair_issues, }; use platform_llm::{ - LlmClient, LlmConfig, LlmMessage, LlmProvider, LlmTextProtocol, LlmTextRequest, + LlmClient, LlmConfig, LlmMessage, LlmProvider, LlmApiKind, LlmRunRequest, DEFAULT_RETRY_BACKOFF_MS, }; use reqwest::header; @@ -91,7 +91,7 @@ struct GameCreatorLlmConfigStatus { api_key_present: bool, base_url: Option, model: Option, - protocol: String, + api_kind: String, error: Option, } @@ -1345,22 +1345,23 @@ fn build_game_creator_llm_client_from_env() -> Result { LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) } -fn read_game_creator_llm_protocol_from_env() -> Result { +fn read_game_creator_llm_api_kind_from_env() -> Result { match read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL", - "GENARRATIVE_LLM_PROTOCOL", - "LLM_PROTOCOL", + "GENARRATIVE_GAME_CREATOR_LLM_API_KIND", + "GENARRATIVE_LLM_API_KIND", + "LLM_API_KIND", ]) - .unwrap_or_else(|| "responses".to_string()) + .unwrap_or_else(|| "openai_responses".to_string()) .trim() .to_ascii_lowercase() .replace('-', "_") .as_str() { - "responses" | "response" | "responses_api" => Ok(LlmTextProtocol::Responses), - "chat_completions" | "chat_completion" | "chat" => Ok(LlmTextProtocol::ChatCompletions), + "openai_responses" => Ok(LlmApiKind::OpenAiResponses), + "openai_chat" => Ok(LlmApiKind::OpenAiChat), + "anthropic" => Ok(LlmApiKind::Anthropic), value => Err(format!( - "LLM protocol 无效:{value},请使用 responses 或 chat_completions" + "LLM api_kind 无效:{value},请使用 openai_responses、openai_chat 或 anthropic" )), } } @@ -1387,12 +1388,12 @@ fn check_game_creator_llm_config_from_env() -> GameCreatorLlmConfigStatus { ]); let mut status = check_game_creator_llm_config_values(api_key.clone(), base_url.clone(), model.clone()); - status.protocol = read_game_creator_llm_protocol_from_env() - .map(game_creator_llm_protocol_name) + status.api_kind = read_game_creator_llm_api_kind_from_env() + .map(game_creator_llm_api_kind_name) .unwrap_or_else(|error| { status.configured = false; status.error = Some(error); - "responses".to_string() + "openai_responses".to_string() }); if let Some(error) = local_env_error { status.configured = false; @@ -1448,15 +1449,16 @@ fn check_game_creator_llm_config_values( api_key_present, base_url, model, - protocol: "responses".to_string(), + api_kind: "openai_responses".to_string(), error, } } -fn game_creator_llm_protocol_name(protocol: LlmTextProtocol) -> String { - match protocol { - LlmTextProtocol::ChatCompletions => "chat_completions", - LlmTextProtocol::Responses => "responses", +fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String { + match api_kind { + LlmApiKind::OpenAiChat => "openai_chat", + LlmApiKind::OpenAiResponses => "openai_responses", + LlmApiKind::Anthropic => "anthropic", } .to_string() } @@ -1830,7 +1832,7 @@ async fn request_planner_spec_with_client( short_memory: &str, long_memory: &str, ) -> Result { - let request = LlmTextRequest::new(vec![ + let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_planner_system_prompt()), LlmMessage::user(game_creator_planner_user_prompt( prompt, @@ -1838,12 +1840,12 @@ async fn request_planner_spec_with_client( long_memory, )), ]) - .with_protocol(read_game_creator_llm_protocol_from_env()?) - .with_max_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); + .with_api_kind(read_game_creator_llm_api_kind_from_env()?) + .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); let response = request_game_creator_llm_text(client, request) .await .map_err(|error| format!("Planner 生成失败:{error}"))?; - let spec = strip_llm_thinking_blocks(response.content.as_str()); + let spec = strip_llm_thinking_blocks(response.text.as_str()); if spec.is_empty() { Err("Planner 未返回规格".to_string()) } else { @@ -1878,12 +1880,12 @@ async fn request_generator_game_draft_with_client( const MAX_EMPTY_RETRIES: u32 = 3; let mut empty_retries = 0u32; let response = loop { - let request = LlmTextRequest::new(vec![ + let request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt.clone()), ]) - .with_protocol(read_game_creator_llm_protocol_from_env()?) - .with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); + .with_api_kind(read_game_creator_llm_api_kind_from_env()?) + .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); match request_game_creator_llm_text(client, request).await { Ok(response) => break response, Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { @@ -1915,19 +1917,19 @@ async fn request_generator_game_draft_with_client( }; // 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。 #[cfg(all(debug_assertions, not(test)))] - debug::persist_snapshot(response.content.as_str()); - let content = strip_llm_thinking_blocks(response.content.as_str()); + debug::persist_snapshot(response.text.as_str()); + let content = strip_llm_thinking_blocks(response.text.as_str()); parse_llm_game_draft_response(content.as_str()) } async fn request_game_creator_llm_text( client: &LlmClient, - request: LlmTextRequest, -) -> Result { + request: LlmRunRequest, +) -> Result { if game_creator_llm_stream_enabled() { - client.stream_text(request, |_| {}).await + client.stream_run(request, |_| {}).await } else { - client.request_text(request).await + client.run(request).await } } @@ -6175,7 +6177,7 @@ fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("llm.apiKeyPresent={}", status.api_key_present); println!("llm.baseUrl={}", status.base_url.unwrap_or_default()); println!("llm.model={}", status.model.unwrap_or_default()); - println!("llm.protocol={}", status.protocol); + println!("llm.apiKind={}", status.api_kind); if let Some(error) = status.error { println!("llm.error={error}"); } @@ -6358,7 +6360,7 @@ mod tests { GENARRATIVE_GAME_CREATOR_LLM_API_KEY=file-key GENARRATIVE_GAME_CREATOR_LLM_BASE_URL="https://example.test/v1" export GENARRATIVE_GAME_CREATOR_LLM_MODEL='model-from-file' -GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL=chat_completions +GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat GENARRATIVE_GAME_CREATOR_LLM_STREAM=true "#, ) @@ -6367,12 +6369,12 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true let api_key = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").ok(); let base_url = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").ok(); let model = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").ok(); - let protocol = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL").ok(); + let api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); let stream = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_STREAM").ok(); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", "process-key"); std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL"); std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_MODEL"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL"); + std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND"); std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_STREAM"); load_game_creator_env_file(&env_path).expect("load local env"); @@ -6390,8 +6392,8 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true Ok("model-from-file") ); assert_eq!( - std::env::var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL").as_deref(), - Ok("chat_completions") + std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").as_deref(), + Ok("openai_chat") ); assert_eq!( std::env::var("GENARRATIVE_GAME_CREATOR_LLM_STREAM").as_deref(), @@ -6401,7 +6403,7 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", api_key); restore_env("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", base_url); restore_env("GENARRATIVE_GAME_CREATOR_LLM_MODEL", model); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL", protocol); + restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", api_kind); restore_env("GENARRATIVE_GAME_CREATOR_LLM_STREAM", stream); fs::remove_dir_all(root).expect("cleanup test env dir"); } @@ -6801,18 +6803,18 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true let previous_api_key = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").ok(); let previous_base_url = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").ok(); let previous_model = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").ok(); - let previous_protocol = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL").ok(); + let previous_api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", "test-key"); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", base_url); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_MODEL", "mock-game-model"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL"); + std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND"); let result = generate_local_game_draft_at(&root, "用上传角色图做主角", None).await; restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", previous_api_key); restore_env("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", previous_base_url); restore_env("GENARRATIVE_GAME_CREATOR_LLM_MODEL", previous_model); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL", previous_protocol); + restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", previous_api_kind); result.expect("generated draft"); let requests = receiver.try_iter().collect::>(); @@ -6843,12 +6845,38 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true Some("http://127.0.0.1:1/v1") ); assert_eq!(configured.model.as_deref(), Some("mock-game-model")); - assert_eq!(configured.protocol, "responses"); + assert_eq!(configured.api_kind, "openai_responses"); assert!(!serde_json::to_string(&configured) .unwrap() .contains("unit-test-api-key")); } + #[test] + fn llm_api_kind_env_reads_canonical_names() { + let _env_guard = TEST_ENV_LOCK.lock().expect("test env lock"); + let api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); + + std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", "anthropic"); + assert_eq!( + read_game_creator_llm_api_kind_from_env(), + Ok(LlmApiKind::Anthropic) + ); + + std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", "openai_chat"); + assert_eq!( + read_game_creator_llm_api_kind_from_env(), + Ok(LlmApiKind::OpenAiChat) + ); + + std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", "openai_responses"); + assert_eq!( + read_game_creator_llm_api_kind_from_env(), + Ok(LlmApiKind::OpenAiResponses) + ); + + restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", api_kind); + } + #[tokio::test] async fn agent_loop_writes_spec_findings_and_retries_generator() { let root = unique_project_path(); @@ -7320,11 +7348,11 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true let previous_api_key = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").ok(); let previous_base_url = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").ok(); let previous_model = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").ok(); - let previous_protocol = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL").ok(); + let previous_api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", "test-key"); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", base_url); std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_MODEL", "mock-game-model"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL"); + std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND"); let error = generate_local_game_draft_at(&root, "做一个会失败三轮的厨房游戏", None) .await @@ -7333,7 +7361,7 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", previous_api_key); restore_env("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", previous_base_url); restore_env("GENARRATIVE_GAME_CREATOR_LLM_MODEL", previous_model); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL", previous_protocol); + restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", previous_api_kind); assert!(error.contains("已重试")); assert!(error.contains(&GAME_CREATOR_AGENT_LOOP_MAX_PASSES.to_string())); assert!(!root.join("memory/session.md").exists()); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cbd1ca587..4180a8b7d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -59,7 +59,7 @@ interface GameCreatorLlmConfigStatus { apiKeyPresent: boolean; baseUrl: string | null; model: string | null; - protocol: string; + apiKind: string; error: string | null; } @@ -1614,7 +1614,7 @@ export function App() { text: status.configured ? `LLM 已配置:${status.model ?? '未命名模型'} @ ${ status.baseUrl ?? '未设置 base_url' - },${status.protocol},API Key 已读取。` + },${status.apiKind},API Key 已读取。` : `LLM 未就绪:${status.error ?? '配置不完整'}。API Key:${ status.apiKeyPresent ? '已读取' : '未读取' }。`, diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 69080b19a..40a695d23 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1376,7 +1376,7 @@ describe('AI 游戏创作 App 界面边界', () => { apiKeyPresent: true, baseUrl: 'https://llm.example.test/v1', model: 'gpt-test', - protocol: 'responses', + apiKind: 'openai_responses', error: null, }; } @@ -1389,7 +1389,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText( - 'LLM 已配置:gpt-test @ https://llm.example.test/v1,responses,API Key 已读取。', + 'LLM 已配置:gpt-test @ https://llm.example.test/v1,openai_responses,API Key 已读取。', ), ).not.toBeNull(); expect(screen.queryByText(/sk-test-secret/)).toBeNull(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 15ac27825..f8ff60519 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -40,7 +40,7 @@ - 验证方式:运行 `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml request_text_parses_non_stream_response`,并用真实 OpenAI-compatible 环境变量执行 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-real-loop-test-6 "做一个像素风反弹弹幕厨房小游戏..."`,确认 36 个 trace step、36 次 tool call、`game.static_smoke`、`preview.start` 和 `preview.stop` 完成。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 -2026-06-27 追加:`platform-llm` 的 `LlmTextRequest::new` 默认协议改为 Responses;旧 `/api/llm/chat/completions` 代理、RPG runtime chat 和需要旧测试网关的 AI 游戏创作 smoke 必须显式选择 Chat Completions。AI 游戏创作真实 LLM 默认 Responses,可用 `GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL=chat_completions` 兼容旧 OpenAI Chat Completions 网关。 +2026-06-29 追加:`platform-llm` 旧 `LlmTextRequest` / `LlmTextResponse` 已直接替换为 provider-neutral 的 `LlmRunRequest` / `LlmRunResponse`,API kind 先固定为 `openai_chat`、`openai_responses`、`anthropic` 三类。AI 游戏创作 App 默认 `openai_responses`,可用 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat` 接旧 Chat Completions 兼容网关,或用 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic` 接 Anthropic Messages。当前 run 响应只保留通用文本、finish reason、response id 和 usage,高级能力后续按 capability 扩展,不把业务层绑死到 Responses 字段。 ## 2026-06-24 AI 游戏创作 App 生成编排使用文件驱动 loop diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 02c04486a..6d2c0bf28 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -94,7 +94,7 @@ game-project/ - `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式用户窗口只登记 `main` 聊天窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。 - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 环境变量是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。CLI 和桌面 App 内的 `/llm-status` / 生成入口都会先读取仓库根目录或 `apps/ai-game-creator-shell/` 下 gitignored 的 `.env.secrets.local`,再检查当前进程环境。 -- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 可放在 gitignored 的 `.env.secrets.local` 中,至少包含 `GENARRATIVE_GAME_CREATOR_LLM_API_KEY`、`GENARRATIVE_GAME_CREATOR_LLM_BASE_URL`、`GENARRATIVE_GAME_CREATOR_LLM_MODEL`;默认按 Responses 协议请求,旧 Chat Completions 兼容网关需显式设置 `GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL=chat_completions`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true`。 +- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 LLM provider 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 可放在 gitignored 的 `.env.secrets.local` 中,至少包含 `GENARRATIVE_GAME_CREATOR_LLM_API_KEY`、`GENARRATIVE_GAME_CREATOR_LLM_BASE_URL`、`GENARRATIVE_GAME_CREATOR_LLM_MODEL`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关需显式设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat`;Anthropic Messages 网关设置为 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true`。 ## 当前最小落地 @@ -104,12 +104,12 @@ game-project/ - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 - 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 环境变量是否就绪;CLI 和桌面 App 内的 `/llm-status` / 生成入口都会先读取 gitignored 的 `.env.secrets.local`,不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-agent` 编排测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke。 -- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;该入口读取当前环境和 gitignored 的 `.env.secrets.local`,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认协议为 Responses;旧 Chat Completions 兼容网关设置 `GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL=chat_completions`。真实 OpenAI-compatible 网关建议设置 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 +- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;该入口读取当前环境和 gitignored 的 `.env.secrets.local`,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat`;Anthropic Messages 网关设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic`。真实 OpenAI-compatible 网关建议设置 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、provider prompt 收到图片与音频资产上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 - `npm run ai-game-creator-shell:dev` 的 Tauri `devUrl` 固定为 `http://127.0.0.1:3080/`,Vite 必须 `strictPort` 对齐;`beforeDevCommand` 先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,否则才启动新的 Vite,若端口被其它服务占用则直接失败并提示释放端口。 - `.agent/manifest.json` 会保存 6 个专业组下 16 个组内角色任务状态,当前覆盖 `Director`、`Gameplay`、`Difficulty`、`Asset`、`Polish`、`SFX`、`Code`、`Review`、`Preview`、`Playtest`、`Publish`;程序组内显式包含 `quality-review` 质量评审 gate,由 Evaluator trace 标记完成;开发窗口的专业组面板读取 manifest,而不是前端硬编码。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 -- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,读取 `GENARRATIVE_GAME_CREATOR_LLM_API_KEY` / `GENARRATIVE_LLM_API_KEY` / `LLM_API_KEY` / `OPENAI_API_KEY`、`GENARRATIVE_GAME_CREATOR_LLM_BASE_URL` / `GENARRATIVE_LLM_BASE_URL` / `LLM_BASE_URL` / `OPENAI_BASE_URL`、`GENARRATIVE_GAME_CREATOR_LLM_MODEL` / `GENARRATIVE_LLM_MODEL` / `LLM_MODEL` / `OPENAI_MODEL`;默认协议为 Responses,可通过 `GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL=chat_completions` 切回旧 Chat Completions 兼容网关;`GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 +- `game.generate_draft` 使用 LLM provider 配置生成结构化 JSON 草案,读取 `GENARRATIVE_GAME_CREATOR_LLM_API_KEY` / `GENARRATIVE_LLM_API_KEY` / `LLM_API_KEY` / `OPENAI_API_KEY`、`GENARRATIVE_GAME_CREATOR_LLM_BASE_URL` / `GENARRATIVE_LLM_BASE_URL` / `LLM_BASE_URL` / `OPENAI_BASE_URL`、`GENARRATIVE_GAME_CREATOR_LLM_MODEL` / `GENARRATIVE_LLM_MODEL` / `LLM_MODEL` / `OPENAI_MODEL`;默认 API kind 为 `openai_responses`,可通过 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat` 切回旧 Chat Completions 兼容网关,通过 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic` 走 Anthropic Messages;`GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 - 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认 LLM base_url、model 和 API Key 是否已从环境变量读取;状态消息不会显示或保存 API Key。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 - `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;角色 brief 写入 `.agent/passes/pass-N/groups//*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 831533d32..4da0aa2ce 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -455,7 +455,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。 -`platform-llm` 文本请求默认使用 Responses 协议;需要接旧 OpenAI Chat Completions 兼容网关时,调用方必须显式选择 Chat Completions。AI 游戏创作独立 App 也默认使用 Responses,可在本地 `.env.secrets.local` 中设置 `GENARRATIVE_GAME_CREATOR_LLM_PROTOCOL=chat_completions` 兼容旧测试网关。 +`platform-llm` 文本请求默认使用 Responses 协议;需要接旧 OpenAI Chat Completions 兼容网关时,调用方必须显式选择 Chat Completions。AI 游戏创作独立 App 也默认使用 Responses,可在本地 `.env.secrets.local` 中设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat` 接旧 Chat Completions 兼容网关,或 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic` 接 Anthropic Messages。 创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行: diff --git a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs index a26566c6e..116d55d94 100644 --- a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs +++ b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs @@ -2,7 +2,7 @@ use module_big_fish::{ BIG_FISH_MAX_LEVEL_COUNT, BIG_FISH_MIN_LEVEL_COUNT, BigFishAnchorPack, BigFishGameDraft, BigFishLevelBlueprint, BigFishRuntimeParams, compile_default_draft, }; -use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use platform_llm::{LlmClient, LlmMessage, LlmRunRequest}; use serde::Deserialize; use serde_json::Value as JsonValue; @@ -109,20 +109,20 @@ async fn request_big_fish_json_stage( empty_response_message: &str, ) -> Result { let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(true), ) .await .map_err(|error| { BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}")) })?; - let text = response.content.trim(); + let text = response.text.trim(); if text.is_empty() { return Err(BigFishDraftCompileError::new(empty_response_message)); } @@ -130,15 +130,15 @@ async fn request_big_fish_json_stage( Ok(value) => Ok(value), Err(_) => { let repaired = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT), LlmMessage::user(format!( "请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}" )), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await .map_err(|error| { @@ -146,7 +146,7 @@ async fn request_big_fish_json_stage( "{debug_label} JSON 修复请求失败:{error}" )) })?; - parse_json_response_text(repaired.content.as_str()).map_err(|error| { + parse_json_response_text(repaired.text.as_str()).map_err(|error| { BigFishDraftCompileError::new(format!("{debug_label} JSON 解析失败:{error}")) }) } diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 86bbfa93a..c98ececca 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -1,4 +1,4 @@ -use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmTextRequest}; +use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmRunRequest}; use serde_json::Value as JsonValue; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; @@ -150,7 +150,7 @@ where F: FnMut(&str), { let response = llm_client - .stream_text( + .stream_run( build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search), |delta: &LlmStreamDelta| { if !emit_reply_updates { @@ -167,7 +167,7 @@ where ) .await .map_err(CreationAgentJsonTurnFailure::Stream)?; - let parsed = parse_json_response_text(response.content.as_str()) + let parsed = parse_json_response_text(response.text.as_str()) .map_err(|_| CreationAgentJsonTurnFailure::Parse)?; Ok(CreationAgentJsonTurnOutput { parsed }) @@ -184,14 +184,14 @@ fn build_creation_agent_llm_request( system_prompt: String, user_prompt: String, enable_web_search: bool, -) -> LlmTextRequest { +) -> LlmRunRequest { // 创作 Agent 是否联网由 api-server 配置集中传入,避免各玩法各自散落默认值。 - LlmTextRequest::new(vec![ + LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(enable_web_search) .with_request_timeout_ms(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS) } @@ -203,17 +203,17 @@ pub(crate) async fn request_creation_agent_json_turn( build_error: impl Fn(String) -> E, ) -> Result { let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await .map_err(|error| build_error(error.to_string()))?; - parse_json_response_text(response.content.as_str()) + parse_json_response_text(response.text.as_str()) .map_err(|error| build_error(error.to_string())) } @@ -331,7 +331,7 @@ mod tests { assert!(request.enable_web_search); assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL)); - assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); + assert_eq!(request.api_kind, platform_llm::LlmApiKind::OpenAiResponses); assert_eq!(request.messages.len(), 2); assert_eq!( request.request_timeout_ms, diff --git a/server-rs/crates/api-server/src/custom_world_agent_entities.rs b/server-rs/crates/api-server/src/custom_world_agent_entities.rs index 1e36853ce..f3d3e80c3 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_entities.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_entities.rs @@ -1,4 +1,4 @@ -use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use platform_llm::{LlmClient, LlmMessage, LlmRunRequest}; use serde_json::{Map as JsonMap, Value as JsonValue}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; @@ -94,18 +94,18 @@ pub async fn generate_custom_world_agent_entities( }; let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(true), ) .await .map_err(|error| format!("{action} LLM 请求失败:{error}"))?; - let generated_entities = parse_json_array_response(response.content.as_str()) + let generated_entities = parse_json_array_response(response.text.as_str()) .map_err(|error| format!("{action} JSON 解析失败:{error}"))?; let normalized_entities = normalize_generated_entities(action, generated_entities, draft_profile, count); diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index be751aa46..b389925ad 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -17,7 +17,7 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; -use platform_llm::{LlmMessage, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmRunRequest}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; @@ -1132,19 +1132,19 @@ async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: let Some(llm_client) = state.llm_client() else { return fallback; }; - let request = LlmTextRequest::new(vec![ + let request = LlmRunRequest::new(vec![ LlmMessage::system(build_result_entity_system_prompt()), LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(true); llm_client - .request_text(request) + .run(request) .await .ok() - .and_then(|response| serde_json::from_str::(response.content.trim()).ok()) + .and_then(|response| serde_json::from_str::(response.text.trim()).ok()) .unwrap_or(fallback) } @@ -1157,7 +1157,7 @@ async fn generate_scene_npc_with_fallback( let Some(llm_client) = state.llm_client() else { return fallback; }; - let request = LlmTextRequest::new(vec![ + let request = LlmRunRequest::new(vec![ LlmMessage::system(build_result_scene_npc_system_prompt()), LlmMessage::user(build_result_scene_npc_user_prompt( profile, @@ -1166,14 +1166,14 @@ async fn generate_scene_npc_with_fallback( )), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(true); llm_client - .request_text(request) + .run(request) .await .ok() - .and_then(|response| serde_json::from_str::(response.content.trim()).ok()) + .and_then(|response| serde_json::from_str::(response.text.trim()).ok()) .unwrap_or(fallback) } diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index fb5a93e14..75d7a5be9 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -6,7 +6,7 @@ use crate::prompt::foundation_draft::{ build_custom_world_role_outline_batch_json_repair_prompt, build_custom_world_role_outline_batch_prompt, }; -use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use platform_llm::{LlmClient, LlmMessage, LlmRunRequest}; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; @@ -195,7 +195,7 @@ where enable_web_search, ) .await?; - let text = response.content.trim(); + let text = response.text.trim(); if text.is_empty() { return Err(empty_response_message.to_string()); } @@ -203,17 +203,17 @@ where Ok(value) => Ok(value), Err(_) => { let repaired = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT), LlmMessage::user(repair_prompt_builder(text)), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await .map_err(|error| format!("{repair_debug_label} LLM 请求失败:{error}"))?; - parse_json_response_text(repaired.content.as_str()) + parse_json_response_text(repaired.text.as_str()) .map_err(|error| format!("{repair_debug_label} JSON 解析失败:{error}")) } } @@ -225,7 +225,7 @@ async fn request_foundation_text_with_optional_search_fallback( user_prompt: &str, debug_label: &str, enable_web_search: bool, -) -> Result { +) -> Result { match request_foundation_text(llm_client, system_prompt, user_prompt, enable_web_search).await { Ok(response) => Ok(response), Err(error) if enable_web_search && should_retry_foundation_without_web_search(&error) => { @@ -247,15 +247,15 @@ async fn request_foundation_text( system_prompt: &str, user_prompt: &str, enable_web_search: bool, -) -> Result { +) -> Result { llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(enable_web_search), ) .await diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index bf9b66656..c220bcf01 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -7,7 +7,7 @@ use axum::{ sse::{Event, Sse}, }, }; -use platform_llm::{LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmMessageRole, LlmApiKind, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, @@ -33,15 +33,15 @@ pub async fn proxy_llm_chat_completions( ) })?; - let request = LlmTextRequest { + let request = LlmRunRequest { model: payload.model, - protocol: LlmTextProtocol::ChatCompletions, + api_kind: LlmApiKind::OpenAiChat, messages: payload .messages .into_iter() .map(map_chat_message) .collect::>(), - max_tokens: None, + max_output_tokens: None, enable_web_search: false, request_timeout_ms: None, }; @@ -51,7 +51,7 @@ pub async fn proxy_llm_chat_completions( } let response = llm_client - .request_text(request) + .run(request) .await .map_err(|error| llm_error_response(&request_context, map_llm_error(error)))?; @@ -60,7 +60,7 @@ pub async fn proxy_llm_chat_completions( LlmChatCompletionResponse { id: response.response_id, model: response.model, - content: response.content, + content: response.text, finish_reason: response.finish_reason, }, ) @@ -69,11 +69,11 @@ pub async fn proxy_llm_chat_completions( fn stream_llm_chat_completions( llm_client: platform_llm::LlmClient, - request: LlmTextRequest, + request: LlmRunRequest, ) -> Sse>> { let stream = async_stream::stream! { let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel::(); - let llm_stream = llm_client.stream_text(request, move |delta| { + let llm_stream = llm_client.stream_run(request, move |delta| { let _ = delta_tx.send(json!({ "delta": delta.delta_text, "content": delta.accumulated_text, @@ -105,7 +105,7 @@ fn stream_llm_chat_completions( json!(LlmChatCompletionResponse { id: response.response_id, model: response.model, - content: response.content, + content: response.text, finish_reason: response.finish_reason, }), )); @@ -182,7 +182,7 @@ mod tests { } #[tokio::test] - async fn llm_chat_completions_returns_non_stream_text_payload() { + async fn llm_chat_completions_returns_non_stream_run_payload() { let server_url = spawn_mock_server(vec![MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 61828c657..4673a8e80 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -21,7 +21,7 @@ use module_match3d::{ MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, MATCH3D_SESSION_ID_PREFIX, }; -use platform_llm::{LlmMessage, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmRunRequest}; use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 3c8a8c2d5..0c3b6f929 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -870,18 +870,18 @@ async fn generate_match3d_draft_plan( config.theme_text, gameplay_item_count, generated_item_count ); let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await; match response { - Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config) + Ok(response) => parse_match3d_draft_plan(response.text.as_str(), config) .unwrap_or_else(|| fallback_match3d_draft_plan(config)), Err(error) => { tracing::warn!( diff --git a/server-rs/crates/api-server/src/match3d/tags.rs b/server-rs/crates/api-server/src/match3d/tags.rs index c4fb43d4c..9b77ad26a 100644 --- a/server-rs/crates/api-server/src/match3d/tags.rs +++ b/server-rs/crates/api-server/src/match3d/tags.rs @@ -92,19 +92,19 @@ pub(super) async fn request_match3d_work_tags_with_llm( summary.unwrap_or_default() ); let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"), LlmMessage::user(user_prompt), ]) .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await; match response { Ok(response) => { - let tags = parse_match3d_tags_from_text(response.content.as_str()); + let tags = parse_match3d_tags_from_text(response.text.as_str()); if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT { return Some(tags); } diff --git a/server-rs/crates/api-server/src/prompt/visual_novel.rs b/server-rs/crates/api-server/src/prompt/visual_novel.rs index 0afc44fbc..7a4c6cfb9 100644 --- a/server-rs/crates/api-server/src/prompt/visual_novel.rs +++ b/server-rs/crates/api-server/src/prompt/visual_novel.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use platform_llm::{LlmMessage, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmRunRequest}; use serde_json::{Value as JsonValue, json}; use shared_contracts::visual_novel::{VisualNovelResultDraft, VisualNovelRuntimeStep}; @@ -285,36 +285,36 @@ pub(crate) fn build_visual_novel_repair_user_prompt( pub(crate) fn build_visual_novel_creation_llm_request( params: VisualNovelCreationPromptParams<'_>, enable_web_search: bool, -) -> LlmTextRequest { - LlmTextRequest::new(vec![ +) -> LlmRunRequest { + LlmRunRequest::new(vec![ LlmMessage::system(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT), LlmMessage::user(build_visual_novel_creation_user_prompt(params)), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() .with_web_search(enable_web_search) } pub(crate) fn build_visual_novel_runtime_llm_request( params: VisualNovelRuntimePromptParams<'_>, -) -> LlmTextRequest { - LlmTextRequest::new(vec![ +) -> LlmRunRequest { + LlmRunRequest::new(vec![ LlmMessage::system(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT), LlmMessage::user(build_visual_novel_runtime_user_prompt(params)), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() } pub(crate) fn build_visual_novel_repair_llm_request( params: VisualNovelRepairPromptParams<'_>, -) -> LlmTextRequest { - LlmTextRequest::new(vec![ +) -> LlmRunRequest { + LlmRunRequest::new(vec![ LlmMessage::system(VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT), LlmMessage::user(build_visual_novel_repair_user_prompt(params)), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() + .with_openai_responses() } pub(crate) fn visual_novel_tool_descriptors() -> Vec { @@ -451,7 +451,7 @@ fn strip_json_code_fence(text: &str) -> &str { #[cfg(test)] mod tests { - use platform_llm::LlmTextProtocol; + use platform_llm::LlmApiKind; use serde_json::json; use super::*; @@ -661,7 +661,7 @@ mod tests { creation_request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL) ); - assert_eq!(creation_request.protocol, LlmTextProtocol::Responses); + assert_eq!(creation_request.api_kind, LlmApiKind::OpenAiResponses); assert!(creation_request.enable_web_search); assert!( creation_request.messages[0] @@ -687,7 +687,7 @@ mod tests { runtime_request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL) ); - assert_eq!(runtime_request.protocol, LlmTextProtocol::Responses); + assert_eq!(runtime_request.api_kind, LlmApiKind::OpenAiResponses); assert!(!runtime_request.enable_web_search); assert!( runtime_request.messages[0] diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 13c042dde..973a03033 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -19,7 +19,7 @@ use module_assets::{ build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; -use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmMessageContentPart, LlmRunRequest}; use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest}; use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde_json::{Value, json}; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index fe545dc56..481b16800 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -705,18 +705,18 @@ pub(crate) async fn generate_puzzle_first_level_name( if let Some(llm_client) = state.llm_client() { let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description); let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await; match response { Ok(response) => { - if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str()) + if let Some(naming) = parse_puzzle_level_naming_from_text(response.text.as_str()) { return naming; } @@ -758,8 +758,8 @@ pub(crate) async fn generate_puzzle_first_level_name_from_image( }; let user_text = build_puzzle_first_level_name_vision_user_text(picture_description); let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), LlmMessage::user_multimodal(vec![ LlmMessageContentPart::InputText { text: user_text }, @@ -769,13 +769,13 @@ pub(crate) async fn generate_puzzle_first_level_name_from_image( ]), ]) .with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL) - .with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), + .with_max_output_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), ) .await; match response { Ok(response) => { - parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| { + parse_puzzle_level_naming_from_text(response.text.as_str()).or_else(|| { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index c6ce0693b..ff5a77724 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -8,19 +8,19 @@ pub(super) async fn generate_puzzle_work_tags( if let Some(llm_client) = state.llm_client() { let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description); let response = llm_client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api(), + .with_openai_responses(), ) .await; match response { Ok(response) => { let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text( - response.content.as_str(), + response.text.as_str(), )); if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { return tags; diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 9223ce2a8..55571965b 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -7,7 +7,7 @@ use axum::{ sse::{Event, Sse}, }, }; -use platform_llm::{LlmMessage, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmRunRequest}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload; @@ -232,22 +232,22 @@ where }; let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input); - let mut reply_request = LlmTextRequest::new(vec![ + let mut reply_request = LlmRunRequest::new(vec![ LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT), LlmMessage::user(reply_prompt), ]) - .with_chat_completions_api(); - reply_request.max_tokens = Some(700); + .with_openai_chat(); + reply_request.max_output_tokens = Some(700); reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled; reply_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let reply_response = llm_client - .stream_text(reply_request, |delta| { + .stream_run(reply_request, |delta| { on_reply_update(delta.accumulated_text.as_str()); }) .await .ok()?; - let npc_reply = normalize_required_text(reply_response.content.as_str()).unwrap_or_else(|| { + let npc_reply = normalize_required_text(reply_response.text.as_str()).unwrap_or_else(|| { build_deterministic_npc_reply( npc_name, payload.player_message.as_str(), @@ -261,19 +261,19 @@ where let suggestion_prompt = build_npc_chat_turn_suggestion_prompt(&prompt_input, npc_reply.as_str()); - let mut suggestion_request = LlmTextRequest::new(vec![ + let mut suggestion_request = LlmRunRequest::new(vec![ LlmMessage::system(NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT), LlmMessage::user(suggestion_prompt), ]) - .with_chat_completions_api(); - suggestion_request.max_tokens = Some(200); + .with_openai_chat(); + suggestion_request.max_output_tokens = Some(200); suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled; suggestion_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let suggestion_text = llm_client - .request_text(suggestion_request) + .run(suggestion_request) .await .ok() - .map(|response| response.content) + .map(|response| response.text) .unwrap_or_default(); let (mut suggestions, mut function_suggestions, should_end_chat) = parse_npc_chat_suggestion_resolution( diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs index b93e668e0..c8ebd436a 100644 --- a/server-rs/crates/api-server/src/runtime_chat_plain.rs +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -7,7 +7,7 @@ use axum::{ sse::{Event, Sse}, }, }; -use platform_llm::{LlmMessage, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmRunRequest}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload; @@ -582,20 +582,20 @@ async fn request_runtime_plain_text( return fallback_text.unwrap_or_default(); }; - let mut request = LlmTextRequest::new(vec![ + let mut request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) - .with_chat_completions_api(); - request.max_tokens = Some(400); + .with_openai_chat(); + request.max_output_tokens = Some(400); request.enable_web_search = state.config.rpg_llm_web_search_enabled; request.model = Some(RPG_STORY_LLM_MODEL.to_string()); llm_client - .request_text(request) + .run(request) .await .ok() - .map(|response| response.content.trim().to_string()) + .map(|response| response.text.trim().to_string()) .filter(|text| !text.is_empty()) .or(fallback_text) .unwrap_or_default() @@ -615,22 +615,22 @@ fn stream_plain_text_response<'a>( return; }; - let mut request = LlmTextRequest::new(vec![ + let mut request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) - .with_chat_completions_api(); - request.max_tokens = Some(700); + .with_openai_chat(); + request.max_output_tokens = Some(700); request.enable_web_search = enable_web_search; request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let response = llm_client - .stream_text(request, |_| {}) + .stream_run(request, |_| {}) .await; match response { Ok(response) => { - let final_text = response.content.trim(); + let final_text = response.text.trim(); let output = if final_text.is_empty() { fallback_text.as_str() } else { diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index d97ffea09..59977e378 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -744,9 +744,9 @@ async fn generate_runtime_steps( .max_assistant_step_count_per_turn, }, ); - if let Ok(response) = llm_client.request_text(request).await { + if let Ok(response) = llm_client.run(request).await { if let Ok(steps) = - vn_prompt::parse_visual_novel_runtime_steps_fixture(response.content.as_str()) + vn_prompt::parse_visual_novel_runtime_steps_fixture(response.text.as_str()) { return Ok(steps); } @@ -1655,9 +1655,9 @@ async fn create_or_update_creation_draft( }, false, ); - if let Ok(response) = llm_client.request_text(request).await { + if let Ok(response) = llm_client.run(request).await { if let Ok(mut draft) = - vn_prompt::parse_visual_novel_result_draft_fixture(response.content.as_str()) + vn_prompt::parse_visual_novel_result_draft_fixture(response.text.as_str()) { prepare_draft_for_session(&mut draft, None, &now_iso); return Ok(draft); diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 5b8cc0243..9552db726 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -627,7 +627,7 @@ async fn resolve_wooden_fish_work_title( let Some(llm_client) = state.llm_client() else { return Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()); }; - let request = platform_llm::LlmTextRequest::new(vec![ + let request = platform_llm::LlmRunRequest::new(vec![ platform_llm::LlmMessage::system( "你是中文作品标题编辑。请根据敲木鱼作品描述生成一个适合卡片展示的简短中文标题,只输出纯文本,不要 JSON、标点解释或引号。", ), @@ -636,11 +636,11 @@ async fn resolve_wooden_fish_work_title( )), ]) .with_model(crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api(); - let response = llm_client.request_text(request).await; + .with_openai_responses(); + let response = llm_client.run(request).await; match response { Ok(response) => { - let title = normalize_wooden_fish_generated_work_title(response.content.as_str()); + let title = normalize_wooden_fish_generated_work_title(response.text.as_str()); if title.is_empty() { Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()) } else { diff --git a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs index ee528203d..ebec0cfe4 100644 --- a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs +++ b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs @@ -1,6 +1,6 @@ use platform_llm::{ - LlmClient, LlmMessage, LlmMessageContentPart, LlmMessageRole, LlmTextProtocol, LlmTextRequest, - LlmTextResponse, + LlmClient, LlmMessage, LlmMessageContentPart, LlmMessageRole, LlmApiKind, LlmRunRequest, + LlmRunResponse, }; use crate::error::PlatformAgentError; @@ -22,10 +22,10 @@ impl Gpt5ResponsesAgentClient { system_prompt: impl Into, user_text: impl Into, image_urls: Vec, - ) -> Result { + ) -> Result { let request = build_gpt5_multimodal_request(system_prompt, user_text, image_urls); self.llm_client - .request_text(request) + .run(request) .await .map_err(Into::into) } @@ -35,7 +35,7 @@ pub fn build_gpt5_multimodal_request( system_prompt: impl Into, user_text: impl Into, image_urls: Vec, -) -> LlmTextRequest { +) -> LlmRunRequest { let mut user_parts = vec![LlmMessageContentPart::InputText { text: user_text.into(), }]; @@ -45,15 +45,15 @@ pub fn build_gpt5_multimodal_request( .map(|image_url| LlmMessageContentPart::InputImage { image_url }), ); - LlmTextRequest { + LlmRunRequest { model: Some(CREATIVE_AGENT_GPT5_MODEL.to_string()), messages: vec![ LlmMessage::new(LlmMessageRole::System, system_prompt.into()), LlmMessage::multimodal(LlmMessageRole::User, user_parts), ], - max_tokens: None, + max_output_tokens: None, request_timeout_ms: None, enable_web_search: false, - protocol: LlmTextProtocol::Responses, + api_kind: LlmApiKind::OpenAiResponses, } } diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index c2088361e..d02fbc08c 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -6,19 +6,22 @@ `platform-llm` 是 Rust 工作区里的大模型平台适配 crate,当前首版已经落地以下能力: -1. 统一 Ark / DashScope / 其他 OpenAI 兼容网关的文本模型配置结构 -2. 统一 `/chat/completions` 文本请求、非流式响应与 SSE 流式增量解析 +1. 统一 Ark / DashScope / Anthropic / 其他兼容网关的文本模型配置结构 +2. 统一 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 文本请求、非流式响应与 SSE 流式增量解析 3. 统一超时、连接失败、上游错误、空响应与重试策略 4. 为后续 `module-ai`、`module-story`、`module-npc`、`module-custom-world` 提供可直接复用的基础 client ## 2. 当前首版边界 -当前实现只覆盖“文本 chat completion”主链,不提前混入媒体生成和业务编排: +当前实现只覆盖“文本 run”主链,不提前混入媒体生成和业务编排: -1. 支持 OpenAI 兼容格式的 JSON 请求与 SSE 增量响应 -2. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate -3. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API -4. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现 +1. 对外抽象固定为 `LlmRunRequest` / `LlmRunResponse`,不再保留旧 `LlmTextRequest` / `LlmTextResponse` 类型。 +2. 支持 `OpenAiChat` 和 `OpenAiResponses` 两类 API kind 的 JSON 请求与 SSE 增量响应。 +3. 支持 `Anthropic` API kind 的最小文本 Messages 请求、非流式响应与 SSE 文本增量解析;Anthropic URL 默认在 base URL 后拼 `/v1/messages`,如果 base URL 已以 `/v1` 结尾则只拼 `/messages`。 +4. 当前 run 抽象只收敛通用文本结果、finish reason、response id 和 usage;上下文管理、后台执行、provider 原生工具等高级能力后续再按 capability 显式扩展,不把 Responses 语义硬编码进业务层。 +5. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate。 +6. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API。 +7. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现。 ## 3. 核心导出 @@ -28,12 +31,13 @@ 2. `LlmConfig` 3. `LlmMessageRole` 4. `LlmMessage` -5. `LlmTextRequest` -6. `LlmStreamDelta` -7. `LlmTextResponse` -8. `LlmTokenUsage` -9. `LlmClient` -10. `LlmError` +5. `LlmRunRequest` +6. `LlmApiKind` +7. `LlmStreamDelta` +8. `LlmRunResponse` +9. `LlmTokenUsage` +10. `LlmClient` +11. `LlmError` ## 4. 设计文档 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 199e0a6b4..0744e522a 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -19,6 +19,9 @@ pub const DEFAULT_MAX_RETRIES: u32 = 1; pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500; pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions"; pub const RESPONSES_PATH: &str = "/responses"; +pub const ANTHROPIC_MESSAGES_PATH: &str = "/v1/messages"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; +const DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS: u32 = 1024; const DEFAULT_LLM_RAW_LOG_DIR: &str = "logs/llm-raw"; static LLM_RAW_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -72,22 +75,27 @@ pub enum LlmMessageContentPart { InputImage { image_url: String }, } -// 文本补全请求冻结为“消息列表 + 可选模型覆盖 + 可选 max_tokens”最小闭环。 +// 文本补全请求冻结为“消息列表 + 可选模型覆盖 + 可选 max_output_tokens”最小闭环。 #[derive(Clone, Debug, PartialEq, Eq)] -pub struct LlmTextRequest { +pub struct LlmRunRequest { pub model: Option, pub messages: Vec, - pub max_tokens: Option, + pub max_output_tokens: Option, pub enable_web_search: bool, - pub protocol: LlmTextProtocol, + pub api_kind: LlmApiKind, pub request_timeout_ms: Option, } -// 默认走 Responses;旧 OpenAI Chat Completions 兼容入口显式选择。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LlmTextProtocol { - ChatCompletions, - Responses, +// 默认走 OpenAI Responses;旧 OpenAI Chat Completions 兼容入口显式选择。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmApiKind { + #[serde(rename = "openai_chat")] + OpenAiChat, + #[serde(rename = "openai_responses")] + OpenAiResponses, + #[serde(rename = "anthropic")] + Anthropic, } // 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。 @@ -108,10 +116,10 @@ pub struct LlmTokenUsage { // 统一文本响应,避免业务层再去解析 choices/message/content。 #[derive(Clone, Debug, PartialEq, Eq)] -pub struct LlmTextResponse { +pub struct LlmRunResponse { pub provider: LlmProvider, pub model: String, - pub content: String, + pub text: String, pub finish_reason: Option, pub response_id: Option, pub usage: Option, @@ -157,6 +165,7 @@ pub struct LlmClient { enum LlmRequestBody { ChatCompletions(ChatCompletionsRequestBody), Responses(ResponsesRequestBody), + Anthropic(AnthropicMessagesRequestBody), } #[derive(Serialize)] @@ -167,7 +176,8 @@ struct ChatCompletionsRequestBody { #[serde(skip_serializing_if = "Option::is_none")] official_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] - max_tokens: Option, + #[serde(rename = "max_tokens")] + max_output_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] web_search_options: Option, } @@ -235,15 +245,31 @@ struct ResponsesWebSearchTool { max_keyword: u8, } +#[derive(Serialize)] +struct AnthropicMessagesRequestBody { + model: String, + max_tokens: u32, + stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + messages: Vec, +} + +#[derive(Serialize)] +struct AnthropicInputMessage { + role: &'static str, + content: String, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LlmRawFailureInputLog<'a> { provider: &'static str, - protocol: &'static str, + api_kind: &'static str, model: &'a str, stream: bool, attempt: u32, - max_tokens: Option, + max_output_tokens: Option, messages: &'a [LlmMessage], } @@ -334,10 +360,37 @@ struct ResponsesUsage { total_tokens: u64, } +#[derive(Deserialize)] +struct AnthropicResponseEnvelope { + id: Option, + model: Option, + #[serde(default)] + content: Vec, + #[serde(default)] + stop_reason: Option, + usage: Option, +} + +#[derive(Deserialize)] +struct AnthropicContentBlock { + #[serde(rename = "type")] + block_type: Option, + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct AnthropicUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, +} + struct OpenAiCompatibleSseParser { buffer: String, raw_text: String, - protocol: LlmTextProtocol, + api_kind: LlmApiKind, } #[derive(Debug)] @@ -453,6 +506,14 @@ impl LlmConfig { RESPONSES_PATH.trim_start_matches('/') ) } + + pub fn anthropic_messages_url(&self) -> String { + let base_url = self.base_url.trim_end_matches('/'); + if base_url.ends_with("/v1") { + return format!("{base_url}/messages"); + } + format!("{base_url}/{}", ANTHROPIC_MESSAGES_PATH.trim_start_matches('/')) + } } impl LlmMessage { @@ -510,14 +571,14 @@ impl LlmMessage { } } -impl LlmTextRequest { +impl LlmRunRequest { pub fn new(messages: Vec) -> Self { Self { model: None, messages, - max_tokens: None, + max_output_tokens: None, enable_web_search: false, - protocol: LlmTextProtocol::Responses, + api_kind: LlmApiKind::OpenAiResponses, request_timeout_ms: None, } } @@ -534,13 +595,13 @@ impl LlmTextRequest { self } - pub fn with_protocol(mut self, protocol: LlmTextProtocol) -> Self { - self.protocol = protocol; + pub fn with_api_kind(mut self, api_kind: LlmApiKind) -> Self { + self.api_kind = api_kind; self } - pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { - self.max_tokens = Some(max_tokens); + pub fn with_max_output_tokens(mut self, max_output_tokens: u32) -> Self { + self.max_output_tokens = Some(max_output_tokens); self } @@ -549,13 +610,18 @@ impl LlmTextRequest { self } - pub fn with_responses_api(mut self) -> Self { - self.protocol = LlmTextProtocol::Responses; + pub fn with_openai_responses(mut self) -> Self { + self.api_kind = LlmApiKind::OpenAiResponses; self } - pub fn with_chat_completions_api(mut self) -> Self { - self.protocol = LlmTextProtocol::ChatCompletions; + pub fn with_openai_chat(mut self) -> Self { + self.api_kind = LlmApiKind::OpenAiChat; + self + } + + pub fn with_anthropic(mut self) -> Self { + self.api_kind = LlmApiKind::Anthropic; self } @@ -613,6 +679,35 @@ impl LlmTextRequest { )); } + if self.api_kind == LlmApiKind::Anthropic { + if self.enable_web_search { + return Err(LlmError::InvalidRequest( + "Anthropic api_kind 暂不支持 web_search".to_string(), + )); + } + + if self.messages.iter().any(|message| { + message + .content_parts + .iter() + .any(|part| matches!(part, LlmMessageContentPart::InputImage { .. })) + }) { + return Err(LlmError::InvalidRequest( + "Anthropic api_kind 暂不支持图片内容".to_string(), + )); + } + + if !self.messages.iter().any(|message| { + message.role != LlmMessageRole::System + && message_text_for_anthropic(message) + .is_some_and(|text| !text.trim().is_empty()) + }) { + return Err(LlmError::InvalidRequest( + "Anthropic api_kind 至少需要一条 user 或 assistant 消息".to_string(), + )); + } + } + Ok(()) } @@ -631,11 +726,12 @@ impl LlmTextRequest { } } -impl LlmTextProtocol { +impl LlmApiKind { fn as_str(self) -> &'static str { match self { - Self::ChatCompletions => "chat_completions", - Self::Responses => "responses", + Self::OpenAiChat => "openai_chat", + Self::OpenAiResponses => "openai_responses", + Self::Anthropic => "anthropic", } } } @@ -707,7 +803,7 @@ impl LlmClient { &self.config } - pub async fn request_text(&self, request: LlmTextRequest) -> Result { + pub async fn run(&self, request: LlmRunRequest) -> Result { request.validate()?; let resolved_model = request.resolved_model(self.config.model()).to_string(); let response = self.execute_request(&request, false).await?; @@ -725,7 +821,7 @@ impl LlmClient { })?; parse_text_response( - request.protocol, + request.api_kind, self.config.provider(), &resolved_model, raw_text.as_str(), @@ -743,20 +839,20 @@ impl LlmClient { }) } - pub async fn request_single_message_text( + pub async fn run_single_message( &self, system_prompt: impl Into, user_prompt: impl Into, - ) -> Result { - self.request_text(LlmTextRequest::single_turn(system_prompt, user_prompt)) + ) -> Result { + self.run(LlmRunRequest::single_turn(system_prompt, user_prompt)) .await } - pub async fn stream_text( + pub async fn stream_run( &self, - request: LlmTextRequest, + request: LlmRunRequest, mut on_delta: F, - ) -> Result + ) -> Result where F: FnMut(&LlmStreamDelta), { @@ -769,7 +865,7 @@ impl LlmClient { .and_then(|value| value.to_str().ok()) .map(str::to_string); - let mut parser = OpenAiCompatibleSseParser::new(request.protocol); + let mut parser = OpenAiCompatibleSseParser::new(request.api_kind); let mut accumulated_text = String::new(); let mut finish_reason = None; let mut undecoded_chunk_bytes = Vec::new(); @@ -926,27 +1022,27 @@ impl LlmClient { return Err(LlmError::EmptyResponse); } - Ok(LlmTextResponse { + Ok(LlmRunResponse { provider: self.config.provider(), model: resolved_model, - content, + text: content, finish_reason, response_id, usage: None, }) } - pub async fn stream_single_message_text( + pub async fn stream_single_message( &self, system_prompt: impl Into, user_prompt: impl Into, on_delta: F, - ) -> Result + ) -> Result where F: FnMut(&LlmStreamDelta), { - self.stream_text( - LlmTextRequest::single_turn(system_prompt, user_prompt), + self.stream_run( + LlmRunRequest::single_turn(system_prompt, user_prompt), on_delta, ) .await @@ -954,31 +1050,38 @@ impl LlmClient { async fn execute_request( &self, - request: &LlmTextRequest, + request: &LlmRunRequest, stream: bool, ) -> Result { let request_body = build_request_body(request, &self.config, stream); let model = request.resolved_model(self.config.model()); - let url = match request.protocol { - LlmTextProtocol::ChatCompletions => self.config.chat_completions_url(), - LlmTextProtocol::Responses => self.config.responses_url(), + let url = match request.api_kind { + LlmApiKind::OpenAiChat => self.config.chat_completions_url(), + LlmApiKind::OpenAiResponses => self.config.responses_url(), + LlmApiKind::Anthropic => self.config.anthropic_messages_url(), }; let max_attempts = self.config.max_retries().saturating_add(1); for attempt in 1..=max_attempts { debug!( - "platform-llm request started: provider={}, protocol={}, stream={}, attempt={}, model={}", + "platform-llm request started: provider={}, api_kind={}, stream={}, attempt={}, model={}", self.config.provider().as_str(), - request.protocol.as_str(), + request.api_kind.as_str(), stream, attempt, model ); - let send_result = self - .http_client - .post(url.as_str()) - .bearer_auth(self.config.api_key()) + let mut request_builder = self.http_client.post(url.as_str()); + request_builder = match request.api_kind { + LlmApiKind::OpenAiChat | LlmApiKind::OpenAiResponses => { + request_builder.bearer_auth(self.config.api_key()) + } + LlmApiKind::Anthropic => request_builder + .header("x-api-key", self.config.api_key()) + .header("anthropic-version", ANTHROPIC_VERSION), + }; + let send_result = request_builder .json(&request_body) .timeout(Duration::from_millis( request.resolved_request_timeout_ms(self.config.request_timeout_ms()), @@ -989,9 +1092,9 @@ impl LlmClient { match send_result { Ok(response) if response.status().is_success() => { debug!( - "platform-llm request succeeded: provider={}, protocol={}, stream={}, attempt={}, status={}", + "platform-llm request succeeded: provider={}, api_kind={}, stream={}, attempt={}, status={}", self.config.provider().as_str(), - request.protocol.as_str(), + request.api_kind.as_str(), stream, attempt, response.status().as_u16() @@ -1005,9 +1108,9 @@ impl LlmClient { if should_retry_status(status) && attempt < max_attempts { warn!( - "platform-llm request retrying after upstream status: provider={}, protocol={}, attempt={}, status={}, message={}", + "platform-llm request retrying after upstream status: provider={}, api_kind={}, attempt={}, status={}, message={}", self.config.provider().as_str(), - request.protocol.as_str(), + request.api_kind.as_str(), attempt, status.as_u16(), message @@ -1032,9 +1135,9 @@ impl LlmClient { Err(error) if error.is_timeout() => { if attempt < max_attempts { warn!( - "platform-llm request retrying after timeout: provider={}, protocol={}, attempt={}", + "platform-llm request retrying after timeout: provider={}, api_kind={}, attempt={}", self.config.provider().as_str(), - request.protocol.as_str(), + request.api_kind.as_str(), attempt ); self.sleep_before_retry(attempt).await; @@ -1056,9 +1159,9 @@ impl LlmClient { let message = format_error_chain(&error); if attempt < max_attempts { warn!( - "platform-llm request retrying after connectivity failure: provider={}, protocol={}, attempt={}, error={}", + "platform-llm request retrying after connectivity failure: provider={}, api_kind={}, attempt={}, error={}", self.config.provider().as_str(), - request.protocol.as_str(), + request.api_kind.as_str(), attempt, message ); @@ -1113,11 +1216,11 @@ impl LlmClient { } impl OpenAiCompatibleSseParser { - fn new(protocol: LlmTextProtocol) -> Self { + fn new(api_kind: LlmApiKind) -> Self { Self { buffer: String::new(), raw_text: String::new(), - protocol, + api_kind, } } @@ -1148,7 +1251,7 @@ impl OpenAiCompatibleSseParser { let block = self.buffer[..boundary].to_string(); self.buffer = self.buffer[(boundary + 2)..].to_string(); - if let Some(event) = parse_sse_event_block(self.protocol, block.as_str())? { + if let Some(event) = parse_sse_event_block(self.api_kind, block.as_str())? { events.push(event); } } @@ -1166,32 +1269,28 @@ fn normalize_non_empty(value: String, error_message: &str) -> Result LlmRequestBody { +fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) -> LlmRequestBody { let fallback_model = config.model(); let official_fallback = config.official_fallback().then_some(true); - match request.protocol { - LlmTextProtocol::ChatCompletions => { + match request.api_kind { + LlmApiKind::OpenAiChat => { LlmRequestBody::ChatCompletions(ChatCompletionsRequestBody { model: request.resolved_model(fallback_model).to_string(), messages: map_chat_completions_input_messages(request.messages.as_slice()), stream, official_fallback, - max_tokens: request.max_tokens, + max_output_tokens: request.max_output_tokens, web_search_options: request .enable_web_search .then_some(ChatCompletionsWebSearchOptions {}), }) } - LlmTextProtocol::Responses => LlmRequestBody::Responses(ResponsesRequestBody { + LlmApiKind::OpenAiResponses => LlmRequestBody::Responses(ResponsesRequestBody { model: request.resolved_model(fallback_model).to_string(), stream, input: map_responses_input_messages(request.messages.as_slice()), official_fallback, - max_output_tokens: request.max_tokens, + max_output_tokens: request.max_output_tokens, tools: request.enable_web_search.then(|| { vec![ResponsesWebSearchTool { tool_type: "web_search", @@ -1199,6 +1298,49 @@ fn build_request_body( }] }), }), + LlmApiKind::Anthropic => { + LlmRequestBody::Anthropic(build_anthropic_messages_request_body( + request, + fallback_model, + stream, + )) + } + } +} + +fn build_anthropic_messages_request_body( + request: &LlmRunRequest, + fallback_model: &str, + stream: bool, +) -> AnthropicMessagesRequestBody { + let system = request + .messages + .iter() + .filter(|message| message.role == LlmMessageRole::System) + .filter_map(message_text_for_anthropic) + .collect::>() + .join("\n\n"); + let messages = request + .messages + .iter() + .filter(|message| message.role != LlmMessageRole::System) + .filter_map(|message| { + let content = message_text_for_anthropic(message)?; + Some(AnthropicInputMessage { + role: map_anthropic_message_role(message.role), + content, + }) + }) + .collect(); + + AnthropicMessagesRequestBody { + model: request.resolved_model(fallback_model).to_string(), + max_tokens: request + .max_output_tokens + .unwrap_or(DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS), + stream, + system: (!system.is_empty()).then_some(system), + messages, } } @@ -1257,6 +1399,32 @@ fn map_llm_message_role(role: LlmMessageRole) -> &'static str { } } +fn map_anthropic_message_role(role: LlmMessageRole) -> &'static str { + match role { + LlmMessageRole::System | LlmMessageRole::User => "user", + LlmMessageRole::Assistant => "assistant", + } +} + +fn message_text_for_anthropic(message: &LlmMessage) -> Option { + if message.content_parts.is_empty() { + return (!message.content.trim().is_empty()).then(|| message.content.clone()); + } + + let text = message + .content_parts + .iter() + .filter_map(|part| match part { + LlmMessageContentPart::InputText { text } => Some(text.as_str()), + LlmMessageContentPart::InputImage { .. } => None, + }) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n"); + + (!text.is_empty()).then_some(text) +} + fn map_responses_content_parts(message: &LlmMessage) -> Vec { if message.content_parts.is_empty() { return vec![ResponsesInputContentPart::InputText { @@ -1282,7 +1450,7 @@ fn map_responses_content_parts(message: &LlmMessage) -> Vec String { } fn parse_text_response( - protocol: LlmTextProtocol, + api_kind: LlmApiKind, provider: LlmProvider, fallback_model: &str, raw_text: &str, -) -> Result { - match protocol { - LlmTextProtocol::ChatCompletions => { +) -> Result { + match api_kind { + LlmApiKind::OpenAiChat => { parse_chat_completions_response(provider, fallback_model, raw_text) } - LlmTextProtocol::Responses => parse_responses_response(provider, fallback_model, raw_text), + LlmApiKind::OpenAiResponses => { + parse_responses_response(provider, fallback_model, raw_text) + } + LlmApiKind::Anthropic => parse_anthropic_response(provider, fallback_model, raw_text), } } @@ -1380,7 +1551,7 @@ fn parse_chat_completions_response( provider: LlmProvider, fallback_model: &str, raw_text: &str, -) -> Result { +) -> Result { let parsed: ChatCompletionsResponsePayload = serde_json::from_str(raw_text) .map_err(|error| LlmError::Deserialize(format!("解析 LLM JSON 响应失败:{error}")))?; let parsed = match parsed { @@ -1401,10 +1572,10 @@ fn parse_chat_completions_response( return Err(LlmError::EmptyResponse); } - Ok(LlmTextResponse { + Ok(LlmRunResponse { provider, model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), - content, + text: content, finish_reason: first_choice.finish_reason.clone(), response_id: parsed.id, usage: parsed.usage, @@ -1415,7 +1586,7 @@ fn parse_responses_response( provider: LlmProvider, fallback_model: &str, raw_text: &str, -) -> Result { +) -> Result { let parsed: ResponsesResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| { LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}")) })?; @@ -1428,10 +1599,10 @@ fn parse_responses_response( return Err(LlmError::EmptyResponse); } - Ok(LlmTextResponse { + Ok(LlmRunResponse { provider, model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), - content, + text: content, finish_reason: parsed.status, response_id: parsed.id, usage: parsed.usage.map(|usage| LlmTokenUsage { @@ -1442,6 +1613,37 @@ fn parse_responses_response( }) } +fn parse_anthropic_response( + provider: LlmProvider, + fallback_model: &str, + raw_text: &str, +) -> Result { + let parsed: AnthropicResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| { + LlmError::Deserialize(format!("解析 LLM Anthropic JSON 响应失败:{error}")) + })?; + let content = extract_anthropic_text(&parsed) + .ok_or(LlmError::EmptyResponse)? + .trim() + .to_string(); + + if content.is_empty() { + return Err(LlmError::EmptyResponse); + } + + Ok(LlmRunResponse { + provider, + model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + text: content, + finish_reason: parsed.stop_reason, + response_id: parsed.id, + usage: parsed.usage.map(|usage| LlmTokenUsage { + prompt_tokens: usage.input_tokens, + completion_tokens: usage.output_tokens, + total_tokens: usage.input_tokens.saturating_add(usage.output_tokens), + }), + }) +} + fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option { parsed .output_text @@ -1461,6 +1663,18 @@ fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option }) } +fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option { + let text = parsed + .content + .iter() + .filter(|block| block.block_type.as_deref().unwrap_or("text") == "text") + .filter_map(|block| block.text.as_deref()) + .collect::>() + .join(""); + + if text.is_empty() { None } else { Some(text) } +} + fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { choice .message @@ -1511,7 +1725,7 @@ fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec), LlmError> } fn parse_sse_event_block( - protocol: LlmTextProtocol, + api_kind: LlmApiKind, block: &str, ) -> Result, LlmError> { let data_lines = block @@ -1529,10 +1743,14 @@ fn parse_sse_event_block( return Ok(None); } - if protocol == LlmTextProtocol::Responses { + if api_kind == LlmApiKind::OpenAiResponses { return parse_responses_sse_event(data.as_str()); } + if api_kind == LlmApiKind::Anthropic { + return parse_anthropic_sse_event(data.as_str()); + } + let parsed: ChatCompletionsResponseEnvelope = serde_json::from_str(data.as_str()) .map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?; let first_choice = parsed @@ -1584,6 +1802,63 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll } } +fn parse_anthropic_sse_event(data: &str) -> Result, LlmError> { + let parsed: serde_json::Value = serde_json::from_str(data).map_err(|error| { + LlmError::Deserialize(format!("解析 LLM Anthropic SSE 事件失败:{error}")) + })?; + let event_type = parsed + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + + match event_type { + "content_block_delta" => { + let delta = parsed.get("delta"); + let delta_type = delta + .and_then(|value| value.get("type")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if delta_type != "text_delta" { + return Ok(None); + } + + Ok(Some(ParsedStreamEvent { + delta_text: delta + .and_then(|value| value.get("text")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + finish_reason: None, + })) + } + "message_delta" => Ok(Some(ParsedStreamEvent { + delta_text: None, + finish_reason: parsed + .get("delta") + .and_then(|value| value.get("stop_reason")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + })), + "message_stop" => Ok(Some(ParsedStreamEvent { + delta_text: None, + finish_reason: Some("stop".to_string()), + })), + "error" => { + let message = parsed + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .or_else(|| parsed.get("message").and_then(serde_json::Value::as_str)) + .unwrap_or("LLM Anthropic SSE 返回失败事件") + .to_string(); + Err(LlmError::Upstream { + status_code: 502, + message, + }) + } + _ => Ok(None), + } +} + fn should_retry_status(status: StatusCode) -> bool { status == StatusCode::REQUEST_TIMEOUT || status == StatusCode::TOO_MANY_REQUESTS @@ -1728,18 +2003,18 @@ mod tests { } #[test] - fn text_request_defaults_to_responses_protocol() { - let request = LlmTextRequest::single_turn("系统", "用户"); + fn run_request_defaults_to_openai_responses_api_kind() { + let request = LlmRunRequest::single_turn("系统", "用户"); - assert_eq!(request.protocol, LlmTextProtocol::Responses); + assert_eq!(request.api_kind, LlmApiKind::OpenAiResponses); assert_eq!( - request.with_chat_completions_api().protocol, - LlmTextProtocol::ChatCompletions + request.with_openai_chat().api_kind, + LlmApiKind::OpenAiChat ); } #[tokio::test] - async fn request_text_sends_official_fallback_for_openai_compatible_clients() { + async fn run_sends_official_fallback_for_openai_compatible_clients() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); let server_handle = thread::spawn(move || { @@ -1770,9 +2045,9 @@ mod tests { .with_official_fallback(true); let client = LlmClient::new(config).expect("client should be created"); let response = client - .request_text(LlmTextRequest::single_turn("系统", "用户").with_responses_api()) + .run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses()) .await - .expect("request_text should succeed"); + .expect("run should succeed"); let request_text = server_handle.join().expect("server thread should join"); let request_body = request_text @@ -1782,13 +2057,13 @@ mod tests { let request_json: serde_json::Value = serde_json::from_str(request_body).expect("request body should be json"); - assert_eq!(response.content, "兼容成功"); + assert_eq!(response.text, "兼容成功"); assert_eq!(request_json["official_fallback"], serde_json::json!(true)); } #[test] fn sse_parser_handles_split_chunks_and_done_marker() { - let mut parser = OpenAiCompatibleSseParser::new(LlmTextProtocol::ChatCompletions); + let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat); let events_a = parser .push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\r\n\r\n") .expect("first chunk should parse"); @@ -1805,7 +2080,7 @@ mod tests { #[test] fn responses_sse_parser_only_emits_output_text_delta() { - let mut parser = OpenAiCompatibleSseParser::new(LlmTextProtocol::Responses); + let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiResponses); let events = parser .push_chunk(concat!( "data: {\"type\":\"response.created\"}\n\n", @@ -1838,7 +2113,7 @@ mod tests { } #[tokio::test] - async fn request_text_parses_chat_completions_non_stream_response() { + async fn run_parses_chat_completions_non_stream_response() { let server_url = spawn_mock_server(vec![MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", @@ -1848,13 +2123,13 @@ mod tests { let client = build_test_client(server_url, 0); let response = client - .request_text(LlmTextRequest::single_turn("系统", "用户").with_chat_completions_api()) + .run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat()) .await - .expect("request_text should succeed"); + .expect("run should succeed"); assert_eq!(response.provider, LlmProvider::Ark); assert_eq!(response.model, "ark-test-model"); - assert_eq!(response.content, "测试成功"); + assert_eq!(response.text, "测试成功"); assert_eq!(response.finish_reason.as_deref(), Some("stop")); assert_eq!(response.response_id.as_deref(), Some("resp_01")); assert_eq!( @@ -1868,7 +2143,7 @@ mod tests { } #[tokio::test] - async fn request_text_retries_after_upstream_500() { + async fn run_retries_after_upstream_500() { let server_url = spawn_mock_server(vec![ MockResponse { status_line: "500 Internal Server Error", @@ -1886,16 +2161,16 @@ mod tests { let client = build_test_client(server_url, 1); let response = client - .request_text(LlmTextRequest::single_turn("系统", "用户").with_chat_completions_api()) + .run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat()) .await .expect("second attempt should succeed"); - assert_eq!(response.content, "第二次成功"); + assert_eq!(response.text, "第二次成功"); assert_eq!(response.response_id.as_deref(), Some("resp_retry")); } #[tokio::test] - async fn request_text_uses_request_level_timeout_override() { + async fn run_uses_request_level_timeout_override() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); thread::spawn(move || { @@ -1928,9 +2203,9 @@ mod tests { let client = LlmClient::new(config).expect("client should be created"); let error = client - .request_text( - LlmTextRequest::single_turn("系统", "用户") - .with_chat_completions_api() + .run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_chat() .with_request_timeout_ms(20), ) .await @@ -1940,7 +2215,7 @@ mod tests { } #[tokio::test] - async fn request_text_sends_web_search_options_when_enabled() { + async fn run_sends_web_search_options_when_enabled() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); let server_handle = thread::spawn(move || { @@ -1960,14 +2235,14 @@ mod tests { let client = build_test_client(format!("http://{address}"), 0); let response = client - .request_text( - LlmTextRequest::single_turn("系统", "用户") - .with_chat_completions_api() + .run( + LlmRunRequest::single_turn("系统", "用户") + .with_openai_chat() .with_web_search(true) - .with_max_tokens(128), + .with_max_output_tokens(128), ) .await - .expect("request_text should succeed"); + .expect("run should succeed"); let request_text = server_handle.join().expect("server thread should join"); let request_body = request_text @@ -1977,7 +2252,7 @@ mod tests { let request_json: serde_json::Value = serde_json::from_str(request_body).expect("request body should be json"); - assert_eq!(response.content, "搜索成功"); + assert_eq!(response.text, "搜索成功"); assert_eq!(request_json["web_search_options"], serde_json::json!({})); assert!(request_json.get("official_fallback").is_none()); } @@ -2014,8 +2289,8 @@ mod tests { .with_official_fallback(true); let client = LlmClient::new(config).expect("client should be created"); let response = client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system("你是拼图关卡命名编辑"), LlmMessage::user_multimodal(vec![ LlmMessageContentPart::InputText { @@ -2026,10 +2301,10 @@ mod tests { }, ]), ]) - .with_chat_completions_api(), + .with_openai_chat(), ) .await - .expect("request_text should succeed"); + .expect("run should succeed"); let request_text = server_handle.join().expect("server thread should join"); let request_line = request_text.lines().next().unwrap_or_default(); @@ -2042,7 +2317,7 @@ mod tests { assert!(request_line.contains("POST /chat/completions HTTP/1.1")); assert_eq!(response.model, "gpt-4o-mini"); - assert_eq!(response.content, r#"{"levelName":"雨夜猫街"}"#); + assert_eq!(response.text, r#"{"levelName":"雨夜猫街"}"#); assert_eq!(request_json["official_fallback"], serde_json::json!(true)); assert_eq!( request_json["messages"][1]["content"], @@ -2054,7 +2329,7 @@ mod tests { } #[tokio::test] - async fn request_text_sends_responses_body_with_web_search_tool() { + async fn run_sends_responses_body_with_web_search_tool() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); let server_handle = thread::spawn(move || { @@ -2074,15 +2349,15 @@ mod tests { let client = build_test_client(format!("http://{address}"), 0); let response = client - .request_text( - LlmTextRequest::single_turn("系统", "用户") + .run( + LlmRunRequest::single_turn("系统", "用户") .with_model("deepseek-v3-2-251201") - .with_responses_api() + .with_openai_responses() .with_web_search(true) - .with_max_tokens(128), + .with_max_output_tokens(128), ) .await - .expect("responses request_text should succeed"); + .expect("responses run should succeed"); let request_text = server_handle.join().expect("server thread should join"); let request_line = request_text.lines().next().unwrap_or_default(); @@ -2094,7 +2369,7 @@ mod tests { serde_json::from_str(request_body).expect("request body should be json"); assert!(request_line.contains("POST /responses HTTP/1.1")); - assert_eq!(response.content, "Responses 成功"); + assert_eq!(response.text, "Responses 成功"); assert_eq!(response.model, "deepseek-v3-2-251201"); assert_eq!( response.usage, @@ -2141,8 +2416,8 @@ mod tests { let client = build_test_client(format!("http://{address}"), 0); let response = client - .request_text( - LlmTextRequest::new(vec![ + .run( + LlmRunRequest::new(vec![ LlmMessage::system("你是创意互动内容生成 Agent"), LlmMessage::user_multimodal(vec![ LlmMessageContentPart::InputText { @@ -2154,10 +2429,10 @@ mod tests { ]), ]) .with_model("gpt-5") - .with_responses_api(), + .with_openai_responses(), ) .await - .expect("responses multimodal request_text should succeed"); + .expect("responses multimodal run should succeed"); let request_text = server_handle.join().expect("server thread should join"); let request_body = request_text @@ -2180,7 +2455,7 @@ mod tests { } #[tokio::test] - async fn stream_text_accumulates_sse_response() { + async fn stream_run_accumulates_sse_response() { let server_url = spawn_mock_server(vec![MockResponse { status_line: "200 OK", content_type: "text/event-stream; charset=utf-8", @@ -2197,23 +2472,23 @@ mod tests { let client = build_test_client(server_url, 0); let mut updates = Vec::new(); let response = client - .stream_text( - LlmTextRequest::single_turn("系统", "用户").with_chat_completions_api(), + .stream_run( + LlmRunRequest::single_turn("系统", "用户").with_openai_chat(), |delta| { updates.push(delta.accumulated_text.clone()); }, ) .await - .expect("stream_text should succeed"); + .expect("stream_run should succeed"); assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]); - assert_eq!(response.content, "你好"); + assert_eq!(response.text, "你好"); assert_eq!(response.finish_reason.as_deref(), Some("stop")); assert_eq!(response.response_id.as_deref(), Some("req_stream_01")); } #[tokio::test] - async fn stream_text_accumulates_responses_sse_response() { + async fn stream_run_accumulates_responses_sse_response() { let server_url = spawn_mock_server(vec![MockResponse { status_line: "200 OK", content_type: "text/event-stream; charset=utf-8", @@ -2229,17 +2504,17 @@ mod tests { let client = build_test_client(server_url, 0); let mut updates = Vec::new(); let response = client - .stream_text( - LlmTextRequest::single_turn("系统", "用户").with_responses_api(), + .stream_run( + LlmRunRequest::single_turn("系统", "用户").with_openai_responses(), |delta| { updates.push(delta.accumulated_text.clone()); }, ) .await - .expect("responses stream_text should succeed"); + .expect("responses stream_run should succeed"); assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]); - assert_eq!(response.content, "你好"); + assert_eq!(response.text, "你好"); assert_eq!(response.finish_reason.as_deref(), Some("completed")); assert_eq!( response.response_id.as_deref(), @@ -2248,7 +2523,7 @@ mod tests { } #[tokio::test] - async fn request_text_writes_raw_failure_logs_after_parse_error() { + async fn run_writes_raw_failure_logs_after_parse_error() { let log_dir = std::env::temp_dir().join(format!( "platform-llm-raw-log-test-{}", build_llm_raw_log_prefix("parse_error") @@ -2266,9 +2541,7 @@ mod tests { let client = build_test_client(server_url, 0); let error = client - .request_text( - LlmTextRequest::single_turn("系统原文", "用户原文").with_chat_completions_api(), - ) + .run(LlmRunRequest::single_turn("系统原文", "用户原文").with_openai_chat()) .await .expect_err("invalid json should fail"); @@ -2305,6 +2578,95 @@ mod tests { fs::remove_dir_all(log_dir).expect("log dir should be removed"); } + #[tokio::test] + async fn run_sends_anthropic_messages_request_and_parses_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"msg_01","model":"claude-test","content":[{"type":"text","text":"Anthropic 成功"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":3}}"#.to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + let response = client + .run(LlmRunRequest::single_turn("系统", "用户").with_anthropic()) + .await + .expect("anthropic run should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert!(request_text.contains("POST /v1/messages HTTP/1.1")); + assert!(request_text.contains("x-api-key: test-key")); + assert!(request_text.contains("anthropic-version: 2023-06-01")); + assert_eq!(response.text, "Anthropic 成功"); + assert_eq!(response.finish_reason.as_deref(), Some("end_turn")); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 5, + completion_tokens: 3, + total_tokens: 8, + }) + ); + assert_eq!(request_json["model"], serde_json::json!("test-model")); + assert_eq!(request_json["system"], serde_json::json!("系统")); + assert_eq!( + request_json["messages"], + serde_json::json!([{ "role": "user", "content": "用户" }]) + ); + } + + #[tokio::test] + async fn stream_run_accumulates_anthropic_sse_response() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n", + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + ) + .to_string(), + extra_headers: vec![("x-request-id", "req_anthropic_stream_01")], + }]); + + let client = build_test_client(server_url, 0); + let mut updates = Vec::new(); + let response = client + .stream_run( + LlmRunRequest::single_turn("系统", "用户").with_anthropic(), + |delta| { + updates.push(delta.accumulated_text.clone()); + }, + ) + .await + .expect("anthropic stream_run should succeed"); + + assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]); + assert_eq!(response.text, "你好"); + assert_eq!(response.finish_reason.as_deref(), Some("end_turn")); + assert_eq!( + response.response_id.as_deref(), + Some("req_anthropic_stream_01") + ); + } + fn build_test_client(base_url: String, max_retries: u32) -> LlmClient { let config = LlmConfig::new( LlmProvider::Ark,