From 65d1489bddbd9d37349bc8335b061c64a29e11fe Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 30 Jun 2026 19:33:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Anthropic=20=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=EF=BC=8CLLM=20=E8=AF=B7=E6=B1=82=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E4=B8=BA=20LlmRunRequest/LlmApiKind=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?Agent=E6=B5=81=E7=A8=8B=E5=9B=BE=E7=BB=98=E5=9B=BE=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Anthropic 链路,LLM 请求收口为 LlmRunRequest/LlmApiKind,为未来创建更多字段适配高级功能准备 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/70 Co-authored-by: Linghong Co-committed-by: Linghong --- .../game-creator.config.json | 2 +- .../scripts/check-config.mjs | 2 +- .../smoke-agent-run-local-provider.mjs | 2 +- .../scripts/start-dev-server.mjs | 2 + .../src-tauri/src/main.rs | 125 ++-- apps/ai-game-creator-shell/src/App.tsx | 25 +- .../tests/appSurface.test.ts | 12 +- .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../api-server/src/big_fish_draft_compiler.rs | 18 +- .../api-server/src/creation_agent_llm_turn.rs | 22 +- .../src/custom_world_agent_entities.rs | 10 +- .../crates/api-server/src/custom_world_ai.rs | 18 +- .../src/custom_world_foundation_draft.rs | 22 +- server-rs/crates/api-server/src/llm.rs | 20 +- server-rs/crates/api-server/src/match3d.rs | 2 +- .../crates/api-server/src/match3d/draft.rs | 8 +- .../crates/api-server/src/match3d/tags.rs | 8 +- .../api-server/src/prompt/visual_novel.rs | 26 +- server-rs/crates/api-server/src/puzzle.rs | 2 +- .../crates/api-server/src/puzzle/draft.rs | 16 +- .../crates/api-server/src/puzzle/tags.rs | 8 +- .../crates/api-server/src/runtime_chat.rs | 22 +- .../api-server/src/runtime_chat_plain.rs | 22 +- .../crates/api-server/src/visual_novel.rs | 8 +- .../crates/api-server/src/wooden_fish.rs | 8 +- .../src/apimart_gpt5_adapter.rs | 16 +- server-rs/crates/platform-llm/README.md | 30 +- server-rs/crates/platform-llm/src/lib.rs | 664 ++++++++++++++---- 30 files changed, 762 insertions(+), 370 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 0d101317c..57df7143c 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -3,7 +3,7 @@ "apiKey": "", "baseUrl": "https://api.openai.com/v1", "model": "gpt-4.1", - "protocol": "responses", + "apiKind": "openai_responses", "stream": false, "requestTimeoutMs": 180000, "maxRetries": 0, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 90645c2cf..89f505d1e 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -357,7 +357,7 @@ for (const snippet of [ 'requestJson?.stream === true', `requestBodies.every((body) => body.includes('"stream":true'))`, "const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')", - "protocol: 'chat_completions'", + "apiKind: 'openai_chat'", 'stream: true', 'await restoreOptionalFile(localConfigPath, previousLocalConfig)', "method: 'HEAD'", 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 a6f1279d5..63ca78f63 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 @@ -577,7 +577,7 @@ async function writeSmokeLocalConfig(baseUrl) { apiKey: 'local-provider-key', baseUrl, model: 'local-game-creator-smoke', - protocol: 'chat_completions', + apiKind: 'openai_chat', stream: true, }, }, diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs index 9a4914dcb..f1f76ff31 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs @@ -92,6 +92,8 @@ const child = spawn( { cwd: appRoot, stdio: 'inherit', + // Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL). + shell: true, }, ); 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 6d5ccbdd6..aa989ed36 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; @@ -92,7 +92,7 @@ struct GameCreatorLlmConfigStatus { api_key_present: bool, base_url: Option, model: Option, - protocol: String, + api_kind: String, error: Option, } @@ -109,7 +109,7 @@ struct GameCreatorLlmConfigFile { api_key: Option, base_url: Option, model: Option, - protocol: Option, + api_kind: Option, stream: Option, request_timeout_ms: Option, max_retries: Option, @@ -136,7 +136,7 @@ struct GameCreatorLlmConfig { api_key: String, base_url: String, model: String, - protocol: String, + api_kind: String, stream: bool, request_timeout_ms: u64, max_retries: u32, @@ -437,7 +437,7 @@ const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"; const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1"; -const DEFAULT_GAME_CREATOR_LLM_PROTOCOL: &str = "responses"; +const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; @@ -481,7 +481,7 @@ impl Default for GameCreatorLlmConfig { api_key: String::new(), base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), - protocol: DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), stream: false, request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, max_retries: 0, @@ -1432,23 +1432,24 @@ fn build_game_creator_llm_client_from_config() -> Result { LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) } -fn read_game_creator_llm_protocol_from_config() -> Result { +fn read_game_creator_llm_api_kind_from_config() -> Result { let app_config = load_game_creator_app_config()?; - parse_game_creator_llm_protocol(&app_config.llm.protocol) + parse_game_creator_llm_api_kind(&app_config.llm.api_kind) } -fn parse_game_creator_llm_protocol(value: &str) -> Result { +fn parse_game_creator_llm_api_kind(value: &str) -> Result { let normalized = value.trim().to_ascii_lowercase().replace('-', "_"); let normalized = if normalized.is_empty() { - DEFAULT_GAME_CREATOR_LLM_PROTOCOL + DEFAULT_GAME_CREATOR_LLM_API_KIND } else { normalized.as_str() }; match normalized { - "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" )), } } @@ -1462,18 +1463,18 @@ fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus { api_key_present: false, base_url: None, model: None, - protocol: DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), error: Some(error), } } }; let mut status = check_game_creator_llm_config_values(&app_config.llm); - status.protocol = parse_game_creator_llm_protocol(&app_config.llm.protocol) - .map(game_creator_llm_protocol_name) + status.api_kind = parse_game_creator_llm_api_kind(&app_config.llm.api_kind) + .map(game_creator_llm_api_kind_name) .unwrap_or_else(|error| { status.configured = false; status.error = Some(error); - DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string() + DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string() }); if status.configured { if let Err(error) = build_game_creator_llm_client_from_config() { @@ -1516,15 +1517,16 @@ fn check_game_creator_llm_config_values( api_key_present, base_url, model, - protocol: DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.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() } @@ -1898,7 +1900,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, @@ -1906,12 +1908,12 @@ async fn request_planner_spec_with_client( long_memory, )), ]) - .with_protocol(read_game_creator_llm_protocol_from_config()?) - .with_max_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); + .with_api_kind(read_game_creator_llm_api_kind_from_config()?) + .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 { @@ -1946,12 +1948,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_config()?) - .with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); + .with_api_kind(read_game_creator_llm_api_kind_from_config()?) + .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 => { @@ -1979,19 +1981,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 } } @@ -4640,8 +4642,8 @@ fn merge_game_creator_llm_config( if let Some(value) = patch.model { config.model = value; } - if let Some(value) = patch.protocol { - config.protocol = value; + if let Some(value) = patch.api_kind { + config.api_kind = value; } if let Some(value) = patch.stream { config.stream = value; @@ -4685,8 +4687,8 @@ fn normalize_game_creator_app_config( config.llm.base_url = trim_config_string(&config.llm.base_url).ok_or_else(llm_base_url_config_error)?; config.llm.model = trim_config_string(&config.llm.model).ok_or_else(llm_model_config_error)?; - config.llm.protocol = game_creator_llm_protocol_name(parse_game_creator_llm_protocol( - &config.llm.protocol, + config.llm.api_kind = game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind( + &config.llm.api_kind, )?); if config.llm.request_timeout_ms == 0 { return Err("配置项 llm.requestTimeoutMs 必须大于 0".to_string()); @@ -6368,7 +6370,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}"); } @@ -6600,7 +6602,7 @@ mod tests { "apiKey": "file-key", "baseUrl": "https://example.test/v1", "model": "model-from-file", - "protocol": "chat_completions", + "apiKind": "openai_chat", "stream": true, "requestTimeoutMs": 42000, "maxRetries": 2, @@ -6621,7 +6623,7 @@ mod tests { assert_eq!(config.llm.api_key, "file-key"); assert_eq!(config.llm.base_url, "https://example.test/v1"); assert_eq!(config.llm.model, "model-from-file"); - assert_eq!(config.llm.protocol, "chat_completions"); + assert_eq!(config.llm.api_kind, "openai_chat"); assert!(config.llm.stream); assert_eq!(config.llm.request_timeout_ms, 42_000); assert_eq!(config.llm.max_retries, 2); @@ -6670,7 +6672,7 @@ mod tests { api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), model: " runtime-model ".to_string(), - protocol: "chat".to_string(), + api_kind: "openai_chat".to_string(), stream: true, request_timeout_ms: 42_000, max_retries: 2, @@ -6691,7 +6693,7 @@ mod tests { ); assert_eq!(saved.config.llm.api_key, "unit-test-key"); assert_eq!(saved.config.llm.base_url, "https://runtime.example.test/v1"); - assert_eq!(saved.config.llm.protocol, "chat_completions"); + assert_eq!(saved.config.llm.api_kind, "openai_chat"); assert_eq!(saved.config.editor_api.api_key, "editor-key"); assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file()); @@ -6702,7 +6704,7 @@ mod tests { } #[test] - fn app_config_write_rejects_invalid_protocol() { + fn app_config_write_rejects_invalid_api_kind() { let root = unique_project_path(); fs::create_dir_all(&root).expect("runtime config dir"); let _guard = use_test_runtime_config_dir(root.clone()); @@ -6710,15 +6712,15 @@ mod tests { let result = write_game_creator_app_config(GameCreatorAppConfig { llm: GameCreatorLlmConfig { api_key: String::new(), - protocol: "legacy".to_string(), + api_kind: "legacy".to_string(), ..GameCreatorLlmConfig::default() }, editor_api: GameCreatorEditorApiConfig::default(), }); assert!(result - .expect_err("invalid protocol") - .contains("LLM protocol 无效")); + .expect_err("invalid api_kind") + .contains("LLM api_kind 无效")); assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists()); fs::remove_dir_all(root).expect("cleanup runtime config dir"); } @@ -7120,7 +7122,7 @@ mod tests { "apiKey": "test-key", "baseUrl": {base_url:?}, "model": "mock-game-model", - "protocol": "responses" + "apiKind": "openai_responses" }} }}"# )); @@ -7161,12 +7163,33 @@ mod tests { 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_parses_canonical_names() { + assert_eq!( + parse_game_creator_llm_api_kind("anthropic"), + Ok(LlmApiKind::Anthropic) + ); + assert_eq!( + parse_game_creator_llm_api_kind("openai_chat"), + Ok(LlmApiKind::OpenAiChat) + ); + assert_eq!( + parse_game_creator_llm_api_kind("openai_responses"), + Ok(LlmApiKind::OpenAiResponses) + ); + assert_eq!( + parse_game_creator_llm_api_kind(""), + Ok(LlmApiKind::OpenAiResponses) + ); + assert!(parse_game_creator_llm_api_kind("legacy").is_err()); + } + #[tokio::test] async fn agent_loop_writes_spec_findings_and_retries_generator() { let root = unique_project_path(); @@ -7640,7 +7663,7 @@ mod tests { "apiKey": "test-key", "baseUrl": {base_url:?}, "model": "mock-game-model", - "protocol": "responses" + "apiKind": "openai_responses" }} }}"# )); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 729cb2bf8..a9c2b086a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -60,18 +60,18 @@ interface GameCreatorLlmConfigStatus { apiKeyPresent: boolean; baseUrl: string | null; model: string | null; - protocol: string; + apiKind: string; error: string | null; } -type GameCreatorLlmProtocol = 'responses' | 'chat_completions'; +type GameCreatorLlmApiKind = 'openai_responses' | 'openai_chat' | 'anthropic'; interface GameCreatorAppConfig { llm: { apiKey: string; baseUrl: string; model: string; - protocol: GameCreatorLlmProtocol; + apiKind: GameCreatorLlmApiKind; stream: boolean; requestTimeoutMs: number; maxRetries: number; @@ -326,7 +326,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { apiKey: '', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1', - protocol: 'responses', + apiKind: 'openai_responses', stream: false, requestTimeoutMs: 180000, maxRetries: 0, @@ -1739,7 +1739,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 ? '已读取' : '未读取' }。`, @@ -3753,19 +3753,20 @@ export function App() { />