use std::{env, fmt, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_RETRY_BACKOFF_MS, LlmProvider, }; use platform_speech::{ DEFAULT_ASR_RESOURCE_ID, DEFAULT_ASR_WS_URL, DEFAULT_REQUEST_TIMEOUT_MS as DEFAULT_SPEECH_REQUEST_TIMEOUT_MS, DEFAULT_TTS_BIDIRECTION_WS_URL, DEFAULT_TTS_RESOURCE_ID, DEFAULT_TTS_SSE_URL, }; use sha2::{Digest, Sha256}; const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; const DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS: u64 = 2; const DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS: u64 = 600; const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900; const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800; pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; pub(crate) const DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS: u64 = 180_000; const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter"; const DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: u64 = 5_000; const BGFILTER_ATTEMPT_SAFETY_FACTOR: u64 = 2; const BGFILTER_WORKER_RESPONSE_WINDOW_MS: u64 = 1_000; const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3; const DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS: u64 = 120; const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com"; const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; /// AGC 项目快照默认落到 AGC 发行 bucket;私有前缀由 platform-oss 单独限制。 const DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET: &str = "agc-dev"; const DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com"; pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra"; const LLM_ROUTER_KEY_ENCRYPTION_DOMAIN: &[u8] = b"genarrative:llm-router-api-key-encryption:v1\0"; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone)] pub struct AppConfig { /// 运行环境标签。生产环境允许连接正式 Router 控制面;其它环境默认只允许 /// loopback 测试 Router,避免本地测试误创建线上用户。 pub environment: String, pub bind_host: String, pub bind_port: u16, pub listen_backlog: i32, pub worker_threads: Option, pub process_role: ProcessRole, pub bgfilter_worker_host: String, pub bgfilter_worker_port: u16, pub bgfilter_worker_base_url: String, pub bgfilter_internal_token: Option, pub bgfilter_worker_concurrency: usize, pub bgfilter_worker_max_requests: usize, pub bgfilter_worker_connect_timeout_ms: u64, pub external_generation_mode: ExternalGenerationMode, pub external_generation_worker_id: String, pub external_generation_worker_concurrency: usize, pub external_generation_worker_poll_interval: Duration, pub external_generation_worker_lease: Duration, pub external_generation_worker_job_timeout: Duration, pub external_generation_worker_long_job_timeout: Duration, pub external_generation_controller_min_workers: usize, pub external_generation_controller_max_workers: usize, pub external_generation_controller_target_jobs_per_worker: usize, pub external_generation_controller_poll_interval: Duration, pub external_generation_controller_scale_down_idle_rounds: u32, pub external_generation_controller_service_template: String, pub external_generation_controller_dry_run: bool, pub max_concurrent_requests: Option, pub admin_max_concurrent_requests: Option, pub shutdown_outbox_flush_timeout: Duration, pub tracking_outbox_enabled: bool, pub tracking_outbox_dir: PathBuf, pub tracking_outbox_batch_size: usize, pub tracking_outbox_flush_interval: Duration, pub tracking_outbox_max_bytes: u64, pub wallet_refund_outbox_enabled: bool, pub wallet_refund_outbox_dir: PathBuf, pub wallet_refund_outbox_batch_size: usize, pub wallet_refund_outbox_flush_interval: Duration, pub wallet_refund_outbox_max_bytes: u64, pub editor_generation_pricing_override_path: PathBuf, pub editor_bgfilter_base_url: String, pub editor_bgfilter_token: Option, pub editor_bgfilter_single_image_estimate_ms: u64, pub aliyun_matting_enabled: bool, pub aliyun_matting_endpoint: String, pub aliyun_matting_access_key_id: Option, pub aliyun_matting_access_key_secret: Option, pub aliyun_matting_request_timeout_ms: u64, pub editor_bgfilter_circuit_failure_threshold: u32, pub editor_bgfilter_circuit_cooldown: Duration, pub image_editor_agent_sidebar_enabled: bool, pub client_download_channel: String, /// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。 pub project_snapshot_channel: String, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, pub admin_password: Option, pub admin_token_ttl_seconds: u64, pub internal_api_secret: Option, pub jwt_issuer: String, pub jwt_secret: String, pub jwt_access_token_ttl_seconds: u64, pub refresh_cookie_name: String, pub refresh_cookie_path: String, pub refresh_cookie_secure: bool, pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, pub dev_password_entry_auto_register_enabled: bool, pub sms_auth_enabled: bool, pub sms_auth_provider: String, pub sms_endpoint: String, pub sms_access_key_id: Option, pub sms_access_key_secret: Option, pub sms_sign_name: String, pub sms_template_code: String, pub sms_template_param_key: String, pub sms_country_code: String, pub sms_scheme_name: Option, pub sms_code_length: u8, pub sms_code_type: u8, pub sms_valid_time_seconds: u64, pub sms_interval_seconds: u64, pub sms_duplicate_policy: u8, pub sms_case_auth_policy: u8, pub sms_return_verify_code: bool, pub sms_mock_verify_code: String, pub wechat_auth_enabled: bool, pub wechat_auth_provider: String, pub wechat_app_id: Option, pub wechat_app_secret: Option, pub wechat_mini_program_app_id: Option, pub wechat_mini_program_app_secret: Option, pub wechat_callback_path: String, pub wechat_redirect_path: String, pub wechat_authorize_endpoint: String, pub wechat_access_token_endpoint: String, pub wechat_user_info_endpoint: String, pub wechat_js_code_session_endpoint: String, pub wechat_stable_access_token_endpoint: String, pub wechat_phone_number_endpoint: String, pub wechat_state_ttl_minutes: u32, pub wechat_mock_user_id: String, pub wechat_mock_union_id: Option, pub wechat_mock_display_name: String, pub wechat_mock_avatar_url: Option, pub wechat_pay_enabled: bool, pub wechat_pay_provider: String, pub wechat_pay_mch_id: Option, pub wechat_pay_merchant_serial_no: Option, pub wechat_pay_private_key_pem: Option, pub wechat_pay_private_key_path: Option, pub wechat_pay_platform_public_key_pem: Option, pub wechat_pay_platform_public_key_path: Option, pub wechat_pay_platform_serial_no: Option, pub wechat_pay_api_v3_key: Option, pub wechat_pay_notify_url: Option, pub wechat_pay_refund_reconciliation_enabled: bool, pub wechat_pay_jsapi_endpoint: String, pub wechat_mini_program_virtual_payment_offer_id: Option, pub wechat_mini_program_virtual_payment_app_key: Option, pub wechat_mini_program_virtual_payment_sandbox_app_key: Option, pub wechat_mini_program_virtual_payment_query_order_endpoint: String, pub wechat_mini_program_virtual_payment_notify_provide_goods_endpoint: String, pub wechat_mini_program_message_token: Option, pub wechat_mini_program_message_encoding_aes_key: Option, pub wechat_mini_program_virtual_payment_env: u8, pub oss_bucket: Option, pub oss_endpoint: Option, pub oss_access_key_id: Option, pub oss_access_key_secret: Option, pub oss_read_expire_seconds: u64, pub oss_post_expire_seconds: u64, pub oss_post_max_size_bytes: u64, pub oss_success_action_status: u16, /// AGC 项目快照的 bucket 与 endpoint 独立配置,默认使用 AGC 目标; /// 仅凭据回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`。 pub project_snapshot_oss_bucket: String, pub project_snapshot_oss_endpoint: String, pub project_snapshot_oss_access_key_id: Option, pub project_snapshot_oss_access_key_secret: Option, /// AGC 模板库独立凭据;只在两项均缺省时成套复用通用 OSS 凭据。 pub template_library_oss_access_key_id: Option, pub template_library_oss_access_key_secret: Option, pub spacetime_server_url: String, pub spacetime_database: String, pub spacetime_token: Option, pub spacetime_runtime_service_bootstrap_secret: Option, pub spacetime_pool_size: u32, pub spacetime_procedure_timeout: Duration, pub spacetime_health_check_timeout: Duration, pub llm_provider: LlmProvider, pub llm_base_url: String, pub llm_api_key: Option, pub llm_model: String, pub llm_request_timeout_ms: u64, pub llm_max_retries: u32, pub llm_retry_backoff_ms: u64, /// LLM Router 账号级代理固定配置;普通客户端不能覆盖这些值。 pub llm_router_base_url: String, pub llm_router_model: String, /// Router 用户密码派生根,仅从部署侧 secret/env/file 读取。 pub llm_router_provisioning_secret: Option, pub llm_router_api_key_encryption_secret: Option, pub llm_router_admin_token: Option, pub dashscope_base_url: String, pub dashscope_api_key: Option, pub dashscope_scene_image_model: String, pub dashscope_reference_image_model: String, pub dashscope_cover_image_model: String, pub dashscope_image_request_timeout_ms: u64, // 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine(同时支持 Chat Completions / Responses 协议)。 // pub apimart_base_url: String, // pub apimart_api_key: Option, // pub apimart_image_request_timeout_ms: u64, pub vector_engine_base_url: String, pub vector_engine_api_key: Option, pub vector_engine_image_request_timeout_ms: u64, pub vector_engine_audio_request_timeout_ms: u64, pub elevenlabs_base_url: String, pub elevenlabs_api_key: Option, pub elevenlabs_request_timeout_ms: u64, pub hyper3d_base_url: String, pub hyper3d_api_key: Option, pub hyper3d_model_request_timeout_ms: u64, pub volcengine_speech_api_key: Option, pub volcengine_speech_app_id: Option, pub volcengine_speech_access_key: Option, pub volcengine_speech_asr_resource_id: String, pub volcengine_speech_tts_resource_id: String, pub volcengine_speech_asr_ws_url: String, pub volcengine_speech_tts_bidirection_ws_url: String, pub volcengine_speech_tts_sse_url: String, pub volcengine_speech_request_timeout_ms: u64, pub draft_asset_generation_max_concurrent_requests: usize, pub ark_character_video_base_url: String, pub ark_character_video_api_key: Option, pub ark_character_video_request_timeout_ms: u64, pub ark_character_video_model: String, pub character_animation_ffmpeg_path: String, pub character_animation_ffprobe_path: String, pub character_animation_frame_extract_timeout_ms: u64, pub slow_request_threshold_ms: u64, } impl fmt::Debug for AppConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // 配置由环境变量和私密文件聚合而来。这里只输出排障所需的封闭运行摘要, // 不递归格式化任一字符串配置,避免 URL 凭据、Token、私钥或未来新增字段进入日志。 f.debug_struct("AppConfig") .field("bind_port", &self.bind_port) .field("listen_backlog", &self.listen_backlog) .field("worker_threads", &self.worker_threads) .field("process_role", &self.process_role) .field("external_generation_mode", &self.external_generation_mode) .field( "external_generation_worker_concurrency", &self.external_generation_worker_concurrency, ) .field("max_concurrent_requests", &self.max_concurrent_requests) .field( "admin_max_concurrent_requests", &self.admin_max_concurrent_requests, ) .field("spacetime_pool_size", &self.spacetime_pool_size) .field("sms_auth_enabled", &self.sms_auth_enabled) .field("wechat_auth_enabled", &self.wechat_auth_enabled) .field("wechat_pay_enabled", &self.wechat_pay_enabled) .field("aliyun_matting_enabled", &self.aliyun_matting_enabled) .field("tracking_outbox_enabled", &self.tracking_outbox_enabled) .field( "wallet_refund_outbox_enabled", &self.wallet_refund_outbox_enabled, ) .field("otel_enabled", &self.otel_enabled) .field("credentials", &"") .finish_non_exhaustive() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ProcessRole { Api, BgfilterWorker, ExternalGenerationWorker, ExternalGenerationController, All, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExternalGenerationMode { Inline, Queue, } impl ExternalGenerationMode { pub fn is_inline(self) -> bool { matches!(self, Self::Inline) } } impl ProcessRole { pub fn as_str(self) -> &'static str { match self { Self::Api => "api", Self::BgfilterWorker => "bgfilter-worker", Self::ExternalGenerationWorker => "external-generation-worker", Self::ExternalGenerationController => "external-generation-controller", Self::All => "all", } } pub fn runs_http(self) -> bool { matches!(self, Self::Api | Self::All) } pub fn runs_bgfilter_worker(self) -> bool { matches!(self, Self::BgfilterWorker) } pub fn runs_external_generation_worker(self) -> bool { matches!(self, Self::ExternalGenerationWorker | Self::All) } pub fn runs_external_generation_controller(self) -> bool { matches!(self, Self::ExternalGenerationController) } } impl Default for AppConfig { fn default() -> Self { Self { environment: "development".to_string(), bind_host: "127.0.0.1".to_string(), bind_port: 3000, listen_backlog: 1024, worker_threads: None, process_role: ProcessRole::Api, bgfilter_worker_host: "127.0.0.1".to_string(), bgfilter_worker_port: 8083, bgfilter_worker_base_url: "http://127.0.0.1:8083".to_string(), bgfilter_internal_token: None, bgfilter_worker_concurrency: 16, bgfilter_worker_max_requests: 2048, bgfilter_worker_connect_timeout_ms: 2_000, external_generation_mode: ExternalGenerationMode::Queue, external_generation_worker_id: default_external_generation_worker_id(), external_generation_worker_concurrency: 2, external_generation_worker_poll_interval: Duration::from_millis(2_000), external_generation_worker_lease: Duration::from_secs( DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS, ), external_generation_worker_job_timeout: Duration::from_secs( DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS, ), external_generation_worker_long_job_timeout: Duration::from_secs( DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ), external_generation_controller_min_workers: 1, external_generation_controller_max_workers: 8, external_generation_controller_target_jobs_per_worker: 2, external_generation_controller_poll_interval: Duration::from_millis(10_000), external_generation_controller_scale_down_idle_rounds: 6, external_generation_controller_service_template: "genarrative-external-generation-worker@{}.service".to_string(), external_generation_controller_dry_run: false, max_concurrent_requests: None, admin_max_concurrent_requests: None, shutdown_outbox_flush_timeout: Duration::from_millis(5_000), tracking_outbox_enabled: true, tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"), tracking_outbox_batch_size: 500, tracking_outbox_flush_interval: Duration::from_millis(1_000), tracking_outbox_max_bytes: 256 * 1024 * 1024, wallet_refund_outbox_enabled: true, wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"), wallet_refund_outbox_batch_size: 100, wallet_refund_outbox_flush_interval: Duration::from_millis(1_000), wallet_refund_outbox_max_bytes: 64 * 1024 * 1024, editor_generation_pricing_override_path: crate::editor_generation_config::default_editor_generation_pricing_override_path(), editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(), editor_bgfilter_token: None, editor_bgfilter_single_image_estimate_ms: DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS, aliyun_matting_enabled: true, aliyun_matting_endpoint: DEFAULT_ALIYUN_MATTING_ENDPOINT.to_string(), aliyun_matting_access_key_id: None, aliyun_matting_access_key_secret: None, aliyun_matting_request_timeout_ms: DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS, editor_bgfilter_circuit_failure_threshold: DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD, editor_bgfilter_circuit_cooldown: Duration::from_secs( DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS, ), image_editor_agent_sidebar_enabled: false, client_download_channel: "dev".to_string(), project_snapshot_channel: "dev".to_string(), log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, admin_password: None, admin_token_ttl_seconds: 4 * 60 * 60, internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()), jwt_issuer: "https://auth.genarrative.local".to_string(), jwt_secret: "genarrative-dev-secret".to_string(), jwt_access_token_ttl_seconds: 2 * 60 * 60, refresh_cookie_name: "genarrative_refresh_session".to_string(), refresh_cookie_path: "/api/auth".to_string(), refresh_cookie_secure: false, refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), sms_endpoint: "dysmsapi.aliyuncs.com".to_string(), sms_access_key_id: None, sms_access_key_secret: None, sms_sign_name: "北京亓盒网络科技".to_string(), sms_template_code: "SMS_506245486".to_string(), sms_template_param_key: "code".to_string(), sms_country_code: "86".to_string(), sms_scheme_name: None, sms_code_length: 6, sms_code_type: 1, sms_valid_time_seconds: 300, sms_interval_seconds: 60, sms_duplicate_policy: 1, sms_case_auth_policy: 1, sms_return_verify_code: false, sms_mock_verify_code: "123456".to_string(), wechat_auth_enabled: false, wechat_auth_provider: "mock".to_string(), wechat_app_id: None, wechat_app_secret: None, wechat_mini_program_app_id: None, wechat_mini_program_app_secret: None, wechat_callback_path: "/api/auth/wechat/callback".to_string(), wechat_redirect_path: "/".to_string(), wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(), wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token" .to_string(), wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(), wechat_js_code_session_endpoint: "https://api.weixin.qq.com/sns/jscode2session" .to_string(), wechat_stable_access_token_endpoint: "https://api.weixin.qq.com/cgi-bin/stable_token" .to_string(), wechat_phone_number_endpoint: "https://api.weixin.qq.com/wxa/business/getuserphonenumber".to_string(), wechat_state_ttl_minutes: 15, wechat_mock_user_id: "wx-mock-user".to_string(), wechat_mock_union_id: Some("wx-mock-union".to_string()), wechat_mock_display_name: "微信旅人".to_string(), wechat_mock_avatar_url: None, wechat_pay_enabled: false, wechat_pay_provider: "mock".to_string(), wechat_pay_mch_id: None, wechat_pay_merchant_serial_no: None, wechat_pay_private_key_pem: None, wechat_pay_private_key_path: None, wechat_pay_platform_public_key_pem: None, wechat_pay_platform_public_key_path: None, wechat_pay_platform_serial_no: None, wechat_pay_api_v3_key: None, wechat_pay_notify_url: None, wechat_pay_refund_reconciliation_enabled: false, wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi" .to_string(), wechat_mini_program_virtual_payment_offer_id: None, wechat_mini_program_virtual_payment_app_key: None, wechat_mini_program_virtual_payment_sandbox_app_key: None, wechat_mini_program_virtual_payment_query_order_endpoint: "https://api.weixin.qq.com/xpay/query_order".to_string(), wechat_mini_program_virtual_payment_notify_provide_goods_endpoint: "https://api.weixin.qq.com/xpay/notify_provide_goods".to_string(), wechat_mini_program_message_token: None, wechat_mini_program_message_encoding_aes_key: None, wechat_mini_program_virtual_payment_env: 0, oss_bucket: None, oss_endpoint: None, oss_access_key_id: None, oss_access_key_secret: None, oss_read_expire_seconds: 10 * 60, oss_post_expire_seconds: 10 * 60, oss_post_max_size_bytes: 20 * 1024 * 1024, oss_success_action_status: 200, project_snapshot_oss_bucket: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string(), project_snapshot_oss_endpoint: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string(), project_snapshot_oss_access_key_id: None, project_snapshot_oss_access_key_secret: None, template_library_oss_access_key_id: None, template_library_oss_access_key_secret: None, spacetime_server_url: "http://127.0.0.1:3000".to_string(), spacetime_database: "genarrative-dev".to_string(), spacetime_token: None, spacetime_runtime_service_bootstrap_secret: None, spacetime_pool_size: 4, spacetime_procedure_timeout: Duration::from_secs(30), spacetime_health_check_timeout: Duration::from_secs( DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS, ), llm_provider: LlmProvider::Ark, llm_base_url: String::new(), llm_api_key: None, llm_model: String::new(), llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, llm_router_base_url: OFFICIAL_LLM_ROUTER_BASE_URL.to_string(), llm_router_model: OFFICIAL_LLM_ROUTER_MODEL.to_string(), llm_router_provisioning_secret: None, llm_router_api_key_encryption_secret: None, llm_router_admin_token: None, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), dashscope_api_key: None, dashscope_scene_image_model: String::new(), dashscope_reference_image_model: String::new(), dashscope_cover_image_model: String::new(), dashscope_image_request_timeout_ms: 150_000, // apimart_base_url: String::new(), // apimart_api_key: None, // apimart_image_request_timeout_ms: 180_000, vector_engine_base_url: String::new(), 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, elevenlabs_base_url: String::new(), elevenlabs_api_key: None, elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS, hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(), hyper3d_api_key: None, hyper3d_model_request_timeout_ms: 180_000, volcengine_speech_api_key: None, volcengine_speech_app_id: None, volcengine_speech_access_key: None, volcengine_speech_asr_resource_id: DEFAULT_ASR_RESOURCE_ID.to_string(), volcengine_speech_tts_resource_id: DEFAULT_TTS_RESOURCE_ID.to_string(), volcengine_speech_asr_ws_url: DEFAULT_ASR_WS_URL.to_string(), volcengine_speech_tts_bidirection_ws_url: DEFAULT_TTS_BIDIRECTION_WS_URL.to_string(), volcengine_speech_tts_sse_url: DEFAULT_TTS_SSE_URL.to_string(), volcengine_speech_request_timeout_ms: DEFAULT_SPEECH_REQUEST_TIMEOUT_MS, draft_asset_generation_max_concurrent_requests: 4, ark_character_video_base_url: String::new(), ark_character_video_api_key: None, ark_character_video_request_timeout_ms: 420_000, ark_character_video_model: String::new(), character_animation_ffmpeg_path: "ffmpeg".to_string(), character_animation_ffprobe_path: "ffprobe".to_string(), character_animation_frame_extract_timeout_ms: 120_000, slow_request_threshold_ms: 1_000, } } } impl AppConfig { pub(crate) fn is_production(&self) -> bool { self.environment.trim().eq_ignore_ascii_case("production") } pub(crate) fn is_test_environment(&self) -> bool { self.environment.trim().eq_ignore_ascii_case("test") } /// Returns the server-side key used to encrypt LLM Router credentials. /// /// Deployments may provide a dedicated secret. When omitted, derive a /// domain-separated encryption key from the JWT signing secret; this is /// storage-key derivation only and is never a Router/API credential. pub(crate) fn effective_llm_router_api_key_encryption_secret(&self) -> Option { if let Some(secret) = self .llm_router_api_key_encryption_secret .as_deref() .map(str::trim) .filter(|secret| !secret.is_empty()) { return Some(secret.to_string()); } let jwt_secret = self.jwt_secret.trim(); if jwt_secret.is_empty() { return None; } let mut digest = Sha256::new(); digest.update(LLM_ROUTER_KEY_ENCRYPTION_DOMAIN); digest.update(jwt_secret.as_bytes()); Some(format!("{:x}", digest.finalize())) } /// 单次 provider attempt 上限:`N × est × 2`。运行时派生,禁止硬编码计算结果; /// worker 角色在 main 中覆盖 `bgfilter_worker_concurrency` 后自动生效。 pub fn bgfilter_provider_attempt_timeout_ms(&self) -> u64 { (self.bgfilter_worker_concurrency.max(1) as u64) .saturating_mul(self.editor_bgfilter_single_image_estimate_ms.max(1)) .saturating_mul(BGFILTER_ATTEMPT_SAFETY_FACTOR) } /// 调用预算:自取得 provider permit 起算,覆盖签名、两次顺序 attempt、 /// 结果校验与响应构造;排队时长不消耗它。 pub fn bgfilter_call_budget_ms(&self) -> u64 { self.bgfilter_provider_attempt_timeout_ms() .saturating_mul(2) .saturating_add(BGFILTER_WORKER_RESPONSE_WINDOW_MS) } pub fn from_env() -> Self { let mut config = Self::default(); if let Some(environment) = read_first_non_empty_env(&["GENARRATIVE_ENV"]) { config.environment = environment.trim().to_ascii_lowercase(); } // 仅为 test/development 的 loopback Router fixture 保留可配置入口; // production 仍会在 provisioning 前强制校验正式官方地址。 if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_LLM_ROUTER_BASE_URL"]) { config.llm_router_base_url = base_url; } if let Some(local_spacetime_database) = read_local_spacetime_database() { config.spacetime_database = local_spacetime_database; } if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") && !bind_host.trim().is_empty() { config.bind_host = bind_host; } if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") && let Ok(parsed_port) = bind_port.parse::() { config.bind_port = parsed_port; } if let Some(host) = read_first_non_empty_env(&["GENARRATIVE_BGFILTER_WORKER_HOST"]) { config.bgfilter_worker_host = host; } if let Some(port) = read_first_positive_u16_env(&["GENARRATIVE_BGFILTER_WORKER_PORT"]) { config.bgfilter_worker_port = port; } if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_BGFILTER_WORKER_BASE_URL"]) { config.bgfilter_worker_base_url = base_url; } config.bgfilter_internal_token = read_secret_env_or_file( &["GENARRATIVE_BGFILTER_INTERNAL_TOKEN"], &["GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE"], ); if let Some(concurrency) = read_first_usize_env(&["GENARRATIVE_BGFILTER_WORKER_CONCURRENCY"]) { config.bgfilter_worker_concurrency = concurrency; } if let Some(max_requests) = read_first_usize_env(&["GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS"]) { config.bgfilter_worker_max_requests = max_requests; } if let Some(connect_timeout_ms) = read_first_positive_u64_env(&["GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS"]) { config.bgfilter_worker_connect_timeout_ms = connect_timeout_ms; } if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") && !log_filter.trim().is_empty() { config.log_filter = log_filter; } if let Some(pricing_override_path) = read_first_non_empty_env(&["GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"]) { config.editor_generation_pricing_override_path = PathBuf::from(pricing_override_path); } if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_EDITOR_BGFILTER_BASE_URL"]) { config.editor_bgfilter_base_url = base_url; } config.editor_bgfilter_token = read_first_non_empty_env(&[ "GENARRATIVE_EDITOR_BGFILTER_TOKEN", "GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN", ]); if let Some(estimate_ms) = read_first_positive_u64_env(&["GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS"]) { config.editor_bgfilter_single_image_estimate_ms = estimate_ms; } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ALIYUN_MATTING_ENABLED"]) { config.aliyun_matting_enabled = enabled; } if let Some(endpoint) = read_first_non_empty_env(&["GENARRATIVE_ALIYUN_MATTING_ENDPOINT"]) { config.aliyun_matting_endpoint = endpoint; } config.aliyun_matting_access_key_id = read_first_non_empty_env(&[ "GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_ID", ]); config.aliyun_matting_access_key_secret = read_first_non_empty_env(&[ "GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET", "ALIBABA_CLOUD_ACCESS_KEY_SECRET", ]); if let Some(timeout_ms) = read_first_positive_u64_env(&["GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"]) { config.aliyun_matting_request_timeout_ms = timeout_ms; } if let Some(threshold) = read_first_u32_env(&["GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"]) { config.editor_bgfilter_circuit_failure_threshold = threshold; } if let Some(cooldown_seconds) = read_first_duration_seconds_env(&[ "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS", ]) { config.editor_bgfilter_circuit_cooldown = Duration::from_secs(cooldown_seconds.max(1)); } if let Ok(channel) = std::env::var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL") { // 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。 config.client_download_channel = channel.trim().to_string(); } // 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先; // 显式空值或非法值同样保留,由快照入口失败关闭。 config.project_snapshot_channel = config.client_download_channel.clone(); if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") { config.project_snapshot_channel = channel.trim().to_string(); } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { config.image_editor_agent_sidebar_enabled = enabled; } if let Some(listen_backlog) = read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"]) { config.listen_backlog = listen_backlog; } if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) { config.worker_threads = Some(worker_threads); } if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) { config.process_role = process_role; } if let Some(external_generation_mode) = read_first_external_generation_mode_env(&["GENARRATIVE_EXTERNAL_GENERATION_MODE"]) { config.external_generation_mode = external_generation_mode; } if let Some(worker_id) = read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"]) { config.external_generation_worker_id = worker_id; } if let Some(concurrency) = read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY"]) { config.external_generation_worker_concurrency = concurrency.max(1); } if let Some(poll_interval_ms) = read_first_positive_u64_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS", ]) { config.external_generation_worker_poll_interval = Duration::from_millis(poll_interval_ms); } if let Some(lease_seconds) = read_first_duration_seconds_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS", ]) { config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1)); } if let Some(timeout_seconds) = read_first_duration_seconds_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS", ]) { config.external_generation_worker_job_timeout = Duration::from_secs(timeout_seconds.max(1)); } if let Some(timeout_seconds) = read_first_duration_seconds_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS", ]) { config.external_generation_worker_long_job_timeout = Duration::from_secs(timeout_seconds.max(1)); } if let Some(min_workers) = read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS"]) { config.external_generation_controller_min_workers = min_workers; } if let Some(max_workers) = read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS"]) { config.external_generation_controller_max_workers = max_workers; } if config.external_generation_controller_max_workers < config.external_generation_controller_min_workers { config.external_generation_controller_max_workers = config.external_generation_controller_min_workers; } if let Some(target_jobs_per_worker) = read_first_usize_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER", ]) { config.external_generation_controller_target_jobs_per_worker = target_jobs_per_worker.max(1); } if let Some(poll_interval_ms) = read_first_positive_u64_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS", ]) { config.external_generation_controller_poll_interval = Duration::from_millis(poll_interval_ms); } if let Some(idle_rounds) = read_first_u32_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS", ]) { config.external_generation_controller_scale_down_idle_rounds = idle_rounds; } if let Some(service_template) = read_first_non_empty_env(&[ "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE", ]) { config.external_generation_controller_service_template = service_template; } if let Some(dry_run) = read_first_bool_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN"]) { config.external_generation_controller_dry_run = dry_run; } if let Some(max_concurrent_requests) = read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"]) { config.max_concurrent_requests = Some(max_concurrent_requests); } if let Some(max_concurrent_requests) = read_first_usize_env(&["GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"]) { config.admin_max_concurrent_requests = Some(max_concurrent_requests); } if let Some(timeout_ms) = read_first_positive_u64_env(&["GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"]) { config.shutdown_outbox_flush_timeout = Duration::from_millis(timeout_ms); } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) { config.tracking_outbox_enabled = enabled; } if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_TRACKING_OUTBOX_DIR"]) { config.tracking_outbox_dir = PathBuf::from(dir); } if let Some(batch_size) = read_first_usize_env(&["GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"]) { config.tracking_outbox_batch_size = batch_size; } if let Some(flush_interval_ms) = read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"]) { config.tracking_outbox_flush_interval = Duration::from_millis(flush_interval_ms); } if let Some(max_bytes) = read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"]) { config.tracking_outbox_max_bytes = max_bytes; } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) { config.wallet_refund_outbox_enabled = enabled; } if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"]) { config.wallet_refund_outbox_dir = PathBuf::from(dir); } if let Some(batch_size) = read_first_usize_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"]) { config.wallet_refund_outbox_batch_size = batch_size; } if let Some(flush_interval_ms) = read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"]) { config.wallet_refund_outbox_flush_interval = Duration::from_millis(flush_interval_ms); } if let Some(max_bytes) = read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"]) { config.wallet_refund_outbox_max_bytes = max_bytes; } if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) { config.otel_enabled = otel_enabled; } config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]); config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]); if let Some(admin_token_ttl_seconds) = read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"]) { config.admin_token_ttl_seconds = admin_token_ttl_seconds; } config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]); if let Some(jwt_issuer) = read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"]) { config.jwt_issuer = jwt_issuer; } if let Some(jwt_secret) = read_first_non_empty_env(&["GENARRATIVE_JWT_SECRET", "JWT_SECRET"]) { config.jwt_secret = jwt_secret; } if let Some(ttl_seconds) = read_first_duration_seconds_env(&[ "GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS", "JWT_EXPIRES_IN", ]) { config.jwt_access_token_ttl_seconds = ttl_seconds; } if let Some(refresh_cookie_name) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_NAME"]) { config.refresh_cookie_name = refresh_cookie_name; } if let Some(refresh_cookie_path) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_PATH"]) { config.refresh_cookie_path = refresh_cookie_path; } if let Some(refresh_cookie_same_site) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_SAME_SITE"]) { config.refresh_cookie_same_site = refresh_cookie_same_site; } if let Some(refresh_cookie_secure) = read_first_bool_env(&["AUTH_REFRESH_COOKIE_SECURE"]) { config.refresh_cookie_secure = refresh_cookie_secure; } if let Some(refresh_session_ttl_days) = read_first_positive_u32_env(&["AUTH_REFRESH_SESSION_TTL_DAYS"]) { config.refresh_session_ttl_days = refresh_session_ttl_days; } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) { config.dev_password_entry_auto_register_enabled = enabled; } if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; } if let Some(sms_auth_provider) = read_first_non_empty_env(&["SMS_AUTH_PROVIDER"]) { config.sms_auth_provider = sms_auth_provider; } if let Some(sms_endpoint) = read_first_non_empty_env(&["ALIYUN_SMS_ENDPOINT"]) { config.sms_endpoint = sms_endpoint; } config.sms_access_key_id = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_ID"]); config.sms_access_key_secret = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_SECRET"]); if let Some(sms_sign_name) = read_first_non_empty_env(&["ALIYUN_SMS_SIGN_NAME"]) { config.sms_sign_name = sms_sign_name; } if let Some(sms_template_code) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_CODE"]) { config.sms_template_code = sms_template_code; } if let Some(sms_template_param_key) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_PARAM_KEY"]) { config.sms_template_param_key = sms_template_param_key; } if let Some(sms_country_code) = read_first_non_empty_env(&["ALIYUN_SMS_COUNTRY_CODE"]) { config.sms_country_code = sms_country_code; } config.sms_scheme_name = read_first_non_empty_env(&["ALIYUN_SMS_SCHEME_NAME"]); if let Some(sms_code_length) = read_first_u8_env(&["ALIYUN_SMS_CODE_LENGTH"]) { config.sms_code_length = sms_code_length; } if let Some(sms_code_type) = read_first_u8_env(&["ALIYUN_SMS_CODE_TYPE"]) { config.sms_code_type = sms_code_type; } if let Some(sms_valid_time_seconds) = read_first_duration_seconds_env(&["ALIYUN_SMS_VALID_TIME_SECONDS"]) { config.sms_valid_time_seconds = sms_valid_time_seconds; } if let Some(sms_interval_seconds) = read_first_duration_seconds_env(&["ALIYUN_SMS_INTERVAL_SECONDS"]) { config.sms_interval_seconds = sms_interval_seconds; } if let Some(sms_duplicate_policy) = read_first_u8_env(&["ALIYUN_SMS_DUPLICATE_POLICY"]) { config.sms_duplicate_policy = sms_duplicate_policy; } if let Some(sms_case_auth_policy) = read_first_u8_env(&["ALIYUN_SMS_CASE_AUTH_POLICY"]) { config.sms_case_auth_policy = sms_case_auth_policy; } if let Some(sms_return_verify_code) = read_first_bool_env(&["ALIYUN_SMS_RETURN_VERIFY_CODE"]) { config.sms_return_verify_code = sms_return_verify_code; } if let Some(sms_mock_verify_code) = read_first_non_empty_env(&["SMS_AUTH_MOCK_VERIFY_CODE"]) { config.sms_mock_verify_code = sms_mock_verify_code; } if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) { config.wechat_auth_enabled = wechat_auth_enabled; } if let Some(wechat_auth_provider) = read_first_non_empty_env(&["WECHAT_AUTH_PROVIDER"]) { config.wechat_auth_provider = wechat_auth_provider; } config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]); config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]); config.wechat_mini_program_app_id = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_ID", "WECHAT_APP_ID"]); config.wechat_mini_program_app_secret = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_SECRET", "WECHAT_APP_SECRET"]); if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) { config.wechat_callback_path = wechat_callback_path; } if let Some(wechat_redirect_path) = read_first_non_empty_env(&["WECHAT_REDIRECT_PATH"]) { config.wechat_redirect_path = wechat_redirect_path; } if let Some(wechat_authorize_endpoint) = read_first_non_empty_env(&["WECHAT_AUTHORIZE_ENDPOINT"]) { config.wechat_authorize_endpoint = wechat_authorize_endpoint; } if let Some(wechat_access_token_endpoint) = read_first_non_empty_env(&["WECHAT_ACCESS_TOKEN_ENDPOINT"]) { config.wechat_access_token_endpoint = wechat_access_token_endpoint; } if let Some(wechat_user_info_endpoint) = read_first_non_empty_env(&["WECHAT_USER_INFO_ENDPOINT"]) { config.wechat_user_info_endpoint = wechat_user_info_endpoint; } if let Some(wechat_js_code_session_endpoint) = read_first_non_empty_env(&["WECHAT_JS_CODE_SESSION_ENDPOINT"]) { config.wechat_js_code_session_endpoint = wechat_js_code_session_endpoint; } if let Some(wechat_stable_access_token_endpoint) = read_first_non_empty_env(&["WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT"]) { config.wechat_stable_access_token_endpoint = wechat_stable_access_token_endpoint; } if let Some(wechat_phone_number_endpoint) = read_first_non_empty_env(&["WECHAT_PHONE_NUMBER_ENDPOINT"]) { config.wechat_phone_number_endpoint = wechat_phone_number_endpoint; } if let Some(wechat_state_ttl_minutes) = read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"]) { config.wechat_state_ttl_minutes = wechat_state_ttl_minutes; } if let Some(wechat_mock_user_id) = read_first_non_empty_env(&["WECHAT_MOCK_USER_ID"]) { config.wechat_mock_user_id = wechat_mock_user_id; } config.wechat_mock_union_id = read_first_non_empty_env(&["WECHAT_MOCK_UNION_ID"]); if let Some(wechat_mock_display_name) = read_first_non_empty_env(&["WECHAT_MOCK_DISPLAY_NAME"]) { config.wechat_mock_display_name = wechat_mock_display_name; } config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]); if let Some(wechat_pay_enabled) = read_first_bool_env(&["WECHAT_PAY_ENABLED"]) { config.wechat_pay_enabled = wechat_pay_enabled; } if let Some(wechat_pay_provider) = read_first_non_empty_env(&["WECHAT_PAY_PROVIDER"]) { config.wechat_pay_provider = wechat_pay_provider; } config.wechat_pay_mch_id = read_first_non_empty_env(&["WECHAT_PAY_MCH_ID"]); config.wechat_pay_merchant_serial_no = read_first_non_empty_env(&["WECHAT_PAY_MERCHANT_SERIAL_NO"]); config.wechat_pay_private_key_pem = read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PEM"]); config.wechat_pay_private_key_path = read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PATH"]).map(PathBuf::from); config.wechat_pay_platform_public_key_pem = read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM"]); config.wechat_pay_platform_public_key_path = read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"]).map(PathBuf::from); config.wechat_pay_platform_serial_no = read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_SERIAL_NO"]); config.wechat_pay_api_v3_key = read_first_non_empty_env(&["WECHAT_PAY_API_V3_KEY"]); config.wechat_pay_notify_url = read_first_non_empty_env(&["WECHAT_PAY_NOTIFY_URL"]); if let Some(enabled) = read_first_bool_env(&["WECHAT_PAY_REFUND_RECONCILIATION_ENABLED"]) { config.wechat_pay_refund_reconciliation_enabled = enabled; } if let Some(wechat_pay_jsapi_endpoint) = read_first_non_empty_env(&["WECHAT_PAY_JSAPI_ENDPOINT"]) { config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint; } config.wechat_mini_program_virtual_payment_offer_id = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"]); config.wechat_mini_program_virtual_payment_app_key = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]); config.wechat_mini_program_virtual_payment_sandbox_app_key = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]); if let Some(endpoint) = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"]) { config.wechat_mini_program_virtual_payment_query_order_endpoint = endpoint; } if let Some(endpoint) = read_first_non_empty_env(&[ "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT", ]) { config.wechat_mini_program_virtual_payment_notify_provide_goods_endpoint = endpoint; } config.wechat_mini_program_message_token = read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]); config.wechat_mini_program_message_encoding_aes_key = read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]); if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"]) && env <= 1 { config.wechat_mini_program_virtual_payment_env = env; } config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]); config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]); config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]); config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]); if let Some(oss_read_expire_seconds) = read_first_duration_seconds_env(&["ALIYUN_OSS_READ_EXPIRE_SECONDS"]) { config.oss_read_expire_seconds = oss_read_expire_seconds; } if let Some(oss_post_expire_seconds) = read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"]) { config.oss_post_expire_seconds = oss_post_expire_seconds; } if let Some(oss_post_max_size_bytes) = read_first_positive_u64_env(&["ALIYUN_OSS_POST_MAX_SIZE_BYTES"]) { config.oss_post_max_size_bytes = oss_post_max_size_bytes; } if let Some(oss_success_action_status) = read_first_positive_u16_env(&["ALIYUN_OSS_SUCCESS_ACTION_STATUS"]) { config.oss_success_action_status = oss_success_action_status; } // 快照与资源存储的目标独立;只复用凭据,不让通用 OSS 配置改变快照落点。 config.project_snapshot_oss_bucket = read_first_non_empty_env(&["GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET"]) .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string()); config.project_snapshot_oss_endpoint = read_first_non_empty_env(&["GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT"]) .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string()); config.project_snapshot_oss_access_key_id = read_first_non_empty_env(&[ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_ID", ]); config.project_snapshot_oss_access_key_secret = read_first_non_empty_env(&[ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", "ALIYUN_OSS_ACCESS_KEY_SECRET", ]); config.template_library_oss_access_key_id = read_first_non_empty_env(&["GENARRATIVE_AGC_TEMPLATE_LIBRARY_OSS_ACCESS_KEY_ID"]); config.template_library_oss_access_key_secret = read_first_non_empty_env(&["GENARRATIVE_AGC_TEMPLATE_LIBRARY_OSS_ACCESS_KEY_SECRET"]); if let Some(spacetime_server_url) = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"]) { config.spacetime_server_url = spacetime_server_url; } if let Some(spacetime_database) = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"]) { config.spacetime_database = spacetime_database; } config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]); config.spacetime_runtime_service_bootstrap_secret = read_secret_env_or_file( &["GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"], &["GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"], ); if let Some(spacetime_pool_size) = read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"]) { config.spacetime_pool_size = spacetime_pool_size; } if let Some(spacetime_procedure_timeout_seconds) = read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"]) { config.spacetime_procedure_timeout = Duration::from_secs(spacetime_procedure_timeout_seconds); } if let Some(spacetime_health_check_timeout_seconds) = read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS"]) { config.spacetime_health_check_timeout = Duration::from_secs(spacetime_health_check_timeout_seconds); } if let Some(llm_provider) = read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"]) { config.llm_provider = llm_provider; } if let Some(llm_base_url) = read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"]) { config.llm_base_url = llm_base_url; } else if config.llm_provider == LlmProvider::Ark { config.llm_base_url = DEFAULT_ARK_BASE_URL.to_string(); } config.llm_api_key = if config.llm_provider == LlmProvider::OpenAiCompatible { read_first_non_empty_env(&[ "GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "TIANTOKEN_API_KEY", "VECTOR_ENGINE_API_KEY", "ARK_API_KEY", ]) } else { read_first_non_empty_env(&[ "GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY", "TIANTOKEN_API_KEY", "VECTOR_ENGINE_API_KEY", ]) }; if let Some(llm_model) = read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"]) { config.llm_model = llm_model; } if let Some(llm_request_timeout_ms) = read_first_positive_u64_env(&[ "GENARRATIVE_LLM_REQUEST_TIMEOUT_MS", "LLM_REQUEST_TIMEOUT_MS", ]) { config.llm_request_timeout_ms = llm_request_timeout_ms; } if let Some(llm_max_retries) = read_first_u32_env(&["GENARRATIVE_LLM_MAX_RETRIES", "LLM_MAX_RETRIES"]) { config.llm_max_retries = llm_max_retries; } if let Some(llm_retry_backoff_ms) = read_first_u64_env(&["GENARRATIVE_LLM_RETRY_BACKOFF_MS", "LLM_RETRY_BACKOFF_MS"]) { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } config.llm_router_provisioning_secret = read_secret_env_or_file( &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET"], &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE"], ); config.llm_router_api_key_encryption_secret = read_secret_env_or_file( &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET"], &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE"], ); config.llm_router_admin_token = read_secret_env_or_file( &["GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN"], &["GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE"], ); if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) { config.dashscope_base_url = dashscope_base_url; } config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]); if let Some(dashscope_scene_image_model) = read_first_non_empty_env(&["DASHSCOPE_SCENE_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"]) { config.dashscope_scene_image_model = dashscope_scene_image_model; } if let Some(dashscope_reference_image_model) = read_first_non_empty_env(&[ "DASHSCOPE_REFERENCE_IMAGE_MODEL", "DASHSCOPE_IMAGE_EDIT_MODEL", ]) { config.dashscope_reference_image_model = dashscope_reference_image_model; } if let Some(dashscope_cover_image_model) = read_first_non_empty_env(&["DASHSCOPE_COVER_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"]) { config.dashscope_cover_image_model = dashscope_cover_image_model; } if let Some(dashscope_image_request_timeout_ms) = read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"]) { config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; } // 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。 // 保留以下历史加载代码,后续删除: // if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) { // config.apimart_base_url = apimart_base_url; // } // config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]); // if let Some(apimart_image_request_timeout_ms) = // read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"]) // { // config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms; // } if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"]) { config.vector_engine_base_url = vector_engine_base_url; } config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_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", ]) { // 单次 attempt 上限允许按环境收短;worker 调用还会受整次任务的绝对 deadline 约束。 config.vector_engine_image_request_timeout_ms = tiantoken_image_request_timeout_ms; } if let Some(vector_engine_audio_request_timeout_ms) = read_first_positive_u64_env(&["VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS"]) { config.vector_engine_audio_request_timeout_ms = vector_engine_audio_request_timeout_ms; } if let Some(elevenlabs_base_url) = read_first_non_empty_env(&["ELEVENLABS_BASE_URL"]) { config.elevenlabs_base_url = elevenlabs_base_url; } config.elevenlabs_api_key = read_first_non_empty_env(&["ELEVENLABS_API_KEY"]); if let Some(elevenlabs_request_timeout_ms) = read_first_positive_u64_env(&["ELEVENLABS_REQUEST_TIMEOUT_MS"]) { config.elevenlabs_request_timeout_ms = elevenlabs_request_timeout_ms; } if let Some(hyper3d_base_url) = read_first_non_empty_env(&["HYPER3D_BASE_URL", "RODIN_BASE_URL"]) { config.hyper3d_base_url = hyper3d_base_url; } config.hyper3d_api_key = read_first_non_empty_env(&["HYPER3D_API_KEY", "RODIN_API_KEY"]); if let Some(hyper3d_model_request_timeout_ms) = read_first_positive_u64_env(&[ "HYPER3D_MODEL_REQUEST_TIMEOUT_MS", "RODIN_MODEL_REQUEST_TIMEOUT_MS", ]) { config.hyper3d_model_request_timeout_ms = hyper3d_model_request_timeout_ms; } config.volcengine_speech_api_key = read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]); config.volcengine_speech_app_id = read_first_non_empty_env(&["VOLCENGINE_SPEECH_APP_ID", "VOLCENGINE_ACCESS_KEY_ID"]); config.volcengine_speech_access_key = read_first_non_empty_env(&[ "VOLCENGINE_SPEECH_ACCESS_KEY", "VOLCENGINE_SECRET_ACCESS_KEY", ]); if let Some(asr_resource_id) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_ASR_RESOURCE_ID"]) { config.volcengine_speech_asr_resource_id = asr_resource_id; } if let Some(tts_resource_id) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_TTS_RESOURCE_ID"]) { config.volcengine_speech_tts_resource_id = tts_resource_id; } if let Some(asr_ws_url) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_ASR_WS_URL"]) { config.volcengine_speech_asr_ws_url = asr_ws_url; } if let Some(tts_bidirection_ws_url) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_TTS_BIDIRECTION_WS_URL"]) { config.volcengine_speech_tts_bidirection_ws_url = tts_bidirection_ws_url; } if let Some(tts_sse_url) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_TTS_SSE_URL"]) { config.volcengine_speech_tts_sse_url = tts_sse_url; } if let Some(request_timeout_ms) = read_first_positive_u64_env(&["VOLCENGINE_SPEECH_REQUEST_TIMEOUT_MS"]) { config.volcengine_speech_request_timeout_ms = request_timeout_ms; } if let Some(max_concurrent_requests) = read_first_usize_env(&[ "GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", "DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", ]) { config.draft_asset_generation_max_concurrent_requests = max_concurrent_requests; } if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[ "ARK_CHARACTER_VIDEO_BASE_URL", "ARK_BASE_URL", "GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL", ]) { config.ark_character_video_base_url = ark_character_video_base_url; } else if config.llm_provider == LlmProvider::Ark { config.ark_character_video_base_url = DEFAULT_ARK_BASE_URL.to_string(); } config.ark_character_video_api_key = read_first_non_empty_env(&[ "ARK_CHARACTER_VIDEO_API_KEY", "ARK_API_KEY", "GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", ]); if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[ "ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS", "DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS", ]) { config.ark_character_video_request_timeout_ms = ark_character_video_request_timeout_ms; } if let Some(ark_character_video_model) = read_first_non_empty_env(&[ "ARK_CHARACTER_VIDEO_MODEL", "DASHSCOPE_CHARACTER_VIDEO_MODEL", ]) { config.ark_character_video_model = ark_character_video_model; } if let Some(character_animation_ffmpeg_path) = read_first_non_empty_env(&[ "GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH", "CHARACTER_ANIMATION_FFMPEG_PATH", ]) { config.character_animation_ffmpeg_path = character_animation_ffmpeg_path; } if let Some(character_animation_ffprobe_path) = read_first_non_empty_env(&[ "GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH", "CHARACTER_ANIMATION_FFPROBE_PATH", ]) { config.character_animation_ffprobe_path = character_animation_ffprobe_path; } if let Some(character_animation_frame_extract_timeout_ms) = read_first_positive_u64_env(&["CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS"]) { config.character_animation_frame_extract_timeout_ms = character_animation_frame_extract_timeout_ms; } if let Some(slow_request_threshold_ms) = read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"]) { config.slow_request_threshold_ms = slow_request_threshold_ms; } config } pub fn bind_socket_addr(&self) -> SocketAddr { let address = format!("{}:{}", self.bind_host, self.bind_port); address .parse() .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000))) } } /// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。 /// /// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作; /// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。 pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String { read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]) .unwrap_or_else(|| config.vector_engine_base_url.clone()) } pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option { read_first_non_empty_env(&["TIANTOKEN_API_KEY"]) .or_else(|| config.vector_engine_api_key.clone()) } fn read_first_non_empty_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key).ok().and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { return None; } Some(value) }) }) } fn read_secret_env_or_file(value_keys: &[&str], file_keys: &[&str]) -> Option { read_first_non_empty_env(value_keys).or_else(|| { let path = read_first_non_empty_env(file_keys)?; let value = fs::read_to_string(path).ok()?; let value = value.trim().to_string(); (!value.is_empty()).then_some(value) }) } fn read_local_spacetime_database() -> Option { let config_path = find_upward_file(SPACETIME_LOCAL_CONFIG_FILE)?; let raw_text = fs::read_to_string(config_path).ok()?; let parsed = serde_json::from_str::(&raw_text).ok()?; parsed .get("database") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn find_upward_file(file_name: &str) -> Option { let mut current_dir = env::current_dir().ok()?; loop { let candidate = current_dir.join(file_name); if candidate.is_file() { return Some(candidate); } if !current_dir.pop() { return None; } } } fn read_first_duration_seconds_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_duration_seconds(&value)) }) } fn read_first_bool_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value))) } fn read_first_llm_provider_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_llm_provider(&value)) }) } fn read_first_process_role_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_process_role(&value)) }) } fn read_first_external_generation_mode_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_external_generation_mode(&value)) }) } fn read_first_positive_u32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_u32(&value)) }) } fn read_first_positive_i32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_i32(&value)) }) } fn read_first_positive_u64_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_u64(&value)) }) } fn read_first_u32_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_u32(&value))) } fn read_first_u64_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value))) } fn read_first_usize_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_usize(&value)) }) } fn read_first_u8_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value))) } fn default_external_generation_worker_id() -> String { let host = env::var("HOSTNAME") .or_else(|_| env::var("COMPUTERNAME")) .unwrap_or_else(|_| "local".to_string()); format!("{}-{}", host.trim(), std::process::id()) } fn parse_process_role(value: &str) -> Option { match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { "api" => Some(ProcessRole::Api), "bgfilter-worker" | "bgfilter_worker" => Some(ProcessRole::BgfilterWorker), "external-generation-worker" | "external_generation_worker" | "worker" => { Some(ProcessRole::ExternalGenerationWorker) } "external-generation-controller" | "external_generation_controller" | "controller" => { Some(ProcessRole::ExternalGenerationController) } "all" => Some(ProcessRole::All), _ => None, } } fn parse_external_generation_mode(value: &str) -> Option { match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { "inline" | "sync" | "synchronous" => Some(ExternalGenerationMode::Inline), "queue" | "queued" | "worker" | "async" | "asynchronous" => { Some(ExternalGenerationMode::Queue) } _ => None, } } fn trim_quoted_env_value(raw: &str) -> &str { let raw = raw.trim(); raw.strip_prefix('"') .and_then(|value| value.strip_suffix('"')) .or_else(|| { raw.strip_prefix('\'') .and_then(|value| value.strip_suffix('\'')) }) .unwrap_or(raw) .trim() } fn read_first_positive_u16_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_u16(&value)) }) } fn parse_duration_seconds(raw: &str) -> Option { let raw = raw.trim(); if raw.is_empty() { return None; } if let Ok(seconds) = raw.parse::() { return Some(seconds); } let (number, unit) = raw.split_at(raw.len().checked_sub(1)?); let unit = unit.to_ascii_lowercase(); let number = number.trim().parse::().ok()?; let multiplier = match unit.as_str() { "s" => 1, "m" => 60, "h" => 60 * 60, "d" => 24 * 60 * 60, _ => return None, }; number.checked_mul(multiplier) } fn parse_bool(raw: &str) -> Option { let raw = raw.trim(); let raw = raw .strip_prefix('"') .and_then(|value| value.strip_suffix('"')) .or_else(|| { raw.strip_prefix('\'') .and_then(|value| value.strip_suffix('\'')) }) .unwrap_or(raw); match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } fn parse_llm_provider(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "ark" => Some(LlmProvider::Ark), "dash_scope" | "dashscope" => Some(LlmProvider::DashScope), "openai_compatible" | "openai-compatible" | "openai" => Some(LlmProvider::OpenAiCompatible), _ => None, } } fn parse_positive_u32(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } fn parse_positive_i32(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value <= 0 { return None; } Some(value) } fn parse_u32(raw: &str) -> Option { raw.trim().parse::().ok() } fn parse_positive_u64(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } fn parse_u64(raw: &str) -> Option { raw.trim().parse::().ok() } fn parse_positive_usize(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } fn parse_u8(raw: &str) -> Option { raw.trim().parse::().ok() } fn parse_positive_u16(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } #[cfg(test)] mod tests { use super::{ AppConfig, DEFAULT_EDITOR_BGFILTER_BASE_URL, DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS, DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD, DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS, DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS, DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS, 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, sync::{Mutex, OnceLock}, }; static ENV_LOCK: OnceLock> = OnceLock::new(); #[test] fn from_env_project_snapshot_target_is_independent_and_only_credentials_fallback() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); let keys = [ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", "ALIYUN_OSS_BUCKET", "ALIYUN_OSS_ENDPOINT", "ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET", ]; struct RestoreEnv(Vec<(&'static str, Option)>); impl Drop for RestoreEnv { fn drop(&mut self) { for (key, value) in &self.0 { unsafe { match value { Some(value) => std::env::set_var(key, value), None => std::env::remove_var(key), } } } } } let _restore = RestoreEnv( keys.iter() .map(|key| (*key, std::env::var_os(key))) .collect(), ); for key in keys { unsafe { std::env::remove_var(key) }; } let defaults = AppConfig::from_env(); assert_eq!(defaults.project_snapshot_oss_bucket, "agc-dev"); assert_eq!( defaults.project_snapshot_oss_endpoint, "oss-rg-china-mainland.aliyuncs.com" ); assert!(defaults.project_snapshot_oss_access_key_id.is_none()); assert!(defaults.project_snapshot_oss_access_key_secret.is_none()); unsafe { std::env::set_var("ALIYUN_OSS_BUCKET", "resource-fixture"); std::env::set_var("ALIYUN_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com"); std::env::set_var("ALIYUN_OSS_ACCESS_KEY_ID", "shared-fixture-id"); std::env::set_var("ALIYUN_OSS_ACCESS_KEY_SECRET", "shared-fixture-secret"); } let shared = AppConfig::from_env(); assert_eq!(shared.oss_bucket.as_deref(), Some("resource-fixture")); assert_eq!(shared.project_snapshot_oss_bucket, "agc-dev"); assert_eq!( shared.project_snapshot_oss_endpoint, "oss-rg-china-mainland.aliyuncs.com" ); assert_eq!( shared.project_snapshot_oss_access_key_id.as_deref(), Some("shared-fixture-id") ); assert_eq!( shared.project_snapshot_oss_access_key_secret.as_deref(), Some("shared-fixture-secret") ); unsafe { std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", " "); std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", ""); } let empty = AppConfig::from_env(); assert_eq!(empty.project_snapshot_oss_bucket, "agc-dev"); assert_eq!( empty.project_snapshot_oss_endpoint, "oss-rg-china-mainland.aliyuncs.com" ); unsafe { std::env::set_var( "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", "snapshot-fixture", ); std::env::set_var( "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", "oss-cn-shanghai.aliyuncs.com", ); std::env::set_var( "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", "snapshot-fixture-id", ); std::env::set_var( "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", "snapshot-fixture-secret", ); } let explicit = AppConfig::from_env(); assert_eq!(explicit.project_snapshot_oss_bucket, "snapshot-fixture"); assert_eq!( explicit.project_snapshot_oss_endpoint, "oss-cn-shanghai.aliyuncs.com" ); assert_eq!( explicit.project_snapshot_oss_access_key_id.as_deref(), Some("snapshot-fixture-id") ); assert_eq!( explicit.project_snapshot_oss_access_key_secret.as_deref(), Some("snapshot-fixture-secret") ); } #[test] fn default_keeps_non_public_model_and_base_url_empty() { let config = AppConfig::default(); assert_eq!(config.environment, "development"); assert_eq!(config.bgfilter_worker_host, "127.0.0.1"); assert_eq!(config.bgfilter_worker_port, 8083); assert_eq!(config.bgfilter_worker_base_url, "http://127.0.0.1:8083"); assert!(config.bgfilter_internal_token.is_none()); assert_eq!(config.bgfilter_worker_concurrency, 16); assert_eq!(config.bgfilter_worker_max_requests, 2048); assert_eq!(config.bgfilter_worker_connect_timeout_ms, 2_000); assert!(config.llm_model.is_empty()); assert!(config.llm_base_url.is_empty()); // assert!(config.apimart_base_url.is_empty()); assert!(config.vector_engine_base_url.is_empty()); assert_eq!( config.editor_bgfilter_base_url, DEFAULT_EDITOR_BGFILTER_BASE_URL ); assert_eq!( config.editor_bgfilter_single_image_estimate_ms, DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS ); assert_eq!(config.bgfilter_provider_attempt_timeout_ms(), 160_000); assert_eq!(config.bgfilter_call_budget_ms(), 321_000); assert_eq!( config.editor_bgfilter_circuit_failure_threshold, DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD ); assert_eq!( config.editor_bgfilter_circuit_cooldown.as_secs(), DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS ); assert_eq!(DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS, 120); assert!(config.editor_bgfilter_token.is_none()); assert_eq!( config.external_generation_worker_lease.as_secs(), DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS ); assert_eq!( config.external_generation_worker_job_timeout.as_secs(), DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS ); assert_eq!( config.external_generation_worker_long_job_timeout.as_secs(), DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS ); assert!(config.ark_character_video_base_url.is_empty()); assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2"); assert!(config.ark_character_video_model.is_empty()); assert!(config.dashscope_scene_image_model.is_empty()); assert!(config.dashscope_reference_image_model.is_empty()); assert!(config.dashscope_cover_image_model.is_empty()); assert_eq!( config.dashscope_base_url, "https://dashscope.aliyuncs.com/api/v1" ); assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com"); assert_eq!(config.sms_sign_name, "北京亓盒网络科技"); assert_eq!(config.sms_template_code, "SMS_506245486"); assert_eq!(config.sms_template_param_key, "code"); assert_eq!( config.wechat_authorize_endpoint, "https://open.weixin.qq.com/connect/qrconnect" ); } #[test] fn from_env_reads_runtime_environment() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::set_var("GENARRATIVE_ENV", " TEST "); } let config = AppConfig::from_env(); assert_eq!(config.environment, "test"); unsafe { std::env::remove_var("GENARRATIVE_ENV"); } } #[test] fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() { 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 "); } assert_eq!( tiantoken_base_url(&config), "https://api.tiantoken.example/" ); 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), "https://vector.example.invalid" ); assert_eq!( tiantoken_api_key(&config).as_deref(), Some("legacy-vector-key") ); } #[test] fn llm_router_key_encryption_secret_prefers_dedicated_secret_or_derives_from_jwt() { let mut config = AppConfig::default(); config.jwt_secret = "jwt-secret-a".to_string(); let derived = config .effective_llm_router_api_key_encryption_secret() .expect("jwt-derived storage key should be available"); assert_ne!(derived, "jwt-secret-a"); assert_eq!(derived.len(), 64); config.jwt_secret = "jwt-secret-b".to_string(); assert_ne!( config .effective_llm_router_api_key_encryption_secret() .expect("second jwt-derived storage key should be available"), derived ); config.llm_router_api_key_encryption_secret = Some(" dedicated-secret ".to_string()); assert_eq!( config .effective_llm_router_api_key_encryption_secret() .as_deref(), Some("dedicated-secret") ); config.llm_router_api_key_encryption_secret = Some(" ".to_string()); assert_ne!( config .effective_llm_router_api_key_encryption_secret() .expect("blank dedicated secret should use jwt-derived storage key"), "jwt-secret-b" ); } #[test] fn parse_bool_accepts_wrapped_quotes_from_shell_env() { assert_eq!(parse_bool("\"true\""), Some(true)); assert_eq!(parse_bool("'true'"), Some(true)); assert_eq!(parse_bool("\"false\""), Some(false)); assert_eq!(parse_bool("'off'"), Some(false)); } #[test] fn process_role_controls_http_and_external_generation_worker_roles() { assert_eq!(parse_process_role("api"), Some(ProcessRole::Api)); assert_eq!( parse_process_role("bgfilter-worker"), Some(ProcessRole::BgfilterWorker) ); assert_eq!( parse_process_role("'bgfilter_worker'"), Some(ProcessRole::BgfilterWorker) ); assert_eq!( parse_process_role("\"external-generation-worker\""), Some(ProcessRole::ExternalGenerationWorker) ); assert_eq!( parse_process_role("'external_generation_worker'"), Some(ProcessRole::ExternalGenerationWorker) ); assert_eq!( parse_process_role("worker"), Some(ProcessRole::ExternalGenerationWorker) ); assert_eq!( parse_process_role("controller"), Some(ProcessRole::ExternalGenerationController) ); assert_eq!( parse_process_role("'external_generation_controller'"), Some(ProcessRole::ExternalGenerationController) ); assert_eq!(parse_process_role("all"), Some(ProcessRole::All)); assert_eq!(parse_process_role("unknown"), None); assert_eq!(ProcessRole::BgfilterWorker.as_str(), "bgfilter-worker"); assert!(ProcessRole::Api.runs_http()); assert!(!ProcessRole::Api.runs_bgfilter_worker()); assert!(!ProcessRole::Api.runs_external_generation_worker()); assert!(!ProcessRole::Api.runs_external_generation_controller()); assert!(!ProcessRole::BgfilterWorker.runs_http()); assert!(ProcessRole::BgfilterWorker.runs_bgfilter_worker()); assert!(!ProcessRole::BgfilterWorker.runs_external_generation_worker()); assert!(!ProcessRole::BgfilterWorker.runs_external_generation_controller()); assert!(!ProcessRole::ExternalGenerationWorker.runs_http()); assert!(!ProcessRole::ExternalGenerationWorker.runs_bgfilter_worker()); assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker()); assert!(!ProcessRole::ExternalGenerationWorker.runs_external_generation_controller()); assert!(!ProcessRole::ExternalGenerationController.runs_http()); assert!(!ProcessRole::ExternalGenerationController.runs_bgfilter_worker()); assert!(!ProcessRole::ExternalGenerationController.runs_external_generation_worker()); assert!(ProcessRole::ExternalGenerationController.runs_external_generation_controller()); assert!(ProcessRole::All.runs_http()); assert!(!ProcessRole::All.runs_bgfilter_worker()); assert!(ProcessRole::All.runs_external_generation_worker()); assert!(!ProcessRole::All.runs_external_generation_controller()); } #[test] fn external_generation_mode_parses_inline_and_queue_aliases() { assert_eq!( parse_external_generation_mode("inline"), Some(ExternalGenerationMode::Inline) ); assert_eq!( parse_external_generation_mode("'sync'"), Some(ExternalGenerationMode::Inline) ); assert_eq!( parse_external_generation_mode("\"queue\""), Some(ExternalGenerationMode::Queue) ); assert_eq!( parse_external_generation_mode("worker"), Some(ExternalGenerationMode::Queue) ); assert_eq!(parse_external_generation_mode("unknown"), None); assert!(ExternalGenerationMode::Inline.is_inline()); assert!(!ExternalGenerationMode::Queue.is_inline()); } #[test] fn from_env_reads_external_generation_mode() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock"); unsafe { std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_MODE", "inline"); } let config = AppConfig::from_env(); assert_eq!( config.external_generation_mode, ExternalGenerationMode::Inline ); unsafe { std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_MODE"); } } #[test] fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("SMS_AUTH_ENABLED"); std::env::set_var("SMS_AUTH_ENABLED", "\"true\""); } let config = AppConfig::from_env(); assert!(config.sms_auth_enabled); unsafe { std::env::remove_var("SMS_AUTH_ENABLED"); } } #[test] fn default_elevenlabs_settings_fail_closed_with_a_bounded_timeout() { let config = AppConfig::default(); assert!(config.elevenlabs_base_url.is_empty()); assert!(config.elevenlabs_api_key.is_none()); assert_eq!( config.elevenlabs_request_timeout_ms, DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS ); } #[test] fn from_env_reads_elevenlabs_settings() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("ELEVENLABS_BASE_URL"); std::env::remove_var("ELEVENLABS_API_KEY"); std::env::remove_var("ELEVENLABS_REQUEST_TIMEOUT_MS"); std::env::set_var( "ELEVENLABS_BASE_URL", "https://elevenlabs.internal.example/v1", ); std::env::set_var("ELEVENLABS_API_KEY", "elevenlabs-test-key"); std::env::set_var("ELEVENLABS_REQUEST_TIMEOUT_MS", "190000"); } let config = AppConfig::from_env(); assert_eq!( config.elevenlabs_base_url, "https://elevenlabs.internal.example/v1" ); assert_eq!( config.elevenlabs_api_key.as_deref(), Some("elevenlabs-test-key") ); assert_eq!(config.elevenlabs_request_timeout_ms, 190_000); unsafe { std::env::remove_var("ELEVENLABS_BASE_URL"); std::env::remove_var("ELEVENLABS_API_KEY"); std::env::remove_var("ELEVENLABS_REQUEST_TIMEOUT_MS"); } } #[test] fn from_env_reads_non_public_models_and_urls() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_LLM_PROVIDER"); std::env::remove_var("GENARRATIVE_LLM_BASE_URL"); std::env::remove_var("GENARRATIVE_LLM_API_KEY"); std::env::remove_var("LLM_API_KEY"); std::env::remove_var("ARK_API_KEY"); std::env::remove_var("GENARRATIVE_LLM_MODEL"); std::env::remove_var("APIMART_BASE_URL"); std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS"); std::env::remove_var("VECTOR_ENGINE_BASE_URL"); std::env::remove_var("VECTOR_ENGINE_API_KEY"); std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"); std::env::remove_var("HYPER3D_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL"); std::env::remove_var("ARK_CHARACTER_VIDEO_BASE_URL"); std::env::remove_var("ARK_CHARACTER_VIDEO_MODEL"); std::env::set_var("GENARRATIVE_LLM_PROVIDER", "openai-compatible"); std::env::set_var( "GENARRATIVE_LLM_BASE_URL", "https://llm.internal.example/v1", ); std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model"); std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1"); std::env::set_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS", "190000"); std::env::set_var("ARK_API_KEY", "ark-key"); std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example"); std::env::set_var("VECTOR_ENGINE_API_KEY", "vector-engine-key"); std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000"); std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2"); std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model"); std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model"); std::env::set_var("DASHSCOPE_COVER_IMAGE_MODEL", "cover-model"); std::env::set_var( "ARK_CHARACTER_VIDEO_BASE_URL", "https://video.internal.example/v1", ); std::env::set_var("ARK_CHARACTER_VIDEO_MODEL", "video-model"); } let config = AppConfig::from_env(); assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible); assert_eq!(config.llm_base_url, "https://llm.internal.example/v1"); assert_eq!(config.llm_api_key.as_deref(), Some("vector-engine-key")); assert_eq!(config.llm_model, "internal-text-model"); // assert_eq!( // config.apimart_base_url, // "https://responses.internal.example/v1" // ); // assert_eq!(config.apimart_image_request_timeout_ms, 190_000); assert_eq!( config.vector_engine_base_url, "https://vector.internal.example" ); assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000); assert_eq!( config.hyper3d_base_url, "https://model.internal.example/api/v2" ); assert_eq!(config.dashscope_scene_image_model, "scene-model"); assert_eq!(config.dashscope_reference_image_model, "reference-model"); assert_eq!(config.dashscope_cover_image_model, "cover-model"); assert_eq!( config.ark_character_video_base_url, "https://video.internal.example/v1" ); assert_eq!(config.ark_character_video_model, "video-model"); unsafe { std::env::remove_var("GENARRATIVE_LLM_PROVIDER"); std::env::remove_var("GENARRATIVE_LLM_BASE_URL"); std::env::remove_var("GENARRATIVE_LLM_API_KEY"); std::env::remove_var("LLM_API_KEY"); std::env::remove_var("ARK_API_KEY"); std::env::remove_var("GENARRATIVE_LLM_MODEL"); std::env::remove_var("APIMART_BASE_URL"); std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS"); std::env::remove_var("VECTOR_ENGINE_BASE_URL"); std::env::remove_var("VECTOR_ENGINE_API_KEY"); std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"); std::env::remove_var("HYPER3D_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL"); std::env::remove_var("ARK_CHARACTER_VIDEO_BASE_URL"); std::env::remove_var("ARK_CHARACTER_VIDEO_MODEL"); } } #[test] fn from_env_reads_spacetime_pool_size() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); std::env::set_var("GENARRATIVE_SPACETIME_POOL_SIZE", "7"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_pool_size, 7); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); } } #[test] fn from_env_reads_runtime_service_bootstrap_secret_from_value_or_file() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); let secret_path = std::env::temp_dir().join(format!( "genarrative-runtime-service-bootstrap-{}.txt", std::process::id() )); fs::write(&secret_path, " file-secret \n").expect("secret file should write"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"); std::env::set_var( "GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE", &secret_path, ); } assert_eq!( AppConfig::from_env() .spacetime_runtime_service_bootstrap_secret .as_deref(), Some("file-secret") ); unsafe { std::env::set_var( "GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET", "direct-secret", ); } assert_eq!( AppConfig::from_env() .spacetime_runtime_service_bootstrap_secret .as_deref(), Some("direct-secret") ); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"); std::env::remove_var("GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"); } let _ = fs::remove_file(secret_path); } #[test] fn from_env_reads_llm_router_admin_token_from_value_or_file() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); let token_path = std::env::temp_dir().join(format!( "genarrative-llm-router-admin-token-{}.txt", std::process::id() )); fs::write(&token_path, " file-admin-token \n").expect("token file should write"); unsafe { std::env::remove_var("GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN"); std::env::set_var("GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE", &token_path); } assert_eq!( AppConfig::from_env().llm_router_admin_token.as_deref(), Some("file-admin-token") ); unsafe { std::env::set_var("GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN", "direct-admin-token"); } assert_eq!( AppConfig::from_env().llm_router_admin_token.as_deref(), Some("direct-admin-token") ); unsafe { std::env::remove_var("GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN"); std::env::remove_var("GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE"); } let _ = fs::remove_file(token_path); } #[test] fn from_env_reads_bgfilter_worker_settings_and_secret_from_value_or_file() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); let secret_path = std::env::temp_dir().join(format!( "genarrative-bgfilter-internal-token-{}.txt", std::process::id() )); fs::write(&secret_path, " file-token \n").expect("secret file should write"); unsafe { std::env::remove_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN"); std::env::set_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE", &secret_path); std::env::set_var("GENARRATIVE_BGFILTER_WORKER_HOST", "127.0.0.2"); std::env::set_var("GENARRATIVE_BGFILTER_WORKER_PORT", "18083"); std::env::set_var( "GENARRATIVE_BGFILTER_WORKER_BASE_URL", "http://127.0.0.2:18083", ); std::env::set_var("GENARRATIVE_BGFILTER_WORKER_CONCURRENCY", "6"); std::env::set_var("GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS", "192"); std::env::set_var("GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS", "3500"); std::env::set_var("GENARRATIVE_PROCESS_ROLE", "bgfilter_worker"); } let config = AppConfig::from_env(); assert_eq!(config.process_role, ProcessRole::BgfilterWorker); assert_eq!(config.bgfilter_worker_host, "127.0.0.2"); assert_eq!(config.bgfilter_worker_port, 18_083); assert_eq!(config.bgfilter_worker_base_url, "http://127.0.0.2:18083"); assert_eq!( config.bgfilter_internal_token.as_deref(), Some("file-token") ); assert_eq!(config.bgfilter_worker_concurrency, 6); assert_eq!(config.bgfilter_worker_max_requests, 192); assert_eq!(config.bgfilter_worker_connect_timeout_ms, 3_500); unsafe { std::env::set_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN", "direct-token"); } assert_eq!( AppConfig::from_env().bgfilter_internal_token.as_deref(), Some("direct-token") ); unsafe { std::env::remove_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN"); std::env::remove_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE"); std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_HOST"); std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_PORT"); std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_BASE_URL"); std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_CONCURRENCY"); std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS"); std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS"); std::env::remove_var("GENARRATIVE_PROCESS_ROLE"); } let _ = fs::remove_file(secret_path); } #[test] fn from_env_reads_api_runtime_performance_settings() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG"); std::env::remove_var("GENARRATIVE_API_WORKER_THREADS"); std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048"); std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6"); std::env::set_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS", "128"); std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16"); std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false"); std::env::set_var( "GENARRATIVE_TRACKING_OUTBOX_DIR", "/tmp/genarrative-tracking-outbox", ); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576"); std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false"); std::env::set_var( "GENARRATIVE_WALLET_REFUND_OUTBOX_DIR", "/tmp/genarrative-wallet-refund-outbox", ); std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE", "50"); std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS", "3000"); std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES", "524288"); std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true"); } let config = AppConfig::from_env(); assert_eq!(config.listen_backlog, 2048); assert_eq!(config.worker_threads, Some(6)); assert_eq!(config.max_concurrent_requests, Some(128)); assert_eq!(config.admin_max_concurrent_requests, Some(16)); assert_eq!( config.shutdown_outbox_flush_timeout, std::time::Duration::from_millis(3_000) ); assert!(!config.tracking_outbox_enabled); assert_eq!( config.tracking_outbox_dir, std::path::PathBuf::from("/tmp/genarrative-tracking-outbox") ); assert_eq!(config.tracking_outbox_batch_size, 250); assert_eq!( config.tracking_outbox_flush_interval, std::time::Duration::from_millis(2_000) ); assert_eq!(config.tracking_outbox_max_bytes, 1_048_576); assert!(!config.wallet_refund_outbox_enabled); assert_eq!( config.wallet_refund_outbox_dir, std::path::PathBuf::from("/tmp/genarrative-wallet-refund-outbox") ); assert_eq!(config.wallet_refund_outbox_batch_size, 50); assert_eq!( config.wallet_refund_outbox_flush_interval, std::time::Duration::from_millis(3_000) ); assert_eq!(config.wallet_refund_outbox_max_bytes, 524_288); assert!(config.otel_enabled); unsafe { std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG"); std::env::remove_var("GENARRATIVE_API_WORKER_THREADS"); std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); } } #[test] fn from_env_reads_wechat_pay_settings() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("WECHAT_PAY_ENABLED"); std::env::remove_var("WECHAT_PAY_PROVIDER"); std::env::remove_var("WECHAT_PAY_MCH_ID"); std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO"); std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH"); std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"); std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO"); std::env::remove_var("WECHAT_PAY_API_V3_KEY"); std::env::remove_var("WECHAT_PAY_NOTIFY_URL"); std::env::remove_var("WECHAT_PAY_REFUND_RECONCILIATION_ENABLED"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"); std::env::remove_var( "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT", ); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); std::env::set_var("WECHAT_PAY_ENABLED", "true"); std::env::set_var("WECHAT_PAY_PROVIDER", "real"); std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109"); std::env::set_var("WECHAT_PAY_MERCHANT_SERIAL_NO", "serial-001"); std::env::set_var("WECHAT_PAY_PRIVATE_KEY_PATH", "certs/apiclient_key.pem"); std::env::set_var( "WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH", "certs/wechatpay_platform.pem", ); std::env::set_var("WECHAT_PAY_PLATFORM_SERIAL_NO", "platform-serial-001"); std::env::set_var("WECHAT_PAY_API_V3_KEY", "12345678901234567890123456789012"); std::env::set_var( "WECHAT_PAY_NOTIFY_URL", "https://api.example.com/api/profile/recharge/wechat/notify", ); std::env::set_var("WECHAT_PAY_REFUND_RECONCILIATION_ENABLED", "true"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", "offer-001"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", "app-key-001"); std::env::set_var( "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY", "sandbox-app-key-001", ); std::env::set_var( "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT", "http://127.0.0.1:18080/xpay/query_order", ); std::env::set_var( "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT", "http://127.0.0.1:18080/xpay/notify_provide_goods", ); std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001"); std::env::set_var( "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", ); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1"); } let config = AppConfig::from_env(); assert!(config.wechat_pay_enabled); assert_eq!(config.wechat_pay_provider, "real"); assert_eq!(config.wechat_pay_mch_id.as_deref(), Some("1900000109")); assert_eq!( config.wechat_pay_private_key_path.as_deref(), Some(std::path::Path::new("certs/apiclient_key.pem")) ); assert_eq!( config.wechat_pay_notify_url.as_deref(), Some("https://api.example.com/api/profile/recharge/wechat/notify") ); assert!(config.wechat_pay_refund_reconciliation_enabled); assert_eq!( config.wechat_pay_platform_public_key_path.as_deref(), Some(std::path::Path::new("certs/wechatpay_platform.pem")) ); assert_eq!( config.wechat_pay_platform_serial_no.as_deref(), Some("platform-serial-001") ); assert_eq!( config .wechat_mini_program_virtual_payment_offer_id .as_deref(), Some("offer-001") ); assert_eq!( config .wechat_mini_program_virtual_payment_app_key .as_deref(), Some("app-key-001") ); assert_eq!( config.wechat_mini_program_message_token.as_deref(), Some("message-token-001") ); assert_eq!( config .wechat_mini_program_message_encoding_aes_key .as_deref(), Some("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") ); assert_eq!( config .wechat_mini_program_virtual_payment_sandbox_app_key .as_deref(), Some("sandbox-app-key-001") ); assert_eq!(config.wechat_mini_program_virtual_payment_env, 1); assert_eq!( config.wechat_mini_program_virtual_payment_query_order_endpoint, "http://127.0.0.1:18080/xpay/query_order" ); assert_eq!( config.wechat_mini_program_virtual_payment_notify_provide_goods_endpoint, "http://127.0.0.1:18080/xpay/notify_provide_goods" ); unsafe { std::env::remove_var("WECHAT_PAY_ENABLED"); std::env::remove_var("WECHAT_PAY_PROVIDER"); std::env::remove_var("WECHAT_PAY_MCH_ID"); std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO"); std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH"); std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"); std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO"); std::env::remove_var("WECHAT_PAY_API_V3_KEY"); std::env::remove_var("WECHAT_PAY_NOTIFY_URL"); std::env::remove_var("WECHAT_PAY_REFUND_RECONCILIATION_ENABLED"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"); std::env::remove_var( "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT", ); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); } } #[test] fn from_env_ignores_zero_spacetime_pool_size() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); std::env::set_var("GENARRATIVE_SPACETIME_POOL_SIZE", "0"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_pool_size, 4); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); } } #[test] fn from_env_reads_spacetime_procedure_timeout() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); std::env::set_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS", "45"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_procedure_timeout.as_secs(), 45); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); } } #[test] fn from_env_reads_spacetime_health_check_timeout() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS"); std::env::set_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS", "3"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_health_check_timeout.as_secs(), 3); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS"); } } #[test] fn from_env_reads_editor_generation_pricing_override_path() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"); std::env::set_var( "GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH", "server-rs/.data/editor-pricing.json", ); } let config = AppConfig::from_env(); assert_eq!( config.editor_generation_pricing_override_path, std::path::PathBuf::from("server-rs/.data/editor-pricing.json") ); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"); } } #[test] fn from_env_reads_editor_bgfilter_settings_and_reuses_background_token() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_BASE_URL"); std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN"); std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS"); std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"); std::env::set_var( "GENARRATIVE_EDITOR_BGFILTER_BASE_URL", "http://10.0.0.12/bgfilter", ); std::env::set_var( "GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN", "shared-token", ); std::env::set_var( "GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS", "8000", ); } let config = AppConfig::from_env(); assert_eq!(config.editor_bgfilter_base_url, "http://10.0.0.12/bgfilter"); assert_eq!( config.editor_bgfilter_token.as_deref(), Some("shared-token") ); assert_eq!(config.editor_bgfilter_single_image_estimate_ms, 8_000); // attempt / callBudget 必须随 N 与 est 联动派生,而不是固定值。 assert_eq!( config.bgfilter_provider_attempt_timeout_ms(), config.bgfilter_worker_concurrency as u64 * 8_000 * 2 ); assert_eq!( config.bgfilter_call_budget_ms(), config.bgfilter_provider_attempt_timeout_ms() * 2 + 1_000 ); unsafe { std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN", "bgfilter-token"); } let config = AppConfig::from_env(); assert_eq!( config.editor_bgfilter_token.as_deref(), Some("bgfilter-token") ); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_BASE_URL"); std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN"); std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS"); std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"); } } #[test] fn from_env_reads_aliyun_matting_settings_and_reuses_alibaba_cloud_keys() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED"); std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT"); std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID"); std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET"); std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"); std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID"); std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET"); std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_ID", "shared-ak-id"); std::env::set_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "shared-ak-secret"); std::env::set_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS", "45000"); } let config = AppConfig::from_env(); assert!(config.aliyun_matting_enabled); assert_eq!( config.aliyun_matting_endpoint, "imageseg.cn-shanghai.aliyuncs.com" ); assert_eq!( config.aliyun_matting_access_key_id.as_deref(), Some("shared-ak-id") ); assert_eq!( config.aliyun_matting_access_key_secret.as_deref(), Some("shared-ak-secret") ); assert_eq!(config.aliyun_matting_request_timeout_ms, 45_000); unsafe { std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", "matting-ak-id"); std::env::set_var("GENARRATIVE_ALIYUN_MATTING_ENABLED", "false"); } let config = AppConfig::from_env(); assert!(!config.aliyun_matting_enabled); assert_eq!( config.aliyun_matting_access_key_id.as_deref(), Some("matting-ak-id") ); unsafe { std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ENABLED"); std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID"); std::env::remove_var("GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS"); std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_ID"); std::env::remove_var("ALIBABA_CLOUD_ACCESS_KEY_SECRET"); } } #[test] fn from_env_reads_editor_bgfilter_circuit_settings() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS"); std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD", "4"); std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS", "90"); } let config = AppConfig::from_env(); assert_eq!(config.editor_bgfilter_circuit_failure_threshold, 4); assert_eq!(config.editor_bgfilter_circuit_cooldown.as_secs(), 90); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS"); } } #[test] fn from_env_allows_zero_editor_bgfilter_circuit_threshold() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD", "0"); } let config = AppConfig::from_env(); assert_eq!(config.editor_bgfilter_circuit_failure_threshold, 0); unsafe { std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); } } #[test] fn from_env_reads_external_generation_worker_timeout_settings() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS"); std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS"); std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS"); std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS", "5m"); std::env::set_var( "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS", "12m", ); std::env::set_var( "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS", "30m", ); } let config = AppConfig::from_env(); assert_eq!(config.external_generation_worker_lease.as_secs(), 300); assert_eq!(config.external_generation_worker_job_timeout.as_secs(), 720); assert_eq!( config.external_generation_worker_long_job_timeout.as_secs(), 1_800 ); unsafe { std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS"); std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS"); std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS"); } } #[test] fn from_env_reads_image_editor_agent_sidebar_runtime_flag() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"); std::env::set_var("GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR", "true"); } let config = AppConfig::from_env(); assert!(config.image_editor_agent_sidebar_enabled); unsafe { std::env::remove_var("GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"); } } #[test] fn client_download_channel_defaults_to_dev_and_preserves_explicit_configuration() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock"); let previous = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); unsafe { std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); } assert_eq!(AppConfig::from_env().client_download_channel, "dev"); for (value, expected) in [ ("release", "release"), (" qa-2026 ", "qa-2026"), ("", ""), ("dev-win", "dev-win"), ] { unsafe { std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value); } assert_eq!(AppConfig::from_env().client_download_channel, expected); } unsafe { match previous { Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value), None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"), } } } #[test] fn project_snapshot_channel_follows_client_download_channel_unless_overridden() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock"); let previous_snapshot = std::env::var_os("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); let previous_download = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); unsafe { std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); } // 未显式配置快照渠道时跟随部署的客户端渠道,缺省是 dev。 assert_eq!(AppConfig::from_env().project_snapshot_channel, "dev"); unsafe { std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", "release"); } assert_eq!(AppConfig::from_env().project_snapshot_channel, "release"); // 显式配置优先;空值与非法值同样保留,由快照入口失败关闭。 for (value, expected) in [(" qa-2026 ", "qa-2026"), ("", ""), ("Dev", "Dev")] { unsafe { std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value); } assert_eq!(AppConfig::from_env().project_snapshot_channel, expected); } unsafe { match previous_snapshot { Some(value) => std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value), None => std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"), } match previous_download { Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value), None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"), } } } #[test] fn from_env_reads_prefixed_character_animation_ffmpeg_paths() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH"); std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH"); std::env::remove_var("CHARACTER_ANIMATION_FFMPEG_PATH"); std::env::remove_var("CHARACTER_ANIMATION_FFPROBE_PATH"); std::env::set_var( "GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH", "C:\\Genarrative\\ffmpeg\\bin\\ffmpeg.exe", ); std::env::set_var( "GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH", "C:\\Genarrative\\ffmpeg\\bin\\ffprobe.exe", ); } let config = AppConfig::from_env(); assert_eq!( config.character_animation_ffmpeg_path, "C:\\Genarrative\\ffmpeg\\bin\\ffmpeg.exe" ); assert_eq!( config.character_animation_ffprobe_path, "C:\\Genarrative\\ffmpeg\\bin\\ffprobe.exe" ); unsafe { std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH"); std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH"); std::env::remove_var("CHARACTER_ANIMATION_FFMPEG_PATH"); std::env::remove_var("CHARACTER_ANIMATION_FFPROBE_PATH"); } } }