修复编辑器 LLM mock 用例无法构造 Tiantoken client 的问题

- AppConfig 新增 tiantoken_base_url / tiantoken_api_key 字段,from_env 只读 TIANTOKEN_* 环境变量,不回退 VectorEngine 凭据
- AppState 改用配置字段构造编辑器 LLM 与 Tiantoken 图片 client,删除现读环境变量的 config::tiantoken_* 访问器
- editor_background_music_prompt_assist / editor_sound_effect_prompt_assist / sound_effect_translation 三处 mock LLM 夹具改用 tiantoken_* 字段,恢复 28 条失败用例
- config 用例改为断言 from_env 的 Tiantoken 凭据来源,state 用例改名为从 Tiantoken 设置构造编辑器 LLM client
- 同步更新后端架构文档与 decision-log 记录
This commit is contained in:
2026-09-19 15:33:14 +08:00
parent 0459109897
commit 1c1deff732
7 changed files with 65 additions and 34 deletions
@@ -8857,3 +8857,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- **启动**api-server 启动时同时构造 VectorEngine 与 Tiantoken 两个 required client;各自只读取自己的环境变量,任一配置缺失即启动失败,不延迟到首次请求。
- **重试**:只在同一个 concrete model 内 retry,禁止跨 model、跨 provider fallback。
- **关联文档**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。
## 2026-09-19 Tiantoken 凭据落到 AppConfig 字段并修复编辑器 LLM 测试夹具
- **决策**Tiantoken 凭据仍然只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,不回退 VectorEngine;读取点从 `AppState::new` 前移到 `AppConfig::from_env()`,落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key` 两个字段,与其它 provider 的配置形态一致,`AppState` 构造与测试构造复用同一条路径;删除 `config::tiantoken_base_url()` / `config::tiantoken_api_key()` 两个现读环境变量的访问器。
- **原因**2026-09-18 的「双 provider 启动边界」改成现读环境变量后,`editor_background_music_prompt_assist``editor_sound_effect_prompt_assist``vector_engine_audio_generation/sound_effect_translation` 三处 mock LLM 夹具仍用 `AppConfig.vector_engine_*` 构造状态,28 条用例拿到 503 `editor_llm_unavailable`。改用进程环境变量做夹具会让并行用例互相踩 `TIANTOKEN_*`,因此把凭据落到 config 字段而不是在测试里 set_var。
- **边界**ADR 的「不得回退 VectorEngine 或其 API key」仍然成立,`from_env` 只读 `TIANTOKEN_*`,并有用例固定该行为。
- **验证**`cargo test -p api-server` 1060 passed / 0 failed / 6 ignored`cargo fmt --all --check``npm run check:encoding``git diff --check` 通过。
- **关联文档**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。
File diff suppressed because one or more lines are too long
+44 -21
View File
@@ -204,6 +204,12 @@ pub struct AppConfig {
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
pub vector_engine_audio_request_timeout_ms: u64,
/// Tiantoken 是 GPT Image 2.5 图片链路和创意 Agent 文本链路的 provider。
///
/// 只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,刻意不回退 VectorEngine 凭据;
/// 启动时落到 `AppConfig` 字段,使 `AppState` 构造与测试构造复用同一条路径。
pub tiantoken_base_url: String,
pub tiantoken_api_key: Option<String>,
pub elevenlabs_base_url: String,
pub elevenlabs_api_key: Option<String>,
pub elevenlabs_request_timeout_ms: u64,
@@ -510,6 +516,8 @@ impl Default for AppConfig {
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
vector_engine_audio_request_timeout_ms: 180_000,
tiantoken_base_url: String::new(),
tiantoken_api_key: None,
elevenlabs_base_url: String::new(),
elevenlabs_api_key: None,
elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
@@ -1277,6 +1285,12 @@ impl AppConfig {
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
// Tiantoken 只读取自己的环境变量,不从 VectorEngine 配置回退;启动时冻结进 AppConfig,
// 供 AppState 构造编辑器 LLM / 图片 client 时复用同一条配置路径。
config.tiantoken_base_url =
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default();
config.tiantoken_api_key = read_first_non_empty_env(&["TIANTOKEN_API_KEY"]);
if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[
"TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS",
"VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS",
@@ -1428,17 +1442,6 @@ impl AppConfig {
}
}
/// Tiantoken 是 GPT Image 2.5 图片任务的独立 provider;凭证不得回退到 VectorEngine。
pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String {
let _ = config;
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default()
}
pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> {
let _ = config;
read_first_non_empty_env(&["TIANTOKEN_API_KEY"])
}
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
env::var(key).ok().and_then(|value| {
@@ -1744,7 +1747,6 @@ mod tests {
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
tiantoken_api_key, tiantoken_base_url,
};
use std::{
fs,
@@ -1838,31 +1840,52 @@ mod tests {
}
#[test]
fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() {
fn tiantoken_credentials_never_fall_back_to_vector_engine_env() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
let mut config = AppConfig::default();
config.vector_engine_base_url = "https://vector.example.invalid".to_string();
config.vector_engine_api_key = Some("legacy-vector-key".to_string());
unsafe {
std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ ");
std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key ");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.example.invalid");
std::env::set_var("VECTOR_ENGINE_API_KEY", "legacy-vector-key");
}
let config = AppConfig::from_env();
assert_eq!(config.tiantoken_base_url, "https://api.tiantoken.example/");
assert_eq!(config.tiantoken_api_key.as_deref(), Some("tiantoken-key"));
assert_eq!(
tiantoken_base_url(&config),
"https://api.tiantoken.example/"
config.vector_engine_base_url,
"https://vector.example.invalid"
);
assert_eq!(
config.vector_engine_api_key.as_deref(),
Some("legacy-vector-key")
);
assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key"));
unsafe {
std::env::remove_var("TIANTOKEN_BASE_URL");
std::env::remove_var("TIANTOKEN_API_KEY");
}
assert_eq!(tiantoken_base_url(&config), "");
assert_eq!(tiantoken_api_key(&config), None);
let config = AppConfig::from_env();
assert_eq!(config.tiantoken_base_url, "");
assert_eq!(config.tiantoken_api_key, None);
assert_eq!(
config.vector_engine_base_url,
"https://vector.example.invalid"
);
assert_eq!(
config.vector_engine_api_key.as_deref(),
Some("legacy-vector-key")
);
unsafe {
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_API_KEY");
}
}
#[test]
@@ -1480,8 +1480,8 @@ mod tests {
fn editor_llm_test_config(base_url: String) -> AppConfig {
AppConfig {
vector_engine_base_url: base_url,
vector_engine_api_key: Some("test-vector-engine-key".to_string()),
tiantoken_base_url: base_url,
tiantoken_api_key: Some("test-tiantoken-key".to_string()),
llm_max_retries: 0,
..AppConfig::default()
}
@@ -574,8 +574,8 @@ mod tests {
fn editor_llm_test_config(base_url: String, max_retries: u32) -> AppConfig {
AppConfig {
vector_engine_base_url: base_url,
vector_engine_api_key: Some("test-vector-engine-key".to_string()),
tiantoken_base_url: base_url,
tiantoken_api_key: Some("test-tiantoken-key".to_string()),
llm_max_retries: max_retries,
..AppConfig::default()
}
+6 -6
View File
@@ -615,8 +615,8 @@ impl AppState {
config.editor_generation_pricing_override_path.clone(),
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let tiantoken_base_url = crate::config::tiantoken_base_url(&config);
let tiantoken_api_key = crate::config::tiantoken_api_key(&config);
let tiantoken_base_url = config.tiantoken_base_url.clone();
let tiantoken_api_key = config.tiantoken_api_key.clone();
let vector_engine_image_client = build_required_image_provider_client(
ImageProvider::VectorEngine,
config.vector_engine_base_url.clone(),
@@ -3176,13 +3176,13 @@ mod tests {
}
#[test]
fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() {
fn app_state_builds_editor_agent_llm_client_from_tiantoken_settings() {
let mut config = AppConfig::default();
config.llm_api_key = None;
config.llm_max_retries = 2;
config.llm_retry_backoff_ms = 120_000;
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = Some("ve-key".to_string());
config.tiantoken_base_url = "https://api.tiantoken.test".to_string();
config.tiantoken_api_key = Some("tiantoken-key".to_string());
let state = AppState::new(config).expect("state should build");
let client = state
@@ -3195,7 +3195,7 @@ mod tests {
);
assert_eq!(
client.config().chat_completions_url(),
"https://api.vectorengine.test/v1/chat/completions"
"https://api.tiantoken.test/v1/chat/completions"
);
assert!(!client.config().official_fallback());
assert_eq!(
@@ -578,8 +578,8 @@ mod tests {
fn editor_llm_test_state(base_url: String, max_retries: u32) -> AppState {
AppState::new(AppConfig {
vector_engine_base_url: base_url,
vector_engine_api_key: Some("test-vector-engine-key".to_string()),
tiantoken_base_url: base_url,
tiantoken_api_key: Some("test-tiantoken-key".to_string()),
llm_max_retries: max_retries,
..AppConfig::default()
})