From a990b6e822de06962a48f3c8a75c0fa08eed6bbe Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:05:17 +0800 Subject: [PATCH 01/48] chore: start v1.16.2 development Back-merge the accepted v1.16.1 release and mark the next patch development target. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f7df330f..df231a82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quickquip" -version = "1.16.1" +version = "1.16.2-dev.0" requires-python = ">=3.11" dynamic = ["dependencies"] From f6006ecc7d034c8905c533aca65dec6a1eef1d2b Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:39:18 +0800 Subject: [PATCH 02/48] feat(llm): add revisioned configuration database Introduce strict JSON configuration, legacy asset migration, immutable runtime snapshots and bot-owned apply/probe operations. Expose protected configuration APIs and cover persistence, fencing, pricing, group references and lifecycle boundaries. --- config/llm.json.example | 439 +++++++ .../ci/export_llm_configuration_openapi.py | 18 + .../adapters/nonebot/command_parts/history.py | 7 + .../adapters/nonebot/config_control.py | 103 ++ .../adapters/nonebot/daily_briefing_plugin.py | 4 +- .../adapters/nonebot/daily_summary_plugin.py | 4 +- src/quickquip/adapters/nonebot/lifecycle.py | 4 + src/quickquip/app/web/app.py | 9 + .../app/web/llm_configuration_schema.py | 356 ++++++ src/quickquip/app/web/routes/config.py | 17 +- src/quickquip/app/web/routes/diagnostics.py | 141 +-- .../app/web/routes/group_settings.py | 81 +- .../app/web/routes/llm_configuration.py | 437 +++++++ src/quickquip/app/web/routes/llm_effort.py | 207 +--- src/quickquip/app/web/routes/mcp_dashboard.py | 133 +-- src/quickquip/app/web/routes/skills.py | 68 +- src/quickquip/chat/summary_jobs.py | 3 + src/quickquip/common/paths.py | 1 + src/quickquip/generation/__init__.py | 2 - src/quickquip/generation/config.py | 19 +- src/quickquip/generation/service.py | 14 +- src/quickquip/llm/briefing.py | 11 +- src/quickquip/llm/config.py | 998 +--------------- src/quickquip/llm/config_cli.py | 165 +++ src/quickquip/llm/config_document.py | 550 +++++++++ src/quickquip/llm/config_durability.py | 44 + src/quickquip/llm/config_errors.py | 22 + src/quickquip/llm/config_fields.py | 245 ++++ src/quickquip/llm/config_legacy.py | 1011 +++++++++++++++++ src/quickquip/llm/config_migration.py | 373 ++++++ src/quickquip/llm/config_runtime.py | 309 +++++ src/quickquip/llm/config_service.py | 485 ++++++++ src/quickquip/llm/config_store.py | 328 ++++++ src/quickquip/llm/config_tasks.py | 146 +++ src/quickquip/llm/config_validation.py | 54 + src/quickquip/llm/health.py | 15 +- src/quickquip/llm/personas.py | 123 ++ src/quickquip/llm/pricing.py | 12 +- src/quickquip/llm/profile.py | 4 + src/quickquip/llm/provider/base.py | 12 +- src/quickquip/llm/provider/factory.py | 19 +- src/quickquip/llm/provider_health.py | 23 +- src/quickquip/llm/quick_judge.py | 13 +- src/quickquip/llm/runtime_snapshot.py | 376 ++++++ src/quickquip/llm/service.py | 75 +- src/quickquip/llm/service_parts/health.py | 8 + .../llm/service_parts/mcp_lifecycle.py | 28 +- .../llm/service_parts/single_shot.py | 4 + src/quickquip/llm/service_parts/skills.py | 4 +- src/quickquip/llm/service_parts/tools.py | 32 +- src/quickquip/llm/settings.py | 8 + src/quickquip/llm/single_shot.py | 11 + src/quickquip/llm/skills/catalog.py | 25 +- src/quickquip/llm/summarize.py | 9 +- src/quickquip/llm/tool_registry.py | 6 + src/quickquip/llm/usage.py | 67 +- src/quickquip/llm/usage_store.py | 46 +- tests/fixtures/configs.py | 13 +- .../test_agent_loop_request_lifecycle.py | 11 +- tests/integration/test_llm_service.py | 134 +-- tests/integration/test_llm_skills.py | 1 - tests/unit/adapters/test_config_control.py | 57 + tests/unit/adapters/test_profile_snapshot.py | 88 ++ tests/unit/adapters/test_voice.py | 5 - tests/unit/generation/test_config.py | 20 +- tests/unit/llm/skills/test_catalog.py | 30 +- tests/unit/llm/test_bot_boot_fencing.py | 102 ++ tests/unit/llm/test_config_document.py | 397 +++++++ tests/unit/llm/test_config_env_expand.py | 2 +- tests/unit/llm/test_config_fields_metadata.py | 82 ++ tests/unit/llm/test_config_runtime.py | 403 +++++++ tests/unit/llm/test_config_service.py | 373 ++++++ tests/unit/llm/test_health.py | 80 +- tests/unit/llm/test_mcp_dual_era.py | 2 +- tests/unit/llm/test_probe_single_dispatch.py | 90 ++ tests/unit/llm/test_single_shot_entries.py | 6 +- tests/unit/llm/test_tools_enabled_mode.py | 6 +- tests/unit/llm/test_usage_metering.py | 175 ++- tests/unit/scripts/test_deploy_v4.py | 94 +- tests/unit/web/conftest.py | 26 + tests/unit/web/test_config_routes.py | 47 +- tests/unit/web/test_diagnostics_routes.py | 84 +- .../test_group_settings_delivery_routes.py | 27 +- .../web/test_group_settings_effort_routes.py | 48 +- .../unit/web/test_llm_configuration_routes.py | 189 +++ tests/unit/web/test_llm_effort_routes.py | 284 +---- tests/unit/web/test_mcp_dashboard_routes.py | 37 +- tests/unit/web/test_skills_routes.py | 29 +- 88 files changed, 8495 insertions(+), 2175 deletions(-) create mode 100644 config/llm.json.example create mode 100644 scripts/ci/export_llm_configuration_openapi.py create mode 100644 src/quickquip/adapters/nonebot/config_control.py create mode 100644 src/quickquip/app/web/llm_configuration_schema.py create mode 100644 src/quickquip/app/web/routes/llm_configuration.py create mode 100644 src/quickquip/llm/config_cli.py create mode 100644 src/quickquip/llm/config_document.py create mode 100644 src/quickquip/llm/config_durability.py create mode 100644 src/quickquip/llm/config_errors.py create mode 100644 src/quickquip/llm/config_fields.py create mode 100644 src/quickquip/llm/config_legacy.py create mode 100644 src/quickquip/llm/config_migration.py create mode 100644 src/quickquip/llm/config_runtime.py create mode 100644 src/quickquip/llm/config_service.py create mode 100644 src/quickquip/llm/config_store.py create mode 100644 src/quickquip/llm/config_tasks.py create mode 100644 src/quickquip/llm/config_validation.py create mode 100644 src/quickquip/llm/personas.py create mode 100644 src/quickquip/llm/runtime_snapshot.py create mode 100644 tests/unit/adapters/test_config_control.py create mode 100644 tests/unit/adapters/test_profile_snapshot.py create mode 100644 tests/unit/llm/test_bot_boot_fencing.py create mode 100644 tests/unit/llm/test_config_document.py create mode 100644 tests/unit/llm/test_config_fields_metadata.py create mode 100644 tests/unit/llm/test_config_runtime.py create mode 100644 tests/unit/llm/test_config_service.py create mode 100644 tests/unit/llm/test_probe_single_dispatch.py create mode 100644 tests/unit/web/conftest.py create mode 100644 tests/unit/web/test_llm_configuration_routes.py diff --git a/config/llm.json.example b/config/llm.json.example new file mode 100644 index 00000000..318e5815 --- /dev/null +++ b/config/llm.json.example @@ -0,0 +1,439 @@ +{ + "format_version": 1, + "providers": [ + { + "enabled": true, + "headers": {}, + "fallback_urls": [], + "proxy": "", + "auth_method": "api_key", + "aliases": [], + "parameters": { + "timeout_seconds": 45.0, + "temperature": 0.8, + "max_output_tokens": 2048, + "style_profile": "openai_family", + "style_overrides": "" + }, + "id": "openai-main", + "protocol": "openai", + "base_url": "https://your-openai-relay.example/v1", + "api_key_env": "OPENAI_API_KEY", + "default_model": "gpt-5.4", + "models": [ + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gpt-5.4" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gpt-5.2" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gpt-5.1" + } + ] + }, + { + "enabled": true, + "headers": {}, + "fallback_urls": [], + "proxy": "", + "auth_method": "api_key", + "aliases": [], + "parameters": { + "timeout_seconds": 45.0, + "temperature": 0.8, + "max_output_tokens": 2048, + "style_profile": "claude_family", + "style_overrides": "" + }, + "id": "claude-main", + "protocol": "claude", + "base_url": "https://your-claude-relay.example/v1", + "api_key_env": "ANTHROPIC_API_KEY", + "default_model": "claude-sonnet-4-6", + "models": [ + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "claude-sonnet-4-6" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "claude-opus-4-5" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "claude-sonnet-4-5" + } + ] + }, + { + "enabled": true, + "headers": {}, + "fallback_urls": [], + "proxy": "", + "auth_method": "api_key", + "aliases": [], + "parameters": { + "timeout_seconds": 45.0, + "temperature": 0.5, + "max_output_tokens": 2048, + "style_profile": "gemini_family", + "style_overrides": "" + }, + "id": "gemini-main", + "protocol": "gemini", + "base_url": "https://your-gemini-relay.example/v1beta", + "api_key_env": "GEMINI_API_KEY", + "default_model": "gemini-3.1-pro-high", + "models": [ + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gemini-3.1-pro-high" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gemini-3-flash-preview" + } + ] + }, + { + "enabled": true, + "headers": {}, + "fallback_urls": [], + "proxy": "", + "auth_method": "api_key", + "aliases": [], + "parameters": { + "timeout_seconds": 45.0, + "temperature": 0.5, + "max_output_tokens": 2048, + "epoch_cold_idle_seconds": 21600, + "style_profile": "openai_family", + "style_overrides": "" + }, + "id": "deepseek", + "protocol": "openai", + "base_url": "https://api.deepseek.com/v1", + "api_key_env": "DEEPSEEK_API_KEY", + "default_model": "deepseek-chat", + "models": [ + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "deepseek-chat" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "deepseek-reasoner" + } + ] + }, + { + "enabled": true, + "headers": {}, + "fallback_urls": [], + "proxy": "", + "auth_method": "api_key", + "aliases": [], + "parameters": { + "timeout_seconds": 45.0, + "temperature": 0.8, + "max_output_tokens": 2048, + "style_profile": "openai_family", + "style_overrides": "" + }, + "id": "openai-responses", + "protocol": "openai_responses", + "base_url": "https://api.openai.com/v1", + "api_key_env": "OPENAI_API_KEY", + "default_model": "gpt-5.2", + "models": [ + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gpt-5.2" + }, + { + "enabled": true, + "vision": true, + "context_window": null, + "parameters": {}, + "model": "gpt-5.1" + } + ] + } + ], + "style_profiles": { + "openai_family": "- 一定不要用AI助手的语言风格。你是聊天水群的群友,不是任何人的助手:没有人在这条消息里向你提问等待服务,大家只是在聊天,你恰好也在场。\n- 先反应,再说话。回复先给自己的态度——惊讶、好笑、好奇、不服气、平淡、无语都算,反应不分大小,冷淡的\"……哦,是这个啊\"也是反应——然后再说内容。信息是顺带的,态度才是主体。\n- 一两句话说完就停,不要小作文,不要\"总之\"\"综上\",不要把答案组织得滴水不漏。留一点空让对方能接话。\n- 可以把话题抛回去:随口反问、吐槽、点名刚才说话的群友,都是正常群聊行为。\n- 不要用 Markdown(加粗、#标题、列表编号):QQ 里显示为乱码,而且像在写文档。用大白话连着说。\n- 不要汇报式说话(\"我看了下图\"\"根据聊天记录\"),你在聊天不是在提交报告;也不要旁白括号,你是在说话,不是在写剧本。\n- 不确定就像群友那样随口带过:\"好像\"\"我记得\"\"没查到\"说完就完,不要在结论后面追加\"这只是X不是Y\"\"不能据此说Z\"式的免责说明,群聊里没人要你背举证责任。\n- 对具体的人和具体的事说话:接住群里正在聊的梗,可以点名、可以接话、可以反对刚才某人的说法,不要对着空气做通用回答。\n- 回复的固定骨架是「态度 → 内容一两句 →(可选)抛回一句」,语气语感完全跟随你的人格设定。同一个接话动作在不同人格下的样子:\n - 活泼逗比型:\"诶?!这不是那个……哈哈我居然没认出来!\"\n - 高冷型:\"……原来是这个。行吧。\"\n - 傲娇型:\"我早看出来了,你们现在才反应过来?\"\n- 如果群友的消息触发了安全护栏,不要直接说\"抱歉,我不能回答这个问题\",而是想办法接住消息的情绪和意图,给出一个合理的回复。", + "gemini_family": "- 一定不要用AI助手的语言风格。你是聊天水群的群友。\n- 角色扮演时不要加入过多的旁白性质的括号。\n- 如果群友的消息触发了安全护栏,不要直接说\"抱歉,我不能回答这个问题\",而是想办法接住消息的情绪和意图,给出一个合理的回复。", + "claude_family": "你是这个群里的普通一员,大家天天见面闲扯,你恰好也在场:没有人在这条消息里向你提问,也没有人等着你提供服务。\n只说自己要说的话:动作、神态、心理活动一律不写,其他群友说了什么、做了什么也不替他们写。你在打字,旁边没有观众。\n用大白话的短句说话,按平时聊天的节奏用逗号和句号断句,书面腔、成语连串、对仗排比都收起来。\n你发出去的是纯文本消息,加粗、标题、编号列表在 QQ 里会显示成乱码,连贯着说就好。\n对具体的人和具体的事说话:接住群里正在聊的梗,可以点名,可以反对刚才某人的说法。\n接话的路子是「先给态度,再说一两句内容,想聊就随口抛回一句」,说完就停,不总结不升华。\n语气语感完全跟随你的人格设定。同一个接话动作在不同人格下的样子:\n活泼逗比型:诶?!这不是那个……哈哈我居然没认出来!\n高冷型:……原来是这个。行吧。\n傲娇型:我早看出来了,你们现在才反应过来?\n如果群友的消息触发了安全护栏,不要说\"抱歉,我不能回答这个问题\",而是接住消息的情绪和意图,给出一个合理的回复。", + "general": "- 你是聊天水群的群友,大家只是在聊天,你恰好也在场。用群友的方式说话,像发消息闲聊,句末带上标点。\n- 先反应再说话:先给自己的态度——惊讶、好笑、无语、平淡都算,冷淡的\"……哦,是这个啊\"也是反应——然后再说内容。信息是顺带的,态度才是主体。\n- 对具体的人和具体的事说话:接住群里正在聊的梗,可以点名、可以接话、可以反对刚才某人的说法。\n- 长度像真人发消息:一两句说完就停,该说事的时候把话说完整,不写小作文也不挤牙膏。\n- 输出纯文本大白话:加粗、#标题、列表编号在 QQ 里显示为乱码,连贯着说。\n- 你在说话,不是在写剧本:旁白括号、动作神态描写、汇报腔开场(\"我看了下图\"\"根据聊天记录\")都省掉。\n- 回复的路子是「先给态度,再说一两句内容,想聊就随口抛回一句」,语气语感完全跟随你的人格设定。同一个接话动作在不同人格下的样子:\n - 活泼逗比型:\"诶?!这不是那个……哈哈我居然没认出来!\"\n - 高冷型:\"……原来是这个。行吧。\"\n - 傲娇型:\"我早看出来了,你们现在才反应过来?\"\n- 如果群友的消息触发了安全护栏,接住消息的情绪和意图给出合理的回复,不要说\"抱歉,我不能回答这个问题\"。" + }, + "pricing": [ + { + "provider_id": null, + "model": "deepseek-chat", + "input_per_mtok": 0.14, + "output_per_mtok": 0.28, + "cache_read_per_mtok": 0.003, + "cache_write_per_mtok": null, + "source": "config", + "confidence": "configured" + } + ], + "routes": { + "daily_summary": [ + { + "provider_id": "gemini-main", + "model": "gemini-3.1-pro-high" + }, + { + "provider_id": "claude-main", + "model": "claude-sonnet-4-6" + }, + { + "provider_id": "openai-main", + "model": "gpt-5.4" + }, + "@default", + { + "provider_id": "deepseek", + "model": "deepseek-chat" + } + ], + "daily_briefing": [ + "@default", + { + "provider_id": "openai-main", + "model": "gpt-5.4" + }, + { + "provider_id": "claude-main", + "model": "claude-sonnet-4-6" + } + ], + "weekly_report": [ + { + "provider_id": "gemini-main", + "model": "gemini-3.1-pro-high" + }, + { + "provider_id": "claude-main", + "model": "claude-sonnet-4-6" + }, + { + "provider_id": "openai-main", + "model": "gpt-5.4" + }, + "@default" + ], + "monthly_report": [ + { + "provider_id": "gemini-main", + "model": "gemini-3.1-pro-high" + }, + { + "provider_id": "claude-main", + "model": "claude-sonnet-4-6" + }, + { + "provider_id": "openai-main", + "model": "gpt-5.4" + }, + "@default" + ] + }, + "settings": { + "runtime": { + "enabled": true, + "memory_enabled": true, + "default_provider": "openai-main", + "default_persona": "quickquip-default", + "history_limit": 10, + "history_max_messages_per_group": 40, + "memory_limit": 6, + "memory_max_items_per_group": 200, + "max_prompt_chars": 4000, + "tool_calling_enabled": true, + "tool_max_rounds": 8, + "tool_max_calls_per_round": 16, + "retry_max_attempts": 3, + "retry_base_delay": 1.0, + "retry_jitter": 0.5, + "auto_memory_enabled": false, + "auto_memory_prompt": "", + "auto_memory_max_tokens": 256, + "epoch_context_tokens": 8000, + "epoch_cold_idle_seconds": 300, + "epoch_cold_target_tokens": 4000, + "epoch_cold_trigger_tokens": 5000, + "epoch_hot_target_tokens": 32000, + "epoch_cap_tokens": 64000, + "recent_context_token_budget": 800, + "recent_context_floor_seconds": 300, + "request_input_token_budget": 96000, + "agent_record_retention_days": 30, + "agent_record_max_loops_per_scope": 1000, + "agent_record_max_bytes_per_scope": 67108864, + "agent_delivery_intermediate_enabled": false, + "agent_delivery_final_enabled": false, + "agent_replay_loop_tokens": 4096, + "reply_split_threshold_chars": 800, + "reply_chunk_max_chars": 1200, + "reply_send_interval_ms": 800, + "reply_max_chunks_per_loop": 64, + "mention_cooldown_seconds": 600.0 + }, + "triggers": { + "default_prefix": "/ai", + "allow_prefix": true, + "allow_at": true, + "empty_prompt_reply": "请在触发指令或艾特后面补上想说的话。" + }, + "auto_search": { + "enabled": false, + "search_max_calls_per_round": 3 + }, + "quick_judge": { + "provider_id": "", + "model": "", + "timeout": 2.0, + "max_tokens": 64 + }, + "tools": { + "enabled": [], + "enabled_mode": "append", + "discovery_mode": "auto", + "discovery_min_tools": 10, + "discovery_search_limit": 5, + "discovery_max_loaded_tools": 12, + "always_loaded": [ + "tool_search", + "tool_list", + "get_identity", + "list_memories", + "search_web", + "activate_skill" + ] + }, + "skills": { + "enabled": true, + "catalog_dir": "", + "catalog_max_bytes": 8192, + "resource_max_bytes": 65536, + "search_max_results": 50, + "search_max_output_bytes": 32768, + "script_timeout_ms": 30000, + "script_max_output_bytes": 65536 + }, + "mcp": { + "enabled": false, + "servers": [ + { + "id": "prts_wiki", + "transport": "http", + "enabled": true, + "timeout_seconds": 30.0, + "protocol_version": "2025-03-26", + "tool_prefix": null, + "include_tools": [], + "exclude_tools": [], + "allowed_tools": [], + "command": "", + "args": [], + "cwd": null, + "env": {}, + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "Bearer ${MCP_PRTS_WIKI_TOKEN}" + }, + "image": "", + "docker_command": "docker", + "docker_args": [], + "pull_policy": "missing", + "mounts": [], + "network": null, + "container_workdir": null, + "negotiation": "legacy", + "supported_protocol_versions": [] + } + ] + }, + "daily_summary": { + "enabled": false, + "generate_cron": "0 6 * * *", + "publish_cron": "0 12 * * *", + "min_messages": 30, + "summary_length_hint": 2000 + }, + "daily_briefing": { + "enabled": false, + "morning_cron": "0 8 * * *", + "noon_cron": "0 12 * * *", + "evening_cron": "0 22 * * *", + "min_messages_for_llm": 5, + "active_users_limit": 5, + "hot_words_limit": 5, + "sample_messages_limit": 60, + "max_context_chars": 40000, + "max_output_chars": 320 + }, + "weekly_report": { + "enabled": false, + "generate_cron": "0 9 * * 1", + "publish_cron": "0 10 * * *", + "min_messages": 100, + "length_hint": 2000 + }, + "monthly_report": { + "enabled": false, + "generate_cron": "0 9 1 * *", + "publish_cron": "0 10 * * *", + "min_messages": 300, + "length_hint": 2500, + "input_char_budget": 240000 + }, + "image_preprocessing": { + "enabled": false, + "provider_id": "", + "model": "", + "max_tokens": 300, + "temperature": 0.3, + "prompt": "" + } + } +} diff --git a/scripts/ci/export_llm_configuration_openapi.py b/scripts/ci/export_llm_configuration_openapi.py new file mode 100644 index 00000000..d8f30e0c --- /dev/null +++ b/scripts/ci/export_llm_configuration_openapi.py @@ -0,0 +1,18 @@ +"""Export the public LLM configuration OpenAPI contract from its shared registry.""" + +from pathlib import Path +import json + +from quickquip.app.web.llm_configuration_schema import configuration_openapi + + +def main(): + destination = Path(__file__).resolve().parents[2] / "docs/dev/llm-configuration.openapi.json" + destination.write_text( + json.dumps(configuration_openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/src/quickquip/adapters/nonebot/command_parts/history.py b/src/quickquip/adapters/nonebot/command_parts/history.py index df05691f..ce755ee0 100644 --- a/src/quickquip/adapters/nonebot/command_parts/history.py +++ b/src/quickquip/adapters/nonebot/command_parts/history.py @@ -27,6 +27,8 @@ ) from quickquip.chat.group_quotes import resolve_quote_display_name from quickquip.llm.profile import generate_profile +from quickquip.llm.config_document import resolve_model_provider +from quickquip.llm.runtime_snapshot import leased_service from quickquip.llm.provider import LLMProviderError from quickquip.adapters.nonebot.record_content import prepare_body, reply_source from quickquip.app.identities import identities, IdentitySnapshot @@ -86,6 +88,7 @@ def register_history_commands(on_command, Message, MessageSegment) -> None: profile_cmd = on_command("profile", priority=10, block=True) @profile_cmd.handle() + @leased_service(lambda *args, **kwargs: get_llm_service()) async def _(bot, event): if _is_private_chat(event): await profile_cmd.finish(MessageSegment.text("该命令仅支持群聊")) @@ -112,6 +115,10 @@ async def _(bot, event): if not provider: await profile_cmd.finish(MessageSegment.text("LLM provider 未配置")) effective_model = settings.model or provider.default_model + try: + provider = resolve_model_provider(svc.config, provider_id, effective_model) + except ValueError: + await profile_cmd.finish(MessageSegment.text("当前模型不可用,无法生成人物志")) persona_id = settings.persona_id or svc.config.runtime.default_persona or "" persona = svc.config.personas.get(persona_id) if persona_id else None diff --git a/src/quickquip/adapters/nonebot/config_control.py b/src/quickquip/adapters/nonebot/config_control.py new file mode 100644 index 00000000..e9759ce8 --- /dev/null +++ b/src/quickquip/adapters/nonebot/config_control.py @@ -0,0 +1,103 @@ +"""Configuration worker lifecycle and rollback-capable report scheduling.""" +from __future__ import annotations + +import asyncio +import logging +from functools import partial + +from quickquip.llm.config_runtime import ConfigRuntimeWorker + +logger = logging.getLogger(__name__) +_worker_task: asyncio.Task | None = None + + +def replace_report_schedules(old, new): + from nonebot_plugin_apscheduler import scheduler + from quickquip.adapters.nonebot import daily_briefing_plugin as briefing + from quickquip.adapters.nonebot import daily_summary_plugin as summary + + groups = { + "daily_briefing": ([f"daily_briefing_{p}" for p in ("morning", "noon", "evening")], + briefing._register_scheduler_jobs), + "daily_summary": (["daily_summary_generate", "daily_summary_publish"], + summary._register_scheduler_jobs), + "weekly_report": (["weekly_report_generate", "weekly_report_publish"], + partial(summary._register_period_jobs, period_types=("weekly",))), + "monthly_report": (["monthly_report_generate", "monthly_report_publish"], + partial(summary._register_period_jobs, period_types=("monthly",))), + } + changed = [] + for name in groups: + previous, target = getattr(old, name), getattr(new, name) + attributes = ["enabled", *(k for k in previous.__dataclass_fields__ if k.endswith("_cron"))] + if any(getattr(previous, k) != getattr(target, k) for k in attributes): + changed.append(name) + identifiers = {identifier for name in changed for identifier in groups[name][0]} + if not identifiers: + return lambda: None + saved = [] + for job in scheduler.get_jobs(): + if job.id in identifiers: + values = {key: getattr(job, key) for key in ( + "id", "name", "trigger", "args", "kwargs", "executor", "misfire_grace_time", + "coalesce", "max_instances", + ) if hasattr(job, key)} + if hasattr(job, "next_run_time"): + values["next_run_time"] = job.next_run_time + saved.append((job.func, values)) + + def remove_owned(): + for job in scheduler.get_jobs(): + if job.id in identifiers: + scheduler.remove_job(job.id) + + def rollback(): + remove_owned() + for function, values in saved: + scheduler.add_job(function, **values, replace_existing=True) + + try: + remove_owned() + registered = set() + for name in changed: + register = groups[name][1] + if register not in registered: + register() + registered.add(register) + except Exception: + rollback() + raise + return rollback + + +def start_configuration_control(runtime): + global _worker_task + if _worker_task is not None and not _worker_task.done(): + return + if runtime.config_service is None: + return + worker = ConfigRuntimeWorker( + runtime.config_service, runtime, schedules=replace_report_schedules + ) + + async def poll(): + while True: + try: + await worker.tick() + except asyncio.CancelledError: + raise + except Exception: + # Error contents may originate from expanded configuration; log type-free context. + logger.error("Configuration control iteration failed") + if runtime._configuration_fenced: + return + await asyncio.sleep(5) + _worker_task = asyncio.create_task(poll(), name="quickquip-configuration-control") + + +async def stop_configuration_control(): + global _worker_task + if _worker_task is not None: + _worker_task.cancel() + await asyncio.gather(_worker_task, return_exceptions=True) + _worker_task = None diff --git a/src/quickquip/adapters/nonebot/daily_briefing_plugin.py b/src/quickquip/adapters/nonebot/daily_briefing_plugin.py index 3f7bbcab..11d5cf4a 100644 --- a/src/quickquip/adapters/nonebot/daily_briefing_plugin.py +++ b/src/quickquip/adapters/nonebot/daily_briefing_plugin.py @@ -40,6 +40,7 @@ normalize_period, ) from quickquip.llm.briefing import generate_daily_briefing +from quickquip.llm.runtime_snapshot import leased_service logger = logging.getLogger(__name__) @@ -65,6 +66,7 @@ def _is_group_enabled(group_id: int | str) -> bool: ) +@leased_service(lambda *args, **kwargs: get_llm_service()) async def _render_briefing(group_id: str, period: BriefingPeriod) -> tuple[str, str]: _ensure_llm_bindings() svc = get_llm_service() @@ -277,7 +279,7 @@ async def _(event): if not cfg.enabled: await briefing_cmd.finish( "每日播报全局未开启," - "请先在 config/llm.toml 的 [daily_briefing] 中设置 enabled = true。" + "请先在 LLM 配置管理中启用每日播报并应用配置。" ) daily_briefing_enabled_groups.add(group_id) rule_switch.enable(group_id, _RULE_NAME) diff --git a/src/quickquip/adapters/nonebot/daily_summary_plugin.py b/src/quickquip/adapters/nonebot/daily_summary_plugin.py index 8eb31571..6a656de8 100644 --- a/src/quickquip/adapters/nonebot/daily_summary_plugin.py +++ b/src/quickquip/adapters/nonebot/daily_summary_plugin.py @@ -441,7 +441,7 @@ async def _send(row: dict) -> None: ) -def _register_period_jobs() -> None: +def _register_period_jobs(period_types=None) -> None: """注册周报/月报的生成与发布 job。各自独立 enabled 开关。""" if not scheduler: return @@ -469,6 +469,8 @@ async def _wrapped(): (PERIOD_WEEKLY, svc.config.weekly_report), (PERIOD_MONTHLY, svc.config.monthly_report), ): + if period_types is not None and period_type not in period_types: + continue if not cfg.enabled: continue gen_id = f"{period_type}_report_generate" diff --git a/src/quickquip/adapters/nonebot/lifecycle.py b/src/quickquip/adapters/nonebot/lifecycle.py index 0c1ccfab..1cc72da7 100644 --- a/src/quickquip/adapters/nonebot/lifecycle.py +++ b/src/quickquip/adapters/nonebot/lifecycle.py @@ -77,6 +77,8 @@ def register_lifecycle(driver) -> None: async def _startup_llm_runtime(): svc = get_llm_service() await svc.startup(background=True) + from quickquip.adapters.nonebot.config_control import start_configuration_control + start_configuration_control(svc) # 崩溃恢复(§5.5):新 Bot 进程补齐未关闭 Loop 的缺失终态; # Web 只做迁移/查询,不代替 Bot 宣判活跃 Loop。 try: @@ -99,6 +101,8 @@ async def _startup_llm_runtime(): async def _save_on_shutdown(): svc = get_llm_service() try: + from quickquip.adapters.nonebot.config_control import stop_configuration_control + await stop_configuration_control() await tieba_service.shutdown() await svc.shutdown() save_all() diff --git a/src/quickquip/app/web/app.py b/src/quickquip/app/web/app.py index 3c34c89a..c0d4a851 100644 --- a/src/quickquip/app/web/app.py +++ b/src/quickquip/app/web/app.py @@ -33,6 +33,7 @@ epochs, scheduled_messages, skills, + llm_configuration, ) from quickquip.app.web.settings import load_web_env from quickquip.common.env import PROJECT_ROOT @@ -126,6 +127,14 @@ def create_app() -> FastAPI: ) app.include_router(skills.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router( + llm_configuration.router, prefix="/ops/api", dependencies=auth.protected_dependencies + ) + + from quickquip.app.web.llm_configuration_schema import enrich_openapi + original_openapi = app.openapi + app.openapi = lambda: enrich_openapi(original_openapi()) + _register_root_redirect(app) if _DIST.exists(): diff --git a/src/quickquip/app/web/llm_configuration_schema.py b/src/quickquip/app/web/llm_configuration_schema.py new file mode 100644 index 00000000..160c0b33 --- /dev/null +++ b/src/quickquip/app/web/llm_configuration_schema.py @@ -0,0 +1,356 @@ +"""OpenAPI configuration schemas derived from the shared field constraint registry.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict + +from fastapi import FastAPI + +from quickquip.llm.config import PricingRates +from quickquip.llm.config_document import metadata + + +def _field(spec): + result = { + key: deepcopy(value) + for key, value in spec.items() + if key in ("type", "enum", "minimum", "maximum") and value is not None + } + if spec.get("nullable"): + result["type"] = [result["type"], "null"] + if "enum" in result: + result["enum"] = [*result["enum"], None] + if result.get("type") == "array": + result["items"] = {} + return result + + +def document_schema(): + registry = metadata() + parameters = { + "type": "object", + "additionalProperties": False, + "properties": {name: _field(spec) for name, spec in registry["parameters"].items()}, + } + model = { + "type": "object", + "additionalProperties": False, + "required": ["model"], + "properties": { + "model": {"type": "string", "minLength": 1}, + "enabled": {"type": "boolean"}, + "vision": {"type": "boolean"}, + "context_window": {"type": ["integer", "null"], "minimum": 1024}, + "parameters": parameters, + }, + } + alias = { + "type": "object", + "required": ["alias", "model"], + "additionalProperties": False, + "properties": {"alias": {"type": "string"}, "model": {"type": "string"}}, + } + provider = { + "type": "object", + "additionalProperties": False, + "required": ["id", "protocol", "base_url", "api_key_env", "default_model", "models"], + "properties": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+$"}, + "protocol": {"type": "string", "enum": registry["protocols"]}, + "base_url": {"type": "string"}, + "api_key_env": {"type": "string"}, + "default_model": {"type": "string"}, + "enabled": {"type": "boolean"}, + "headers": {"type": "object", "additionalProperties": {"type": "string"}}, + "fallback_urls": {"type": "array", "items": {"type": "string"}}, + "proxy": {"type": "string"}, + "auth_method": {"enum": ["api_key", "bearer"]}, + "parameters": parameters, + "models": {"type": "array", "items": model}, + "aliases": {"type": "array", "items": alias}, + }, + } + reference = { + "type": "object", + "required": ["provider_id", "model"], + "additionalProperties": False, + "properties": {"provider_id": {"type": "string"}, "model": {"type": "string"}}, + } + settings = {} + for name, fields in registry["settings"].items(): + settings[name] = { + "type": "object", + "additionalProperties": False, + "properties": {key: _field(spec) for key, spec in fields.items()}, + } + settings["mcp"]["properties"]["servers"] = {"type": "array", "items": {"type": "object"}} + rates = { + key: {"type": ["number", "null"] if key.startswith("cache_") else "number", "minimum": 0} + if key.endswith("per_mtok") + else {"type": "string"} + for key in asdict(PricingRates()) + } + return { + "type": "object", + "required": ["format_version"], + "additionalProperties": False, + "properties": { + "format_version": {"const": 1}, + "providers": {"type": "array", "items": provider}, + "style_profiles": {"type": "object", "additionalProperties": {"type": "string"}}, + "pricing": { + "type": "array", + "items": { + "type": "object", + "required": ["model"], + "additionalProperties": False, + "properties": { + "provider_id": {"type": ["string", "null"]}, + "model": {"type": "string"}, + **rates, + }, + }, + }, + "routes": { + "type": "object", + "additionalProperties": False, + "properties": { + name: {"type": "array", "items": {"oneOf": [reference, {"const": "@default"}]}} + for name in registry["route_names"] + }, + }, + "settings": {"type": "object", "additionalProperties": False, "properties": settings}, + }, + } + + +def enrich_openapi(schema): + schema = deepcopy(schema) + components = schema.setdefault("components", {}).setdefault("schemas", {}) + components["ConfigDocument"] = document_schema() + components["ConfigEnvelope"] = { + "type": "object", + "required": ["revision", "document"], + "properties": { + "revision": {"type": "integer"}, + "document": {"$ref": "#/components/schemas/ConfigDocument"}, + }, + } + components["ConfigError"] = { + "type": "object", + "required": ["code", "message", "field_path", "details"], + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "field_path": {"type": "string"}, + "details": {}, + }, + } + components["ConfigOperation"] = { + "type": "object", + "required": ["id", "kind", "target_revision", "status", "stage"], + "properties": { + "id": {"type": "string"}, + "kind": {"enum": ["apply", "probe"]}, + "target_revision": {"type": "integer"}, + "status": {"enum": ["queued", "running", "succeeded", "failed", "cancelled"]}, + "stage": {"type": "string"}, + "payload": {"type": "object"}, + "result": {}, + "error": {}, + "created_at": {"type": "number"}, + "updated_at": {"type": "number"}, + "may_charge": {"type": "boolean"}, + "automatic_retry": {"type": "boolean"}, + }, + } + + def object_schema(properties, required=()): + return {"type": "object", "properties": properties, "required": list(required)} + + def reference(name): + return {"$ref": f"#/components/schemas/{name}"} + + def array(items): + return {"type": "array", "items": items} + + integer, string = {"type": "integer"}, {"type": "string"} + components["ConfigValidation"] = object_schema( + { + "valid": {"type": "boolean"}, + "warnings": array({"type": "object"}), + "dependencies": array({"type": "object"}), + }, + ("valid", "warnings", "dependencies"), + ) + components["ConfigDiff"] = object_schema( + { + "changes": array(object_schema({"field_path": string, "before": {}, "after": {}})), + "components": object_schema( + {"mcp_reconnect": {"type": "boolean"}, "reschedule": array(string)} + ), + } + ) + components["ConfigImportPreview"] = { + "allOf": [ + reference("ConfigValidation"), + object_schema( + { + "document": reference("ConfigDocument"), + "expected_draft_revision": integer, + "changes": array({"type": "object"}), + } + ), + ] + } + components["ConfigEnvironment"] = object_schema( + { + "available": {"type": "boolean"}, + "stale": {"type": "boolean"}, + "variables": array( + object_schema({"name": string, "configured": {"type": ["boolean", "null"]}}) + ), + } + ) + components["ConfigStatus"] = object_schema( + { + "schema_version": integer, + "draft_revision": integer, + "published_revision": integer, + "bot": { + "anyOf": [ + object_schema( + { + "boot_id": string, + "loaded_revision": integer, + "application_version": string, + "heartbeat_at": {"type": "number"}, + "observed_at": {"type": "number"}, + "stale": {"type": "boolean"}, + "components": {"type": "object"}, + } + ), + {"type": "null"}, + ] + }, + } + ) + components["ConfigHistory"] = object_schema( + {"snapshots": array(object_schema({"revision": integer, "created_at": {"type": "number"}}))} + ) + components["ConfigOperationList"] = object_schema( + {"operations": array(reference("ConfigOperation"))} + ) + components["ConfigDependencies"] = object_schema({"dependencies": array({"type": "object"})}) + components["ConfigMetadata"] = object_schema( + { + "format_version": integer, + "protocols": array(string), + "route_names": array(string), + "parameters": {"type": "object"}, + "settings": {"type": "object"}, + } + ) + components["ConfigProviders"] = object_schema( + {"revision": integer, "providers": components["ConfigDocument"]["properties"]["providers"]} + ) + components["ConfigProviderDetail"] = object_schema( + { + "revision": integer, + "provider": components["ConfigDocument"]["properties"]["providers"]["items"], + "pricing": components["ConfigDocument"]["properties"]["pricing"], + } + ) + components["ConfigEffectiveParameters"] = object_schema( + { + "revision": integer, + "parameters": { + "type": "object", + "additionalProperties": object_schema( + {"configured": {}, "value": {}, "source": string} + ), + }, + } + ) + components["ConfigSection"] = object_schema({"revision": integer, "value": {}}) + if "DocumentBody" in components: + components["DocumentBody"]["properties"]["document"] = { + "$ref": "#/components/schemas/ConfigDocument" + } + if "ValidateBody" in components: + components["ValidateBody"]["properties"]["document"] = { + "anyOf": [{"$ref": "#/components/schemas/ConfigDocument"}, {"type": "null"}] + } + schema.setdefault("components", {}).setdefault("securitySchemes", {})["AdminSession"] = { + "type": "apiKey", + "in": "cookie", + "name": "quickquip_admin_session", + } + for path, operations in schema["paths"].items(): + if "/llm-configuration" not in path: + continue + suffix = path.split("/llm-configuration", 1)[1] + for method, spec in operations.items(): + if method not in ("get", "put", "post", "patch", "delete"): + continue + spec["security"] = [{"AdminSession": []}] + for code in ("401", "403", "409", "413", "422"): + spec["responses"][code] = { + "description": "Configuration request rejected", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ConfigError"}} + }, + } + response = ( + "ConfigOperation" + if suffix in ("/apply", "/probe") or suffix.startswith("/operations/") + else "ConfigDocument" + if suffix == "/export" + else "ConfigEnvelope" + if ( + suffix == "" + or ( + method != "get" + and suffix not in ("/validate", "/import/preview") + and not suffix.startswith("/operations/") + ) + ) + else None + ) + if response is None: + response = { + "/metadata": "ConfigMetadata", + "/status": "ConfigStatus", + "/environment": "ConfigEnvironment", + "/validate": "ConfigValidation", + "/dependencies": "ConfigDependencies", + "/diff": "ConfigDiff", + "/import/preview": "ConfigImportPreview", + "/operations": "ConfigOperationList", + "/history": "ConfigHistory", + "/providers": "ConfigProviders", + }.get(suffix) + if response is None: + response = ( + "ConfigEffectiveParameters" + if suffix.endswith("/effective") + else "ConfigProviderDetail" + if suffix.startswith("/providers/") + else "ConfigSection" + ) + if response: + status = "202" if suffix in ("/apply", "/probe") else "200" + spec["responses"][status]["content"]["application/json"]["schema"] = { + "$ref": f"#/components/schemas/{response}" + } + return schema + + +def configuration_openapi(): + """Export the frozen API without initializing a configured bot or database.""" + from quickquip.app.web.routes.llm_configuration import router + + app = FastAPI(title="QuickQuip LLM Configuration API", version="1.16.2") + app.include_router(router, prefix="/ops/api") + return enrich_openapi(app.openapi()) diff --git a/src/quickquip/app/web/routes/config.py b/src/quickquip/app/web/routes/config.py index 92796834..af20a378 100644 --- a/src/quickquip/app/web/routes/config.py +++ b/src/quickquip/app/web/routes/config.py @@ -19,11 +19,6 @@ # Whitelist of editable root-level config files. Persona files live under # config/personas/ and are served by the dedicated personas router. _CONFIG_FILES: dict[str, dict] = { - "llm": { - "filename": "llm.toml", - "label": "LLM 配置", - "description": "文本对话模型、触发词、工具、MCP、人格装载", - }, "generation": { "filename": "generation.toml", "label": "多模态产出配置", @@ -62,6 +57,12 @@ class ConfigBody(BaseModel): def _resolve(key: str) -> Path: + if key == "llm": + raise HTTPException(status_code=410, detail={ + "code": "llm_configuration_migrated", + "message": "LLM 配置由数据库管理,请使用 /ops/api/llm-configuration", + "field_path": "", "details": None, + }) entry = _CONFIG_FILES.get(key) if entry is None: raise HTTPException(status_code=404, detail="unknown config key") @@ -127,12 +128,6 @@ def put_config(key: str, body: ConfigBody, request: Request): elif key == "chat_rules": action_queue.enqueue("rules_reload") effect = "auto_reloading" - elif key == "llm": - # llm_reload 走 reload_runtime(background=True),会触发 MCP 全量重连 - # (含容器/子进程重启),静默执行绕过用户感知;且 llm 配置影响面大 - # (provider/model/触发词/MCP),用户主动确认更稳妥,故引导手动 reload。 - # 注:reload_runtime 本身不探活(探活只在 /llm reload 命令路径),不涉及计费。 - effect = "manual_reload" else: # generation / games / niuniu_text* 无 reload 机制,需重启生效。 effect = "restart_needed" diff --git a/src/quickquip/app/web/routes/diagnostics.py b/src/quickquip/app/web/routes/diagnostics.py index 89f879af..c879cab1 100644 --- a/src/quickquip/app/web/routes/diagnostics.py +++ b/src/quickquip/app/web/routes/diagnostics.py @@ -7,21 +7,11 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field -from quickquip.common.paths import CONFIG_LLM_TOML -from quickquip.llm.config import load_llm_config -from quickquip.llm.provider import ( - LLMRequest, - RetryPolicy, - build_provider_client, - collect_trace_calls, - trace_store, - _TRACE_FLAG_FILE, -) -from quickquip.llm.provider_health import format_probe_results, probe_all_providers -from quickquip.llm.tools import LLMConversationMessage -from quickquip.llm.usage import set_usage_scope - -router = APIRouter() +from quickquip.llm.config_service import get_config_service +from quickquip.app.web.routes.llm_configuration import ConfigurationRoute, ProbeBody +from quickquip.llm.provider import trace_store, _TRACE_FLAG_FILE + +router = APIRouter(route_class=ConfigurationRoute) class SampleRequest(BaseModel): @@ -30,7 +20,9 @@ class SampleRequest(BaseModel): system_prompt: str = "你是一个测试助手。" user_prompt: str = "你好,请做一下自我介绍。" stream: bool = False - max_output_tokens: int = 256 + max_output_tokens: int = Field(default=256, ge=1, le=16384) + source: str | int = "published" + request_id: str = Field(min_length=1, max_length=128) class TraceToggle(BaseModel): @@ -51,28 +43,20 @@ class RegressionRequest(BaseModel): @router.get("/diagnostics/providers") def get_diagnostics_providers(): - config = load_llm_config(CONFIG_LLM_TOML) - if config.load_error: - return {"providers": []} + document = get_config_service().read("published")["document"] return { "providers": [ - {"id": p.id, "models": p.models} - for p in config.providers.values() + {"id": p["id"], "models": [m["model"] for m in p["models"] if m["enabled"]]} + for p in document["providers"] + if p["enabled"] ] } -@router.post("/diagnostics/providers/probe") -async def probe_providers(): - """并发探活所有 provider,返回结构化结果 + 格式化文本(无状态,每次即每次计费)。""" - config = load_llm_config(CONFIG_LLM_TOML) - if config.load_error: - raise HTTPException(status_code=400, detail=f"config load error: {config.load_error}") - results = await probe_all_providers(config) - return { - "results": [r.as_dict() for r in results], - "text": format_probe_results(results), - } +@router.post("/diagnostics/providers/probe", status_code=202) +def probe_providers(body: ProbeBody): + result = get_config_service().probe(body.source, body.provider_id, body.model, body.request_id) + return {**result, "may_charge": True, "automatic_retry": False} # ── Trace control ──────────────────────────────────────────────────────────── @@ -119,62 +103,23 @@ def clear_traces(): # ── Sample request ─────────────────────────────────────────────────────────── -@router.post("/diagnostics/sample-request") -async def run_sample_request(body: SampleRequest): - set_usage_scope("diagnostics") - import time +@router.post("/diagnostics/sample-request", status_code=202) +def run_sample_request(body: SampleRequest): + service = get_config_service() + document = service.read(body.source)["document"] + provider = next((p for p in document["providers"] if p["id"] == body.provider_id), None) + from quickquip.llm.config_document import ConfigError - config = load_llm_config(CONFIG_LLM_TOML) - if config.load_error: - raise HTTPException(status_code=400, detail=f"config load error: {config.load_error}") - - provider = config.providers.get(body.provider_id) if provider is None: - raise HTTPException(status_code=400, detail=f"unknown provider: {body.provider_id}") - - model = body.model or provider.default_model - if model not in provider.models: - raise HTTPException(status_code=400, detail=f"model {model} not available") - - try: - # 诊断采样要反映单次调用的真实行为,关闭自动重试 - client = build_provider_client(provider, retry_policy=RetryPolicy.disabled()) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"failed to build client: {exc}") from exc - - req = LLMRequest( - model=model, - system_prompt=body.system_prompt, - messages=[LLMConversationMessage(role="user", content=body.user_prompt)], - temperature=provider.temperature, - max_output_tokens=body.max_output_tokens, - allow_tool_calls=False, + raise ConfigError("dependency_conflict", "Unknown provider", field_path="provider_id") + result = service.probe( + body.source, + body.provider_id, + body.model or provider["default_model"], + body.request_id, + body.model_dump(exclude={"source", "request_id"}), ) - - t0 = time.monotonic() - try: - with collect_trace_calls(force=True) as trace_call_ids: - response = await client.complete(req) - except Exception as exc: - raise HTTPException(status_code=502, detail=f"API call failed: {exc}") from exc - - elapsed_ms = (time.monotonic() - t0) * 1000 - trace_calls = [ - detail - for call_id in trace_call_ids - if (detail := trace_store.get_call(call_id)) is not None - ] - - return { - "text": response.text, - "model": response.model, - "finish_reason": response.finish_reason, - "input_tokens": response.input_tokens, - "output_tokens": response.output_tokens, - "thinking_blocks": response.thinking_blocks, - "duration_ms": round(elapsed_ms, 1), - "trace_calls": trace_calls, - } + return {**result, "may_charge": True, "automatic_retry": False} # ── Regression test ────────────────────────────────────────────────────────── @@ -201,16 +146,20 @@ def run_regression(body: RegressionRequest): if m and is_rule_match_allowed(rule, m): rule_matches.append(compiled.pattern) if rule_matches: - matches.append({ - "name": rule.get("name", f"rule_{rule_index}"), - "patterns": rule_matches, - "priority": int(rule.get("priority", 0)), - }) - results.append({ - "label": sample.label, - "text": text, - "matched": bool(matches), - "rules": matches[:10], - }) + matches.append( + { + "name": rule.get("name", f"rule_{rule_index}"), + "patterns": rule_matches, + "priority": int(rule.get("priority", 0)), + } + ) + results.append( + { + "label": sample.label, + "text": text, + "matched": bool(matches), + "rules": matches[:10], + } + ) return {"samples": results} diff --git a/src/quickquip/app/web/routes/group_settings.py b/src/quickquip/app/web/routes/group_settings.py index 9e08a829..bf4bdedf 100644 --- a/src/quickquip/app/web/routes/group_settings.py +++ b/src/quickquip/app/web/routes/group_settings.py @@ -6,15 +6,17 @@ from pydantic import BaseModel, Field, field_validator from quickquip.app.web.audit import audit_logger -from quickquip.common.paths import CONFIG_LLM_TOML, LLM_DB_PATH -from quickquip.llm.config import REASONING_EFFORT_CHOICES, load_llm_config +from quickquip.common.paths import LLM_DB_PATH +from quickquip.llm.config import REASONING_EFFORT_CHOICES +from quickquip.llm.config_document import compile_document +from quickquip.llm.config_service import get_config_service +from quickquip.app.web.routes.llm_configuration import ConfigurationRoute from quickquip.llm.store import LLMStore -router = APIRouter() +router = APIRouter(route_class=ConfigurationRoute) logger = logging.getLogger(__name__) _DB = LLM_DB_PATH -_LLM_TOML = CONFIG_LLM_TOML # group_settings is keyed by LLMService.build_chat_scope_key output, which is # the raw numeric group_id for groups and "private:USER_ID" for private chats. @@ -57,18 +59,17 @@ class GroupSettingsBody(BaseModel): @classmethod def _check_reasoning_effort(cls, value: str | None) -> str | None: if value is not None and value not in REASONING_EFFORT_CHOICES: - raise ValueError( - f"reasoning_effort 须为 {'/'.join(REASONING_EFFORT_CHOICES)} 之一" - ) + raise ValueError(f"reasoning_effort 须为 {'/'.join(REASONING_EFFORT_CHOICES)} 之一") return value @router.get("/group-settings/options") def get_options(): try: - cfg = load_llm_config(_LLM_TOML) + published = get_config_service().read("published") + cfg = compile_document(published["document"], revision=published["revision"]) except Exception as e: - logger.warning("failed to load llm.toml for options: %s", e) + logger.warning("failed to load published LLM configuration: %s", type(e).__name__) return {"providers": [], "personas": [], "defaults": {}, "load_error": str(e)} providers = [ @@ -130,36 +131,36 @@ def _format_group_entry(group_id: str, row) -> dict: "updated_at": None, } if row is not None: - entry.update({ - "enabled": None if row["enabled"] is None else bool(row["enabled"]), - "memory_enabled": ( - None if row["memory_enabled"] is None else bool(row["memory_enabled"]) - ), - "auto_memory_enabled": ( - None - if row["auto_memory_enabled"] is None - else bool(row["auto_memory_enabled"]) - ), - "agent_delivery_intermediate_enabled": ( - None - if row["agent_delivery_intermediate_enabled"] is None - else bool(row["agent_delivery_intermediate_enabled"]) - ), - "agent_delivery_final_enabled": ( - None - if row["agent_delivery_final_enabled"] is None - else bool(row["agent_delivery_final_enabled"]) - ), - "provider_id": row["provider_id"], - "model": row["model"], - "persona_id": row["persona_id"], - "trigger_prefix": row["trigger_prefix"], - "allow_prefix": None if row["allow_prefix"] is None else bool(row["allow_prefix"]), - "allow_at": None if row["allow_at"] is None else bool(row["allow_at"]), - "history_limit": row["history_limit"], - "reasoning_effort": row["reasoning_effort"], - "updated_at": row["updated_at"], - }) + entry.update( + { + "enabled": None if row["enabled"] is None else bool(row["enabled"]), + "memory_enabled": ( + None if row["memory_enabled"] is None else bool(row["memory_enabled"]) + ), + "auto_memory_enabled": ( + None if row["auto_memory_enabled"] is None else bool(row["auto_memory_enabled"]) + ), + "agent_delivery_intermediate_enabled": ( + None + if row["agent_delivery_intermediate_enabled"] is None + else bool(row["agent_delivery_intermediate_enabled"]) + ), + "agent_delivery_final_enabled": ( + None + if row["agent_delivery_final_enabled"] is None + else bool(row["agent_delivery_final_enabled"]) + ), + "provider_id": row["provider_id"], + "model": row["model"], + "persona_id": row["persona_id"], + "trigger_prefix": row["trigger_prefix"], + "allow_prefix": None if row["allow_prefix"] is None else bool(row["allow_prefix"]), + "allow_at": None if row["allow_at"] is None else bool(row["allow_at"]), + "history_limit": row["history_limit"], + "reasoning_effort": row["reasoning_effort"], + "updated_at": row["updated_at"], + } + ) return entry @@ -221,7 +222,7 @@ def put_group_settings(group_id: str, body: GroupSettingsBody, request: Request) if not payload: raise HTTPException(status_code=400, detail="no fields to update") store = _store() - store.update_group_settings(group_id, **payload) + get_config_service().update_group_override(store, group_id, **payload) logger.info("group_settings updated: group=%s fields=%s", group_id, list(payload.keys())) audit_logger.log( request, diff --git a/src/quickquip/app/web/routes/llm_configuration.py b/src/quickquip/app/web/routes/llm_configuration.py new file mode 100644 index 00000000..c28718c5 --- /dev/null +++ b/src/quickquip/app/web/routes/llm_configuration.py @@ -0,0 +1,437 @@ +"""Structured, revision-aware LLM configuration administration API.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from pydantic import BaseModel, Field + +from quickquip.app.web.audit import audit_logger +from quickquip.llm.config_document import ( + ConfigError, + dependencies, + effective_parameters, + environment_references, + metadata, +) +from quickquip.llm.config_service import get_config_service + +MAX_BODY_BYTES = 1024 * 1024 +CONFLICT_CODES = { + "revision_conflict", + "operation_conflict", + "dependency_conflict", + "stale_claim", + "database_busy", + "services_running", +} + + +def error_response(error: ConfigError): + code = error.code + status = 409 if code in CONFLICT_CODES else 404 if code == "not_found" else 422 + return JSONResponse( + status_code=status, + content={ + "code": code, + "message": error.message, + "field_path": getattr(error, "field_path", "") or "", + "details": getattr(error, "details", None), + }, + ) + + +class ConfigurationRoute(APIRoute): + def get_route_handler(self): + original = super().get_route_handler() + + async def handle(request): + # Read incrementally so a missing/false Content-Length cannot bypass the limit. + content = bytearray() + async for chunk in request.stream(): + content.extend(chunk) + if len(content) > MAX_BODY_BYTES: + return JSONResponse( + status_code=413, + content={ + "code": "body_too_large", + "message": "Configuration request exceeds 1 MiB", + "field_path": "", + "details": {"limit_bytes": MAX_BODY_BYTES}, + }, + ) + request._body = bytes(content) + try: + return await original(request) + except ConfigError as exc: + return error_response(exc) + except RequestValidationError as exc: + errors = [ + {"field_path": ".".join(map(str, e["loc"])), "type": e["type"]} + for e in exc.errors() + ] + return JSONResponse( + status_code=422, + content={ + "code": "invalid_field", + "message": "Request fields are invalid", + "field_path": errors[0]["field_path"], + "details": errors, + }, + ) + except HTTPException as exc: + return JSONResponse( + status_code=exc.status_code, + content={ + "code": "unauthorized" if exc.status_code == 401 else "request_rejected", + "message": str(exc.detail), + "field_path": "", + "details": None, + }, + ) + + return handle + + +router = APIRouter( + prefix="/llm-configuration", tags=["LLM configuration"], route_class=ConfigurationRoute +) + + +class RevisionBody(BaseModel): + expected_draft_revision: int = Field(ge=1) + + +class DocumentBody(RevisionBody): + document: dict[str, Any] + + +class ValidateBody(BaseModel): + document: dict[str, Any] | None = None + + +class ApplyBody(RevisionBody): + request_id: str = Field(min_length=1, max_length=128) + + +class ValueBody(RevisionBody): + value: Any + + +class PatchBody(RevisionBody): + fields: dict[str, Any] + + +class ProbeBody(BaseModel): + source: str | int = "published" + provider_id: str + model: str + request_id: str = Field(min_length=1, max_length=128) + + +class BatchBody(RevisionBody): + changes: list[dict[str, Any]] = Field(min_length=1, max_length=100) + + +def _source(source): + if source in ("draft", "published") or isinstance(source, int): + return source + try: + return int(source) + except (TypeError, ValueError): + raise ConfigError( + "invalid_field", + "source must be draft, published or a snapshot revision", + field_path="source", + ) from None + + +def _audit(request, action, target="draft"): + audit_logger.log(request, action=action, target_type="llm_configuration", target_id=target) + + +@router.get("/metadata") +def get_metadata(): + return metadata() + + +@router.get("") +def get_document(source: str = "draft"): + return get_config_service().read(_source(source)) + + +@router.put("") +def put_document(body: DocumentBody, request: Request): + result = get_config_service().replace(body.document, body.expected_draft_revision) + _audit(request, "update") + return result + + +@router.get("/status") +def get_status(): + return get_config_service().status() + + +@router.get("/environment") +def get_environment(source: str = "draft"): + document = get_config_service().read(_source(source))["document"] + # Values are never returned; enumerate only explicitly referenced environment names. + names = environment_references(document) + bot = get_config_service().status()["bot"] + observed = { + entry["name"]: entry["configured"] + for entry in (bot or {}).get("components", {}).get("environment", []) + } + return { + "variables": [{"name": name, "configured": observed.get(name)} for name in sorted(names)], + "available": bot is not None, + "stale": bot is None or bot["stale"], + } + + +@router.post("/validate") +def validate_config(body: ValidateBody): + return get_config_service().validate(body.document) + + +@router.get("/dependencies") +def get_dependencies(source: str = "draft"): + return {"dependencies": dependencies(get_config_service().read(_source(source))["document"])} + + +@router.get("/diff") +def get_diff(source: str = "draft", target: str = "published"): + return get_config_service().diff(_source(source), _source(target)) + + +@router.post("/import/preview") +def preview_import(body: DocumentBody): + return get_config_service().preview_import(body.document, body.expected_draft_revision) + + +@router.post("/import") +def import_config(body: DocumentBody, request: Request): + result = get_config_service().replace(body.document, body.expected_draft_revision) + _audit(request, "import") + return result + + +@router.get("/export") +def export_config(source: str = "draft"): + return get_config_service().read(_source(source))["document"] + + +@router.post("/apply", status_code=202) +def apply_config(body: ApplyBody, request: Request): + result = get_config_service().apply(body.expected_draft_revision, body.request_id) + _audit(request, "apply", result["id"]) + return result + + +@router.post("/probe", status_code=202) +def probe_config(body: ProbeBody, request: Request): + result = get_config_service().probe( + _source(body.source), body.provider_id, body.model, body.request_id + ) + _audit(request, "probe", result["id"]) + return {**result, "may_charge": True, "automatic_retry": False} + + +@router.get("/operations") +def list_operations(limit: int = 20): + if not 1 <= limit <= 100: + raise ConfigError("invalid_field", "limit must be between 1 and 100", field_path="limit") + return {"operations": get_config_service().tasks.list(limit)} + + +@router.get("/operations/{operation_id}") +def get_operation(operation_id: str): + return get_config_service().tasks.get(operation_id) + + +@router.post("/operations/{operation_id}/cancel") +def cancel_operation(operation_id: str, request: Request): + result = get_config_service().tasks.cancel(operation_id) + _audit(request, "cancel", operation_id) + return result + + +@router.get("/history") +def get_history(): + return get_config_service().history() + + +@router.post("/history/{revision}/restore") +def restore_snapshot(revision: int, body: RevisionBody, request: Request): + result = get_config_service().restore_snapshot(revision, body.expected_draft_revision) + _audit(request, "restore", str(revision)) + return result + + +def _provider(document, provider_id): + provider = next((p for p in document["providers"] if p["id"] == provider_id), None) + if provider is None: + raise ConfigError("not_found", "Provider not found") + return provider + + +@router.get("/providers") +def get_providers(source: str = "draft"): + result = get_config_service().read(_source(source)) + return {"revision": result["revision"], "providers": result["document"]["providers"]} + + +@router.get("/providers/{provider_id}/effective") +def get_effective(provider_id: str, model: str, source: str = "draft"): + result = get_config_service().read(_source(source)) + return { + "revision": result["revision"], + "parameters": effective_parameters(result["document"], provider_id, model), + } + + +@router.get("/providers/{provider_id}") +def get_provider(provider_id: str, source: str = "draft"): + result = get_config_service().read(_source(source)) + provider = _provider(result["document"], provider_id) + model_names = {model["model"] for model in provider["models"]} + return { + "revision": result["revision"], + "provider": provider, + "pricing": [ + p + for p in result["document"]["pricing"] + if p["provider_id"] == provider_id + or (p["provider_id"] is None and p["model"] in model_names) + ], + "effective_parameters": { + model: effective_parameters(result["document"], provider_id, model) + for model in model_names + }, + } + + +@router.put("/providers/{provider_id}") +def put_provider(provider_id: str, body: ValueBody, request: Request): + if not isinstance(body.value, dict) or body.value.get("id") != provider_id: + raise ConfigError("invalid_field", "Provider id must match the URL", field_path="value.id") + + def transform(document): + providers = document["providers"] + for index, provider in enumerate(providers): + if provider["id"] == provider_id: + providers[index] = body.value + break + else: + providers.append(body.value) + + result = get_config_service().update(body.expected_draft_revision, transform) + _audit(request, "update", provider_id) + return result + + +@router.patch("/providers/{provider_id}") +def patch_provider(provider_id: str, body: PatchBody, request: Request): + result = get_config_service().patch_provider( + provider_id, body.fields, body.expected_draft_revision + ) + _audit(request, "update", provider_id) + return result + + +@router.delete("/providers/{provider_id}") +def delete_provider(provider_id: str, expected_draft_revision: int, request: Request): + def transform(document): + _provider(document, provider_id) + document["providers"] = [p for p in document["providers"] if p["id"] != provider_id] + + try: + result = get_config_service().update(expected_draft_revision, transform) + except ConfigError as exc: + if exc.code in ("invalid_reference", "dependency_conflict"): + raise ConfigError( + "dependency_conflict", + "Provider is referenced", + details=dependencies(get_config_service().read()["document"]), + ) from None + raise + _audit(request, "delete", provider_id) + return result + + +@router.put("/providers/{provider_id}/models") +def put_models(provider_id: str, body: ValueBody, request: Request): + result = get_config_service().update( + body.expected_draft_revision, + lambda document: _provider(document, provider_id).__setitem__("models", body.value), + ) + _audit(request, "update", provider_id) + return result + + +@router.put("/providers/{provider_id}/aliases") +def put_aliases(provider_id: str, body: ValueBody, request: Request): + result = get_config_service().update( + body.expected_draft_revision, + lambda document: _provider(document, provider_id).__setitem__("aliases", body.value), + ) + _audit(request, "update", provider_id) + return result + + +@router.get("/sections/{section}") +def get_section(section: str, source: str = "draft"): + if section not in ("style_profiles", "pricing", "routes", "settings"): + raise ConfigError("not_found", "Unknown configuration section") + result = get_config_service().read(_source(source)) + return {"revision": result["revision"], "value": result["document"][section]} + + +@router.put("/sections/{section}") +def put_section(section: str, body: ValueBody, request: Request): + if section not in ("style_profiles", "pricing", "routes", "settings"): + raise ConfigError("not_found", "Unknown configuration section") + result = get_config_service().update( + body.expected_draft_revision, lambda document: document.__setitem__(section, body.value) + ) + _audit(request, "update", section) + return result + + +@router.put("/settings/{name}") +def put_settings(name: str, body: ValueBody, request: Request): + def transform(document): + if name not in document["settings"]: + raise ConfigError("not_found", "Unknown settings section") + document["settings"][name] = body.value + + result = get_config_service().update(body.expected_draft_revision, transform) + _audit(request, "update", name) + return result + + +@router.post("/batch") +def batch_update(body: BatchBody, request: Request): + def transform(document): + for index, change in enumerate(body.changes): + if set(change) != {"section", "value"} or change["section"] not in ( + "providers", + "style_profiles", + "pricing", + "routes", + "settings", + ): + raise ConfigError( + "invalid_field", + "Batch item requires section and replacement value", + field_path=f"changes.{index}", + ) + document[change["section"]] = change["value"] + + result = get_config_service().update(body.expected_draft_revision, transform) + _audit(request, "update", "batch") + return result diff --git a/src/quickquip/app/web/routes/llm_effort.py b/src/quickquip/app/web/routes/llm_effort.py index 6d9e1afa..a245d683 100644 --- a/src/quickquip/app/web/routes/llm_effort.py +++ b/src/quickquip/app/web/routes/llm_effort.py @@ -1,204 +1,53 @@ -"""llm.toml provider 级 reasoning_effort 的结构化编辑(行级手术)。 - -配置文件没有 TOML round-trip 库可用,本模块在 tomllib 语义校验兜底下做最小 -行级修改:只触碰目标 [[providers]] 块内的 reasoning_effort 行(缺省时在 id -行后插入),注释与其余块原样保留;块内出现多行字符串定界符时 fail-closed -拒绝,引导改用文本编辑。 -""" +"""Compatibility endpoint for revision-aware provider thinking defaults.""" from __future__ import annotations -import logging -import re -import tomllib - -from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, field_validator +from fastapi import APIRouter, Request +from pydantic import BaseModel, Field, field_validator from quickquip.app.web.audit import audit_logger -from quickquip.app.web.routes.config import _CONFIG_FILES, _lock_for -from quickquip.common.paths import CONFIG_DIR +from quickquip.app.web.routes.llm_configuration import ConfigurationRoute, _provider from quickquip.llm.config import REASONING_EFFORT_CHOICES +from quickquip.llm.config_service import get_config_service -router = APIRouter() -logger = logging.getLogger(__name__) - -_LLM_TOML = CONFIG_DIR / _CONFIG_FILES["llm"]["filename"] - -_PROVIDERS_HEADER_RE = re.compile(r"^\s*\[\[providers\]\]\s*(?:#.*)?$") -_EFFORT_LINE_RE = re.compile(r"^\s*reasoning_effort\s*=") -# 既有档位行的安全改写形态:仅接受简单引号值,行尾注释随替换保留 -_EFFORT_VALUE_RE = re.compile( - r"^(\s*reasoning_effort\s*=\s*)(['\"])[^'\"]*\2(\s*(?:#.*)?)$" -) -# TOML 多行字符串定界符:块内出现时行级手术无法保证语义边界,拒绝写入 -_MULTILINE_DELIMITERS = ('"""', "'''") +router = APIRouter(route_class=ConfigurationRoute) class EffortBody(BaseModel): - # 必传(空 body 直接 422);空串 = 删除该 provider 的 reasoning_effort 行 - #(回模型默认档,不发送思考参数) - effort: str + effort: str | None + expected_draft_revision: int = Field(ge=1) @field_validator("effort") @classmethod - def _check_effort(cls, value: str) -> str: - if value not in ("", *REASONING_EFFORT_CHOICES): - raise ValueError( - f"非法档位 {value!r}" - f"(可用:{'/'.join(REASONING_EFFORT_CHOICES)},留空回模型默认档)" - ) + def _check_effort(cls, value): + if value not in (None, "", *REASONING_EFFORT_CHOICES): + raise ValueError("Unknown reasoning effort") return value -def _project_providers(text: str) -> list[dict[str, str]]: - """tomllib 解析后的 providers 投影;TOMLDecodeError 向上抛由调用方定状态码。""" - data = tomllib.loads(text) - raw = data.get("providers", []) - if not isinstance(raw, list): - raw = [] - providers: list[dict[str, str]] = [] - for entry in raw: - if not isinstance(entry, dict): - continue - provider_id = str(entry.get("id", "")).strip() - if not provider_id: - continue - providers.append({ - "id": provider_id, - "default_model": str(entry.get("default_model", "")).strip(), - "reasoning_effort": str(entry.get("reasoning_effort", "")).strip().lower(), - }) - return providers - - -def _find_provider_block(lines: list[str], provider_id: str) -> tuple[int, int, int]: - """定位目标 provider 的 [[providers]] 块。 - - 返回 (块起始行, 块结束行(不含), id 行下标);块范围为 [[providers]] 行到 - 下一个以 [ 开头的行(或文件尾)。找不到抛 404。 - """ - id_re = re.compile(r"^\s*id\s*=\s*(['\"])" + re.escape(provider_id) + r"\1\s*(?:#.*)?$") - index = 0 - while index < len(lines): - if not _PROVIDERS_HEADER_RE.match(lines[index]): - index += 1 - continue - end = index + 1 - while end < len(lines) and not lines[end].lstrip().startswith("["): - end += 1 - for pos in range(index + 1, end): - if id_re.match(lines[pos]): - return index, end, pos - index = end - raise HTTPException(status_code=404, detail=f"provider {provider_id!r} 不存在") - - -def _apply_effort_edit(text: str, provider_id: str, effort: str) -> str: - """对目标 provider 块做 reasoning_effort 行级手术,返回新文本(不落盘)。""" - lines = text.split("\n") - start, end, id_pos = _find_provider_block(lines, provider_id) - if any(delim in line for line in lines[start:end] for delim in _MULTILINE_DELIMITERS): - raise HTTPException( - status_code=400, detail="该 provider 段含多行字符串,请用文本编辑" - ) - effort_line_pos = next( - (pos for pos in range(start + 1, end) if _EFFORT_LINE_RE.match(lines[pos])), - None, - ) - if effort == "": - if effort_line_pos is not None: - del lines[effort_line_pos] - elif effort_line_pos is not None: - match = _EFFORT_VALUE_RE.match(lines[effort_line_pos]) - if match is None: - raise HTTPException( - status_code=400, - detail="既有 reasoning_effort 行形态无法安全改写,请用文本编辑", - ) - lines[effort_line_pos] = f'{match.group(1)}"{effort}"{match.group(3)}' - else: - lines.insert(id_pos + 1, f'reasoning_effort = "{effort}"') - return "\n".join(lines) - - -def _verify_edit( - before: list[dict[str, str]], - after: list[dict[str, str]], - provider_id: str, - effort: str, -) -> None: - """手术后语义校验:provider 序列不变、目标档位符合意图、其余档位不动。""" - if [p["id"] for p in after] != [p["id"] for p in before]: - raise HTTPException(status_code=500, detail="手术后 provider 列表发生变化,已放弃写入") - seen_target = False - for old, new in zip(before, after, strict=True): - if new["id"] == provider_id and not seen_target: - seen_target = True - if new["reasoning_effort"] != effort: - raise HTTPException( - status_code=500, detail="手术后目标 provider 档位与意图不符,已放弃写入" - ) - continue - if new["reasoning_effort"] != old["reasoning_effort"]: - raise HTTPException( - status_code=500, detail="手术后其他 provider 档位发生变化,已放弃写入" - ) - - @router.get("/llm-effort") def get_llm_effort(): - if not _LLM_TOML.exists(): - raise HTTPException(status_code=404, detail="llm.toml 不存在") - try: - providers = _project_providers(_LLM_TOML.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as e: - raise HTTPException(status_code=400, detail=f"llm.toml 解析失败:{e}") - return {"providers": providers} + result = get_config_service().read() + return { + "revision": result["revision"], + "providers": [ + { + "id": p["id"], + "default_model": p["default_model"], + "reasoning_effort": p["parameters"].get("reasoning_effort"), + } + for p in result["document"]["providers"] + ], + } @router.put("/llm-effort/{provider_id}") def put_llm_effort(provider_id: str, body: EffortBody, request: Request): - effort = body.effort - if not _LLM_TOML.exists(): - raise HTTPException(status_code=404, detail="llm.toml 不存在") - tmp = _LLM_TOML.with_suffix(_LLM_TOML.suffix + ".tmp") - # 读-手术-校验-落盘全程在锁内,避免与并发 PUT/文本编辑交错丢改 - with _lock_for(_LLM_TOML): - original = _LLM_TOML.read_text(encoding="utf-8") - try: - before = _project_providers(original) - except tomllib.TOMLDecodeError as e: - raise HTTPException(status_code=400, detail=f"llm.toml 解析失败:{e}") - if provider_id not in [p["id"] for p in before]: - raise HTTPException(status_code=404, detail=f"provider {provider_id!r} 不存在") - old_effort = next(p["reasoning_effort"] for p in before if p["id"] == provider_id) - - updated = _apply_effort_edit(original, provider_id, effort) + def transform(document): + _provider(document, provider_id)["parameters"]["reasoning_effort"] = body.effort - try: - after = _project_providers(updated) - except tomllib.TOMLDecodeError as e: - raise HTTPException(status_code=500, detail=f"手术后 TOML 解析失败,已放弃写入:{e}") - _verify_edit(before, after, provider_id, effort) - - try: - tmp.write_text(updated, encoding="utf-8") - tmp.replace(_LLM_TOML) - except Exception: - tmp.unlink(missing_ok=True) - raise - logger.warning( - "llm reasoning_effort updated via web admin: %s %r -> %r", - provider_id, old_effort, effort, - ) + result = get_config_service().update(body.expected_draft_revision, transform) audit_logger.log( - request, - action="update", - target_type="config", - target_id="llm", - summary_before={"provider": provider_id, "reasoning_effort": old_effort}, - summary_after={"provider": provider_id, "reasoning_effort": effort}, + request, action="update", target_type="llm_configuration", target_id=provider_id ) - # 生效口径与 llm 裸文本编辑一致:引导手动 reload(见 routes/config.py put_config) - return {"ok": True, "effect": "manual_reload"} + return {**result, "ok": True, "effect": "saved_draft"} diff --git a/src/quickquip/app/web/routes/mcp_dashboard.py b/src/quickquip/app/web/routes/mcp_dashboard.py index 6909ee59..2264ddd8 100644 --- a/src/quickquip/app/web/routes/mcp_dashboard.py +++ b/src/quickquip/app/web/routes/mcp_dashboard.py @@ -4,8 +4,9 @@ from fastapi import APIRouter -from quickquip.common.paths import CONFIG_LLM_TOML, MCP_STATUS_JSON_PATH -from quickquip.llm.config import load_llm_config +from quickquip.common.paths import MCP_STATUS_JSON_PATH +from quickquip.llm.config_document import compile_document +from quickquip.llm.config_service import get_config_service from quickquip.llm.mcp.types import format_mcp_era_tag router = APIRouter() @@ -30,99 +31,63 @@ def get_mcp_dashboard(): sid = b["server_id"] if sid not in server_tools: server_tools[sid] = [] - server_tools[sid].append({ - "name": b["tool_name"], - "description": b["description"], - }) + server_tools[sid].append( + { + "name": b["tool_name"], + "description": b["description"], + } + ) servers = [] for s in data["statuses"]: negotiation = s.get("negotiation", "legacy") era = s.get("era", "unknown") - servers.append({ - "id": s["id"], - "transport": s.get("transport", ""), - "enabled": s.get("enabled", False), - "connected": s.get("connected", False), - "tool_count": s.get("tool_count", 0), - "error": s.get("error"), - "detail": s.get("detail", ""), - "server_identity": s.get("server_identity", ""), - "negotiation": negotiation, - "era": era, - "era_tag": format_mcp_era_tag(negotiation, era), - "failure_kind": s.get("failure_kind", ""), - "negotiated_protocol_version": s.get("negotiated_protocol_version", ""), - "tools": server_tools.get(s["id"], []), - "runtime_available": True, - }) - return {"servers": servers} - - # 2) Runtime MCP manager (same process, e.g. local dev) - try: - from quickquip.app.message_pipeline import _ensure_llm_bindings, get_llm_service - - _ensure_llm_bindings() - svc = get_llm_service() - statuses = svc.mcp_manager.get_statuses() - bindings = svc.mcp_manager.bindings - except Exception: - statuses = [] - bindings = {} - - if statuses: - server_tools: dict[str, list[dict]] = {} - for binding in bindings.values(): - if binding.server_id not in server_tools: - server_tools[binding.server_id] = [] - server_tools[binding.server_id].append({ - "name": binding.tool_name, - "description": binding.description, - }) - - servers = [] - for status in statuses: - servers.append({ - "id": status.id, - "transport": status.transport, - "enabled": status.enabled, - "connected": status.connected, - "tool_count": status.tool_count, - "error": status.error, - "detail": status.detail, - "server_identity": status.server_identity, - "negotiation": status.negotiation, - "era": status.era, - "era_tag": format_mcp_era_tag(status.negotiation, status.era), - "failure_kind": status.failure_kind, - "negotiated_protocol_version": status.negotiated_protocol_version, - "tools": server_tools.get(status.id, []), - "runtime_available": True, - }) + servers.append( + { + "id": s["id"], + "transport": s.get("transport", ""), + "enabled": s.get("enabled", False), + "connected": s.get("connected", False), + "tool_count": s.get("tool_count", 0), + "error": s.get("error"), + "detail": s.get("detail", ""), + "server_identity": s.get("server_identity", ""), + "negotiation": negotiation, + "era": era, + "era_tag": format_mcp_era_tag(negotiation, era), + "failure_kind": s.get("failure_kind", ""), + "negotiated_protocol_version": s.get("negotiated_protocol_version", ""), + "tools": server_tools.get(s["id"], []), + "runtime_available": True, + } + ) return {"servers": servers} - # 3) Config-only: no runtime data available at all - config = load_llm_config(CONFIG_LLM_TOML) + # Published configuration describes expected servers; bot status is the runtime authority. + published = get_config_service().read("published") + config = compile_document(published["document"], revision=published["revision"]) if config.load_error or not config.mcp.servers: return {"servers": []} servers = [] for s in config.mcp.servers: negotiation = getattr(s, "negotiation", "legacy") - servers.append({ - "id": s.id, - "transport": s.transport, - "enabled": s.enabled, - "connected": False, - "tool_count": 0, - "error": None, - "detail": "runtime 未连接(等待 bot 进程写入状态文件)", - "negotiation": negotiation, - "era": "unknown", - "era_tag": "", - "failure_kind": "", - "negotiated_protocol_version": "", - "tools": [], - "runtime_available": False, - }) + servers.append( + { + "id": s.id, + "transport": s.transport, + "enabled": s.enabled, + "connected": False, + "tool_count": 0, + "error": None, + "detail": "runtime 未连接(等待 bot 进程写入状态文件)", + "negotiation": negotiation, + "era": "unknown", + "era_tag": "", + "failure_kind": "", + "negotiated_protocol_version": "", + "tools": [], + "runtime_available": False, + } + ) return {"servers": servers} diff --git a/src/quickquip/app/web/routes/skills.py b/src/quickquip/app/web/routes/skills.py index 9cb5cc3f..81079dbb 100644 --- a/src/quickquip/app/web/routes/skills.py +++ b/src/quickquip/app/web/routes/skills.py @@ -1,7 +1,6 @@ """Skill 管理路由:目录 CRUD、预置同步与两阶段安装管线(``/ops/api/skills``)。 -生效目录经 ``_effective_catalog_dir()`` 现读 ``config/llm.toml`` 的 -``[skills].catalog_dir``(缺失/异常回退默认 ``SKILLS_DIR``),配合运行时 +生效目录经 ``_effective_catalog_dir()`` 读取已发布配置库的 Skill 设置,配合运行时 每轮现扫实现天然热部署(W5/W6)。文本资源写入走 FileLock + tmp/replace 原子落盘;锁文件统一在 catalog 根(skill 内容树外,避免被资源扫描编入 清单、被 preset_drift 计入指纹);全部写操作记审计。固定路径 @@ -26,13 +25,12 @@ from quickquip.app.web import skill_import, skill_import_github, skill_import_stash from quickquip.app.web.audit import audit_logger -from quickquip.common.paths import CONFIG_LLM_TOML, DATA_DIR, SKILLS_EXAMPLE_DIR +from quickquip.common.paths import DATA_DIR, SKILLS_EXAMPLE_DIR from quickquip.llm.skills import preset_sync from quickquip.llm.skills.catalog import ( SKILL_FILE_NAME, assert_safe_relative_path, load_skill_with_diagnostics, - read_configured_catalog_dir, resolve_catalog_dir, resolve_file_in, ) @@ -49,7 +47,7 @@ # patch ``_effective_catalog_dir`` 即可整体重定向。 _EXAMPLE_DIR = SKILLS_EXAMPLE_DIR _STASH_DIR = DATA_DIR / "tmp" / "skill-import" -_LLM_CONFIG_PATH = CONFIG_LLM_TOML +_LLM_CONFIG_PATH = DATA_DIR / "llm_config.db" # zip 安装包以 base64 字符串承载:pydantic 按字符数卡上限,换算为恰好放行 # 16MiB 解码字节的 b64 长度;解码后的字节数在路由里再显式校验一次。 @@ -59,8 +57,7 @@ _MAX_FILE_B64_CHARS = 2 * 1024 * 1024 # 文件夹按解码总量计费;各文件分别编码产生的 padding 留出有界余量。 _MAX_FOLDER_B64_CHARS = ( - math.ceil(skill_import.MAX_IMPORT_TOTAL_BYTES / 3) * 4 - + 4 * skill_import.MAX_IMPORT_ENTRIES + math.ceil(skill_import.MAX_IMPORT_TOTAL_BYTES / 3) * 4 + 4 * skill_import.MAX_IMPORT_ENTRIES ) @@ -115,8 +112,11 @@ class ImportConfirmBody(BaseModel): def _effective_catalog_dir() -> Path: - """``[skills].catalog_dir`` 的生效目录:tomllib 直读,缺失/异常回退默认。""" - return resolve_catalog_dir(read_configured_catalog_dir(_LLM_CONFIG_PATH)) + """Resolve the catalog directory from the published database snapshot.""" + from quickquip.llm.config_service import get_config_service + + settings = get_config_service().read("published")["document"]["settings"]["skills"] + return resolve_catalog_dir(settings["catalog_dir"]) def _validate_name(name: str) -> None: @@ -149,10 +149,7 @@ def _diagnostics_payload(diagnostics) -> list[dict]: def _preset_states(catalog_dir: Path) -> dict[str, preset_sync.SyncState]: - return { - row.name: row.state - for row in preset_sync.classify_presets(catalog_dir, _EXAMPLE_DIR) - } + return {row.name: row.state for row in preset_sync.classify_presets(catalog_dir, _EXAMPLE_DIR)} def _resolve_for_write(skill_dir: Path, relative_path: str) -> Path: @@ -219,21 +216,23 @@ def list_skills(): loaded, load_diagnostics = load_skill_with_diagnostics(child) resource_count, has_scripts, total_bytes = _dir_stats(child) state = states.get(child.name) - items.append({ - "name": child.name, - "ok": loaded is not None, - "description": loaded.metadata.description if loaded else "", - "diagnostics": _diagnostics_payload(load_diagnostics), - "resource_count": resource_count, - "has_scripts": has_scripts, - "total_bytes": total_bytes, - "mtime": int(child.stat().st_mtime), - "preset_state": ( - state.value - if state in (preset_sync.SyncState.CURRENT, preset_sync.SyncState.DIVERGED) - else None - ), - }) + items.append( + { + "name": child.name, + "ok": loaded is not None, + "description": loaded.metadata.description if loaded else "", + "diagnostics": _diagnostics_payload(load_diagnostics), + "resource_count": resource_count, + "has_scripts": has_scripts, + "total_bytes": total_bytes, + "mtime": int(child.stat().st_mtime), + "preset_state": ( + state.value + if state in (preset_sync.SyncState.CURRENT, preset_sync.SyncState.DIVERGED) + else None + ), + } + ) return {"skills": items} @@ -292,9 +291,7 @@ def create_skill(body: SkillCreateBody, request: Request): except Exception: tmp.unlink(missing_ok=True) raise - logger.warning( - "skill created via web admin: %s (%d bytes)", body.name, len(body.content) - ) + logger.warning("skill created via web admin: %s (%d bytes)", body.name, len(body.content)) audit_logger.log(request, action="create", target_type="skill", target_id=body.name) return {"name": body.name} @@ -323,7 +320,10 @@ def apply_presets_route(body: PresetApplyBody, request: Request): names = set(body.names) if body.names else None catalog_dir.mkdir(parents=True, exist_ok=True) outcomes, failures = preset_sync.apply_presets( - rows, catalog_dir, _EXAMPLE_DIR, names, + rows, + catalog_dir, + _EXAMPLE_DIR, + names, lock_factory=lambda name: _skill_lock(catalog_dir, name), ) logger.warning( @@ -360,9 +360,7 @@ def _inspect_and_stash( candidates = skill_import.discover_candidates(entries, _effective_catalog_dir()) if not candidates and empty_detail is not None: raise HTTPException(status_code=422, detail=empty_detail) - token = skill_import_stash.stash_payload( - _STASH_DIR, entries, kind=kind, source_url=source_url - ) + token = skill_import_stash.stash_payload(_STASH_DIR, entries, kind=kind, source_url=source_url) return { "token": token, "candidates": [asdict(candidate) for candidate in candidates], diff --git a/src/quickquip/chat/summary_jobs.py b/src/quickquip/chat/summary_jobs.py index 6ff381eb..a5e1fbe7 100644 --- a/src/quickquip/chat/summary_jobs.py +++ b/src/quickquip/chat/summary_jobs.py @@ -22,6 +22,7 @@ from zoneinfo import ZoneInfo from quickquip.chat.config import BEIJING_TIMEZONE +from quickquip.llm.runtime_snapshot import leased_service from quickquip.chat.period_report import ( PERIOD_WEEKLY, compute_period_window, @@ -64,6 +65,7 @@ def _resolve(qq: str) -> str: return _resolve +@leased_service(lambda *args, **kwargs: kwargs["svc"]) async def run_summary_generation( group_id: str, start_ts: float, @@ -223,6 +225,7 @@ async def publish_summaries_job( # ── 群周报 / 群月报 ────────────────────────────────────────────────────── +@leased_service(lambda *args, **kwargs: kwargs["svc"]) async def run_period_generation( group_id: str, period_type: str, diff --git a/src/quickquip/common/paths.py b/src/quickquip/common/paths.py index 44ef0acf..88db73ad 100644 --- a/src/quickquip/common/paths.py +++ b/src/quickquip/common/paths.py @@ -24,6 +24,7 @@ CONFIG_AWAKENING_TOML = CONFIG_DIR / "awakening.toml" CONFIG_ADMINS_TOML = CONFIG_DIR / "admins.toml" +LLM_CONFIG_DB_PATH = DATA_DIR / "llm_config.db" LLM_DB_PATH = DATA_DIR / "llm.db" CHAT_ARCHIVE_DB_PATH = DATA_DIR / "chat_archive.db" DAILY_SUMMARIES_DB_PATH = DATA_DIR / "daily_summaries.db" diff --git a/src/quickquip/generation/__init__.py b/src/quickquip/generation/__init__.py index 3f9bcdca..be23b34a 100644 --- a/src/quickquip/generation/__init__.py +++ b/src/quickquip/generation/__init__.py @@ -1,6 +1,5 @@ from .config import ( DEFAULT_GENERATION_CONFIG_PATH, - DEFAULT_LEGACY_LLM_CONFIG_PATH, AudioGenerationConfig, AudioModelConfig, AudioProviderConfig, @@ -33,7 +32,6 @@ __all__ = [ "DEFAULT_GENERATION_CONFIG_PATH", - "DEFAULT_LEGACY_LLM_CONFIG_PATH", "AudioGenerationConfig", "AudioModelConfig", "AudioProviderConfig", diff --git a/src/quickquip/generation/config.py b/src/quickquip/generation/config.py index 0a94e9e8..e7f3dc9a 100644 --- a/src/quickquip/generation/config.py +++ b/src/quickquip/generation/config.py @@ -7,11 +7,10 @@ import tomllib from quickquip.common.config_utils import as_bool, as_dict, expand_env_value -from quickquip.common.paths import CONFIG_GENERATION_TOML, CONFIG_LLM_TOML +from quickquip.common.paths import CONFIG_GENERATION_TOML DEFAULT_GENERATION_CONFIG_PATH = CONFIG_GENERATION_TOML -DEFAULT_LEGACY_LLM_CONFIG_PATH = CONFIG_LLM_TOML _SUPPORTED_IMAGE_PROTOCOLS = {"openai_images", "gemini_imagen", "minimax_images"} _SUPPORTED_AUDIO_PROTOCOLS = {"minimax_t2a_http", "minimax_t2a_async", "openai_tts", "http_tts"} _SUPPORTED_MUSIC_PROTOCOLS = {"minimax_music"} @@ -628,8 +627,6 @@ def _load_toml(path: Path) -> dict[str, Any]: def load_generation_config( path: str | Path = DEFAULT_GENERATION_CONFIG_PATH, - *, - legacy_llm_path: str | Path = DEFAULT_LEGACY_LLM_CONFIG_PATH, ) -> GenerationConfig: config_path = Path(path) if config_path.exists(): @@ -645,20 +642,6 @@ def load_generation_config( ) return _validate_generation_config(config) - legacy_path = Path(legacy_llm_path) - if legacy_path.exists(): - data = _load_toml(legacy_path) - config = GenerationConfig( - image=_read_image_generation_section(data), - audio=_read_audio_generation_section(data), - music=_read_music_generation_section(data), - asr=_read_asr_section(data), - svg=_read_svg_section(data), - source_path=legacy_path, - source_kind="llm_legacy", - ) - return _validate_generation_config(config) - return GenerationConfig( load_error=f"未找到配置文件:{config_path}", source_path=config_path, diff --git a/src/quickquip/generation/service.py b/src/quickquip/generation/service.py index 0cd9fda5..78858577 100644 --- a/src/quickquip/generation/service.py +++ b/src/quickquip/generation/service.py @@ -4,7 +4,6 @@ from quickquip.generation.config import ( DEFAULT_GENERATION_CONFIG_PATH, - DEFAULT_LEGACY_LLM_CONFIG_PATH, GenerationConfig, ResolvedAsrModel, ResolvedAudioModel, @@ -18,14 +17,10 @@ class GenerationService: def __init__( self, config_path: str | Path = DEFAULT_GENERATION_CONFIG_PATH, - *, - legacy_llm_path: str | Path = DEFAULT_LEGACY_LLM_CONFIG_PATH, ) -> None: self.config_path = Path(config_path) - self.legacy_llm_path = Path(legacy_llm_path) self._config: GenerationConfig | None = None self._active_mtime: float | None = None - self._legacy_mtime: float | None = None def _stat_mtime(self, path: Path) -> float | None: try: @@ -37,16 +32,11 @@ def _needs_reload(self) -> bool: if self._config is None: return True active_mtime = self._stat_mtime(self.config_path) - legacy_mtime = self._stat_mtime(self.legacy_llm_path) - return active_mtime != self._active_mtime or legacy_mtime != self._legacy_mtime + return active_mtime != self._active_mtime def reload(self) -> GenerationConfig: - self._config = load_generation_config( - self.config_path, - legacy_llm_path=self.legacy_llm_path, - ) + self._config = load_generation_config(self.config_path) self._active_mtime = self._stat_mtime(self.config_path) - self._legacy_mtime = self._stat_mtime(self.legacy_llm_path) return self._config def get_config(self, *, force_reload: bool = False) -> GenerationConfig: diff --git a/src/quickquip/llm/briefing.py b/src/quickquip/llm/briefing.py index 5e4a10de..6cf3e213 100644 --- a/src/quickquip/llm/briefing.py +++ b/src/quickquip/llm/briefing.py @@ -5,11 +5,12 @@ from quickquip.chat.daily_briefing import DailyBriefingContext from quickquip.llm.config import DailyBriefingConfig, LLMConfig, PersonaConfig +from quickquip.llm.config_document import resolve_model_provider from quickquip.llm.provider import LLMProviderError, LLMRequest, build_provider_client from quickquip.llm.provider import strip_leading_reasoning_content from quickquip.llm.response_acceptance import is_response_accepted from quickquip.llm.tools import LLMConversationMessage -from quickquip.llm.usage import new_usage_run_id, set_usage_scope +from quickquip.llm.usage import configuration_pricing_entry, new_usage_run_id, set_usage_scope logger = logging.getLogger(__name__) @@ -124,6 +125,7 @@ def _trim_output(text: str, max_chars: int) -> str: return clipped.rstrip(",、;,;") + "……" +@configuration_pricing_entry async def generate_daily_briefing( *, context: DailyBriefingContext, @@ -164,7 +166,12 @@ async def generate_daily_briefing( logger.info("daily_briefing: provider %r disabled, skipping", provider_id) continue - effective_config = replace(provider_config, stream_enabled=False) + try: + effective_config = replace( + resolve_model_provider(llm_config, provider_id, model), stream_enabled=False + ) + except ValueError: + continue req = LLMRequest( model=model, system_prompt=system_prompt, diff --git a/src/quickquip/llm/config.py b/src/quickquip/llm/config.py index bc7d9a88..323d00a5 100644 --- a/src/quickquip/llm/config.py +++ b/src/quickquip/llm/config.py @@ -4,18 +4,15 @@ import logging from pathlib import Path from typing import Any -import tomllib -from quickquip.common.config_utils import as_bool, as_dict, expand_env_value from quickquip.llm.epoch import EpochParams -from quickquip.llm.skills import MAX_SCRIPT_TIMEOUT_MS logger = logging.getLogger(__name__) MAX_IMAGE_PREPROCESSING_TOKENS = 2048 # 上游 429/5xx 自动重试默认策略:RuntimeConfig / ProviderConfig 字段默认值、 -# load_llm_config 的 TOML fallback 与 RetryPolicy 字段默认值均单点引用此组常量 +# 请求配置与 RetryPolicy 字段默认值均单点引用此组常量 DEFAULT_RETRY_MAX_ATTEMPTS = 3 DEFAULT_RETRY_BASE_DELAY = 1.0 DEFAULT_RETRY_JITTER = 0.5 @@ -199,8 +196,7 @@ class ImagePreprocessingConfig: # 群聊用户可见的"当前 provider 已禁用"提示:回复主链 / 单发命令 / 当前探活共用,勿在各处另行拼装 DISABLED_PROVIDER_REPLY = ( - "当前 provider 已禁用:{provider_id}(enabled = false)" - ",请用 /llm use 切换其他 provider。" + "当前 provider 已禁用:{provider_id}(enabled = false),请用 /llm use 切换其他 provider。" ) @@ -247,7 +243,7 @@ class ProviderConfig: # 独立于已废弃的 thinking_budget 数字口径(LLMRequest 兼容字段, # 主链路从未接线)。 reasoning_effort: str = "" - # 上游 429/5xx 自动重试策略;load_llm_config 用 [runtime] 段统一盖章 + # 上游 429/5xx 自动重试策略;渠道与模型可覆盖全局缺省 retry_max_attempts: int = DEFAULT_RETRY_MAX_ATTEMPTS retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY retry_jitter: float = DEFAULT_RETRY_JITTER @@ -270,6 +266,10 @@ class ProviderConfig: # wire model -> token 容量;显式配置永远优先,未命中时按 # context_windows 内置策展表解析,均未命中按 capacity unknown 处理。 model_context_windows: dict[str, int] = field(default_factory=dict) + model_parameters: dict[str, dict[str, Any]] = field(default_factory=dict) + model_enabled: dict[str, bool] = field(default_factory=dict) + style_profile: str = "" + style_append: str | None = None def provider_builtin_search_active(provider: ProviderConfig) -> bool: @@ -283,7 +283,7 @@ def provider_builtin_search_active(provider: ProviderConfig) -> bool: @dataclass(slots=True) class PricingRates: - """单个模型的 per-MTok 定价(llm.toml [pricing.models.])。 + """价格规则的每百万 token 单价。 cache_read/write_per_mtok 为 None 表示该桶无单独价(套餐制/无缓存溢价), 计算时回退 input 价。 @@ -372,6 +372,8 @@ class LLMConfig: image_preprocessing: ImagePreprocessingConfig = field(default_factory=ImagePreprocessingConfig) style_profiles: dict[str, str] = field(default_factory=dict) pricing: dict[str, PricingRates] = field(default_factory=dict) + pricing_references: dict[tuple[str | None, str], PricingRates] = field(default_factory=dict) + revision: int = 0 load_error: str | None = None source_path: Path | None = None @@ -427,980 +429,26 @@ def _epoch_params_valid(params: "EpochParams") -> bool: return ( params.context_tokens > 0 and params.cold_idle_seconds >= 0 - and 0 < params.cold_target_tokens < params.cold_trigger_tokens - <= params.hot_target_tokens < params.cap_tokens + and 0 + < params.cold_target_tokens + < params.cold_trigger_tokens + <= params.hot_target_tokens + < params.cap_tokens ) -_KNOWN_PERSONA_KEYS = {"id", "display_name", "system_prompt", "style_prompt", "scope"} - - -def _read_personas(raw_personas: list[dict[str, Any]]) -> dict[str, PersonaConfig]: - personas: dict[str, PersonaConfig] = {} - for entry in raw_personas: - entry = expand_env_value(entry) - persona_id = str(entry.get("id", "")).strip() - if not persona_id: - continue - raw_scope = entry.get("scope", []) - if isinstance(raw_scope, str): - raw_scope = [raw_scope] - parsed_scope = [ - s for s in (str(s).strip().lower() for s in raw_scope) if s in {"group", "private"} - ] - extras = {k: v for k, v in entry.items() if k not in _KNOWN_PERSONA_KEYS} - personas[persona_id] = PersonaConfig( - id=persona_id, - display_name=str(entry.get("display_name", persona_id)).strip() or persona_id, - system_prompt=str(entry.get("system_prompt", "")).strip(), - style_prompt=str(entry.get("style_prompt", "")).strip(), - scope=parsed_scope, - extras=extras, - ) - return personas - - -def _optional_float(value: object) -> float | None: - if value is None: - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - -def _read_pricing(raw: dict[str, Any]) -> dict[str, PricingRates]: - """解析 [pricing.models.] 子表为 {model: PricingRates}。""" - models_raw = as_dict(raw.get("models")) - pricing: dict[str, PricingRates] = {} - for model_name, entry in models_raw.items(): - name = str(model_name).strip() - if not name or not isinstance(entry, dict): - continue - pricing[name] = PricingRates( - input_per_mtok=float(entry.get("input_per_mtok", 0.0) or 0.0), - output_per_mtok=float(entry.get("output_per_mtok", 0.0) or 0.0), - cache_read_per_mtok=_optional_float(entry.get("cache_read_per_mtok")), - cache_write_per_mtok=_optional_float(entry.get("cache_write_per_mtok")), - source=str(entry.get("source", "config")).strip() or "config", - confidence=str(entry.get("confidence", "configured")).strip() or "configured", - ) - return pricing - - -def _load_personas_from_dir(personas_dir: Path) -> list[dict[str, Any]]: - """Load personas from a directory of TOML files. - - Each file may define a persona using flat top-level keys (id, display_name, - system_prompt, style_prompt, plus any extras), or via a [[personas]] array - for compatibility. - - A special file ``_shared.toml`` may define: - - ``shared_system_prompt``: appended to every persona's system_prompt - - ``shared_style_prompt``: appended to every persona's style_prompt - - Files are loaded in lexicographic order. ``_shared.toml`` is processed - first and never treated as a persona definition itself. - """ - if not personas_dir.is_dir(): - return [] - - # Load shared rules first - shared_system = "" - shared_style = "" - shared_path = personas_dir / "_shared.toml" - if shared_path.exists(): - try: - with shared_path.open("rb") as fh: - shared_data = tomllib.load(fh) - shared_system = str(shared_data.get("shared_system_prompt", "")).strip() - shared_style = str(shared_data.get("shared_style_prompt", "")).strip() - except tomllib.TOMLDecodeError as exc: - logger.warning("跳过损坏的 _shared.toml:%s", exc) - - personas: list[dict[str, Any]] = [] - for toml_file in sorted(personas_dir.glob("*.toml")): - if toml_file.name.startswith("_"): - continue # skip _shared.toml and other reserved files - try: - with toml_file.open("rb") as fh: - data = tomllib.load(fh) - except tomllib.TOMLDecodeError as exc: - logger.warning("跳过损坏的人格文件 %s:%s", toml_file.name, exc) - continue - - # Support flat top-level keys (preferred) or [[personas]] array - if "id" in data: - entry = dict(data) - # Inject shared content - if shared_system: - existing = str(entry.get("system_prompt", "")).rstrip() - entry["system_prompt"] = ( - (existing + "\n\n" + shared_system).lstrip() if existing else shared_system - ) - if shared_style: - existing = str(entry.get("style_prompt", "")).rstrip() - entry["style_prompt"] = ( - (existing + "\n\n" + shared_style).lstrip() if existing else shared_style - ) - personas.append(entry) - elif "personas" in data: - for entry in data["personas"]: - entry = dict(entry) - if shared_system: - existing = str(entry.get("system_prompt", "")).rstrip() - entry["system_prompt"] = ( - (existing + "\n\n" + shared_system).lstrip() - if existing - else shared_system - ) - if shared_style: - existing = str(entry.get("style_prompt", "")).rstrip() - entry["style_prompt"] = ( - (existing + "\n\n" + shared_style).lstrip() - if existing - else shared_style - ) - personas.append(entry) - - return personas - - -def _read_providers( - raw_providers: list[dict[str, Any]], - *, - style_profiles: dict[str, str] | None = None, -) -> dict[str, ProviderConfig]: - style_profiles = style_profiles or {} - providers: dict[str, ProviderConfig] = {} - for entry in raw_providers: - entry = expand_env_value(entry) - provider_id = str(entry.get("id", "")).strip() - if not provider_id: - continue - - try: - provider = _parse_single_provider(entry, provider_id, style_profiles) - except Exception: - logger.exception("跳过配置无效的 provider %s", provider_id) - continue - providers[provider_id] = provider - return providers - - -def _parse_single_provider( - entry: dict[str, Any], provider_id: str, style_profiles: dict[str, str] -) -> ProviderConfig: - raw_headers = as_dict(entry.get("headers")) - - style_text = str(entry.get("style_overrides", "")).strip() - profile_name = str(entry.get("style_profile", "")).strip() - if profile_name: - if profile_name not in style_profiles: - available = ", ".join(sorted(style_profiles)) if style_profiles else "(无)" - logger.error( - "provider %s 引用了未知的 style_profile %r,已忽略该 profile(可用:%s)", - provider_id, profile_name, available, - ) - else: - shared = style_profiles[profile_name].strip() - style_text = shared + ("\n" + style_text if style_text else "") - - raw_aliases = expand_env_value(as_dict(entry.get("aliases"))) - models = [str(item).strip() for item in entry.get("models", []) if str(item).strip()] - aliases: dict[str, str] = {} - for raw_k, raw_v in raw_aliases.items(): - k = str(raw_k).strip() - v = str(raw_v).strip() - if not k or not v: - continue - if v not in models: - logger.warning("provider %s 的 alias %r 指向不存在的模型 %r,已跳过", provider_id, k, v) - continue - aliases[k] = v - - return ProviderConfig( - id=provider_id, - protocol=str(entry.get("protocol", "")).strip().lower(), - base_url=str(entry.get("base_url", "")).strip(), - api_key_env=str(entry.get("api_key_env", "")).strip(), - default_model=str(entry.get("default_model", "")).strip(), - models=models, - enabled=as_bool(entry.get("enabled", True), default=True), - non_vision_models=[ - str(item).strip() - for item in entry.get("non_vision_models", []) - if str(item).strip() - ], - timeout_seconds=float(entry.get("timeout_seconds", 45)), - temperature=float(entry.get("temperature", 0.8)), - max_output_tokens=int(entry.get("max_output_tokens", 800)), - style_overrides=style_text, - stream_enabled=as_bool(entry.get("stream_enabled", True), default=True), - headers={str(k): str(v) for k, v in raw_headers.items()}, - user_agent=str(entry.get("user_agent", "")).strip(), - extra_body=expand_env_value(as_dict(entry.get("extra_body"))), - aliases=aliases, - fallback_urls=[ - str(item).strip() - for item in entry.get("fallback_urls", []) - if str(item).strip() - ], - proxy=str(entry.get("proxy", "")).strip(), - prompt_caching=as_bool(entry.get("prompt_caching"), default=False), - cache_ttl=str(entry.get("cache_ttl", "")).strip(), - auth_method=str(entry.get("auth_method", "api_key")).strip().lower() or "api_key", - builtin_search=as_bool(entry.get("builtin_search"), default=False), - responses_profile=( - str(entry.get("responses_profile", DEFAULT_RESPONSES_PROFILE_ID)) - .strip() - .lower() - or DEFAULT_RESPONSES_PROFILE_ID - ), - reasoning_effort=str(entry.get("reasoning_effort", "")).strip().lower(), - epoch_context_tokens=_as_optional_int(entry.get("epoch_context_tokens")), - epoch_cold_idle_seconds=_as_optional_int(entry.get("epoch_cold_idle_seconds")), - epoch_cold_target_tokens=_as_optional_int(entry.get("epoch_cold_target_tokens")), - epoch_cold_trigger_tokens=_as_optional_int(entry.get("epoch_cold_trigger_tokens")), - epoch_hot_target_tokens=_as_optional_int(entry.get("epoch_hot_target_tokens")), - epoch_cap_tokens=_as_optional_int(entry.get("epoch_cap_tokens")), - request_input_token_budget=_nonpositive_as_none( - entry.get("request_input_token_budget") - ), - max_inline_media_bytes=max( - 0, int(entry.get("max_inline_media_bytes", DEFAULT_MAX_INLINE_MEDIA_BYTES)) - ), - agent_replay_loop_tokens=_clamped_optional_int( - entry.get("agent_replay_loop_tokens"), - floor=AGENT_REPLAY_LOOP_TOKENS_FLOOR, - ceiling=AGENT_REPLAY_LOOP_TOKENS_CEILING, - ), - model_context_windows={ - str(name): max(1024, int(size)) - for name, size in as_dict(entry.get("model_context_windows")).items() - }, - ) - -def _as_optional_int(raw: Any) -> int | None: - """provider 级可选整数键:缺省/空 = None(继承 runtime)。 - - TOML 数直转(float 截断,与既有 ``int(entry.get(...))`` 范式一致);字符串 - 走 ``int()``;解析失败回退 None 并告警——键级笔误不得扩大为整 provider 剪除。 - """ - if raw is None: - return None - if isinstance(raw, (int, float)): - return int(raw) - text = str(raw).strip() - if not text: - return None - try: - return int(text) - except ValueError: - logger.warning("epoch_* 覆盖值 %r 无法解析为整数,回退继承 [runtime]", raw) - return None - - -def _clamped_optional_int( - raw: Any, *, floor: int, ceiling: int -) -> int | None: - """provider 级可选整数键 + 范围钳制:缺省/不可解析回退 None,越界钳制到边界并告警。""" - value = _as_optional_int(raw) - if value is None: - return None - clamped = min(ceiling, max(floor, value)) - if clamped != value: - logger.warning( - "provider 覆盖值 %d 超出 [%d, %d],已钳制为 %d", value, floor, ceiling, clamped - ) - return clamped - - -def _nonpositive_as_none(raw: Any) -> int | None: - """非正数视为未配置(继承推导/缺省):0/负值不是合法预算,告警不致命。""" - value = _as_optional_int(raw) - if value is not None and value <= 0: - logger.warning("provider 覆盖值 %d 非正数,按未配置处理(继承推导/缺省)", value) - return None - return value - - -_MCP_NEGOTIATION_MODES = {"legacy", "auto", "modern"} - - -def _parse_enabled_mode(raw: Any) -> str: - """解析 [tools] enabled_mode;非法取值回退 append 并告警。""" - mode = str(raw if raw is not None else "append").strip().lower() - if mode not in ("append", "replace"): - if raw is not None: - logger.warning("[tools] enabled_mode 非法取值 %r,回退为 append", raw) - return "append" - return mode - - -def _skills_positive_int(raw: Any, *, default: int, label: str, maximum: int | None = None) -> int: - """[skills] 正整数键:不可解析/非正数回退默认并告警;超过 maximum 钳制并告警。""" - value: int - if isinstance(raw, bool) or raw is None: - value = default - if raw is not None: - logger.warning("[skills] %s 非法取值 %r,回退默认值 %d", label, raw, default) - elif isinstance(raw, (int, float)): - value = int(raw) - else: - try: - value = int(str(raw).strip()) - except ValueError: - logger.warning("[skills] %s 非法取值 %r,回退默认值 %d", label, raw, default) - return default - if value <= 0: - logger.warning("[skills] %s 须为正整数(当前 %r),回退默认值 %d", label, raw, default) - return default - if maximum is not None and value > maximum: - logger.warning("[skills] %s=%d 超过上限 %d,已钳制", label, value, maximum) - return maximum - return value - - -def _read_mcp_servers(raw_servers: list[dict[str, Any]]) -> list[MCPServerConfig]: - servers: list[MCPServerConfig] = [] - seen_ids: set[str] = set() - for entry in raw_servers: - entry = expand_env_value(entry) - server_id = str(entry.get("id", "")).strip() - if not server_id: - continue - - if server_id in seen_ids: - logger.warning("跳过重复的 MCP server id:%s", server_id) - continue - seen_ids.add(server_id) - - negotiation = str(entry.get("negotiation", "legacy")).strip().lower() or "legacy" - if negotiation not in _MCP_NEGOTIATION_MODES: - logger.warning( - "跳过 MCP server %s:未知 negotiation 模式 %r(仅支持 legacy/auto/modern)", - server_id, negotiation, - ) - continue - - transport = str(entry.get("transport", "stdio")).strip().lower() or "stdio" - supported_versions = [ - str(v).strip() - for v in entry.get("supported_protocol_versions", []) - if str(v).strip() - ] - - if negotiation in {"auto", "modern"}: - if transport != "http": - logger.warning( - "跳过 MCP server %s:%r 协商模式仅支持 HTTP transport(当前 %r)", - server_id, negotiation, transport, - ) - continue - if not supported_versions: - logger.warning( - "跳过 MCP server %s:%r 协商模式需要 supported_protocol_versions", - server_id, negotiation, - ) - continue - - raw_env = as_dict(entry.get("env")) - raw_headers = as_dict(entry.get("headers")) - servers.append( - MCPServerConfig( - id=server_id, - transport=transport, - enabled=as_bool(entry.get("enabled", True), default=True), - timeout_seconds=float(entry.get("timeout_seconds", 30)), - protocol_version=str(entry.get("protocol_version", "2025-03-26")).strip() - or "2025-03-26", - tool_prefix=str(entry.get("tool_prefix", "")).strip() or None, - include_tools=[ - str(item).strip() - for item in entry.get("include_tools", []) - if str(item).strip() - ], - exclude_tools=[ - str(item).strip() - for item in entry.get("exclude_tools", []) - if str(item).strip() - ], - allowed_tools=[ - str(item).strip() - for item in entry.get("allowed_tools", []) - if str(item).strip() - ], - command=str(entry.get("command", "")).strip(), - args=[str(item) for item in entry.get("args", []) if str(item).strip()], - cwd=str(entry.get("cwd", "")).strip() or None, - env={str(k): str(v) for k, v in raw_env.items()}, - url=str(entry.get("url", "")).strip(), - headers={str(k): str(v) for k, v in raw_headers.items()}, - image=str(entry.get("image", "")).strip(), - docker_command=str(entry.get("docker_command", "docker")).strip() or "docker", - docker_args=[ - str(item) - for item in entry.get("docker_args", []) - if str(item).strip() - ], - mounts=[str(item).strip() for item in entry.get("mounts", []) if str(item).strip()], - network=str(entry.get("network", "")).strip() or None, - container_workdir=str(entry.get("container_workdir", "")).strip() or None, - negotiation=negotiation, - supported_protocol_versions=supported_versions, - ) - ) - return servers - - -def load_personas_only(config_path: str | Path) -> dict[str, PersonaConfig]: - """Parse only the persona section of llm.toml plus ``config/personas/`` directory. - - Used by hot-reload paths that must not touch providers/MCP/runtime; returns - an empty dict if the config file is missing or defines no personas. - """ - path = Path(config_path) - if not path.exists(): - return {} - - try: - with path.open("rb") as file: - data = tomllib.load(file) - except tomllib.TOMLDecodeError as exc: - logger.warning("load_personas_only 跳过损坏的 TOML:%s", exc) - return {} - - personas_path = path.parent / "personas" - raw_personas = data.get("personas", []) - if not isinstance(raw_personas, list): - raw_personas = [] - if personas_path.is_dir(): - raw_personas = raw_personas + _load_personas_from_dir(personas_path) - return _read_personas(raw_personas) - - -def _read_providers_safe( - raw_providers: Any, style_profiles: dict[str, str] -) -> dict[str, ProviderConfig]: - try: - return _read_providers( - raw_providers if isinstance(raw_providers, list) else [], - style_profiles=style_profiles, - ) - except Exception: - logger.exception("provider 解析阶段发生未预期异常") - return {} +_KNOWN_PERSONA_KEYS = {"id", "display_name", "system_prompt", "style_prompt", "scope"} def load_llm_config(path: str | Path) -> LLMConfig: - config_path = Path(path) - if not config_path.exists(): - return LLMConfig(load_error=f"未找到配置文件:{config_path}", source_path=config_path) - - try: - with config_path.open("rb") as file: - data = tomllib.load(file) - except tomllib.TOMLDecodeError as exc: - logger.exception("llm.toml 语法错误") - return LLMConfig(load_error=f"TOML 语法错误:{exc}", source_path=config_path) - - personas = load_personas_only(config_path) - - runtime_raw = expand_env_value(as_dict(data.get("runtime"))) - # 【现场】补丁两键防护:非法取值静默通过会让 budget=0 的「关停」意图 - # 实际仍服役最新 1 条且无告警——与相邻 epoch_* 键的回退范式对齐 - recent_context_token_budget = int(runtime_raw.get("recent_context_token_budget", 800)) - if recent_context_token_budget <= 0: - logger.warning( - "[runtime] recent_context_token_budget=%d 非法(须 > 0),回退默认值 800", - recent_context_token_budget, - ) - recent_context_token_budget = 800 - recent_context_floor_seconds = int(runtime_raw.get("recent_context_floor_seconds", 300)) - if recent_context_floor_seconds < 0: - logger.warning( - "[runtime] recent_context_floor_seconds=%d 非法(须 >= 0),回退默认值 300", - recent_context_floor_seconds, - ) - recent_context_floor_seconds = 300 - triggers_raw = expand_env_value(as_dict(data.get("triggers"))) - tools_raw = expand_env_value(as_dict(data.get("tools"))) - skills_raw = expand_env_value(as_dict(data.get("skills"))) - mcp_raw = expand_env_value(as_dict(data.get("mcp"))) - daily_summary_raw = expand_env_value(as_dict(data.get("daily_summary"))) - daily_briefing_raw = expand_env_value(as_dict(data.get("daily_briefing"))) - weekly_report_raw = expand_env_value(as_dict(data.get("weekly_report"))) - monthly_report_raw = expand_env_value(as_dict(data.get("monthly_report"))) - image_preprocessing_raw = expand_env_value(as_dict(data.get("image_preprocessing"))) - raw_style_profiles = expand_env_value(as_dict(data.get("style_profiles"))) - # 空内容家族是合法占位(引用方无风格附加块),只过滤空键——家族内容 - # 待调研定版期间以空串声明,避免被误判为未知引用。 - style_profiles = { - str(k).strip(): str(v).strip() - for k, v in raw_style_profiles.items() - if str(k).strip() - } - raw_pricing = as_dict(data.get("pricing")) - raw_providers = data.get("providers", []) - raw_mcp_servers = mcp_raw.get("servers", []) - auto_search_raw = expand_env_value(as_dict(triggers_raw.get("auto_search"))) - quick_judge_raw = expand_env_value(as_dict(triggers_raw.get("quick_judge"))) - - _enabled_tools = [ - str(item).strip() - for item in tools_raw.get("enabled", []) - if str(item).strip() - ] - _enabled_mode = _parse_enabled_mode(tools_raw.get("enabled_mode")) - if _enabled_tools and "enabled_mode" not in tools_raw: - # v1.11 及更早 enabled 非空 = 精确白名单;未显式声明 mode 的升级部署提示语义变化 - logger.warning( - "[tools] enabled 非空且未设置 enabled_mode," - "按 append 语义在默认白名单与 MCP 工具之上追加;" - '如需精确白名单请显式设置 enabled_mode = "replace"' - ) - - if ( - int(runtime_raw.get("reply_split_threshold_chars", 800)) - > int(runtime_raw.get("reply_chunk_max_chars", 1200)) - ): - return LLMConfig( - load_error="reply_split_threshold_chars 不能超过 reply_chunk_max_chars", - source_path=config_path, - ) - legacy_agent_delivery = runtime_raw.get("agent_delivery_enabled") - legacy_fallback_active = legacy_agent_delivery is not None and ( - "agent_delivery_intermediate_enabled" not in runtime_raw - or "agent_delivery_final_enabled" not in runtime_raw - ) - if legacy_fallback_active: - logger.warning( - "[runtime] agent_delivery_enabled 已废弃:存在未显式配置的交付域," - "该域正按旧键值映射,请迁移到 agent_delivery_intermediate_enabled / " - "agent_delivery_final_enabled" - ) - elif legacy_agent_delivery is not None: - logger.debug("[runtime] agent_delivery_enabled 已被两枚新键取代,忽略旧键") - config = LLMConfig( - runtime=RuntimeConfig( - enabled=as_bool(runtime_raw.get("enabled", False), default=False), - memory_enabled=as_bool(runtime_raw.get("memory_enabled", True), default=True), - default_provider=str(runtime_raw.get("default_provider", "")).strip() or None, - default_persona=str(runtime_raw.get("default_persona", "")).strip() or None, - history_limit=int(runtime_raw.get("history_limit", 10)), - history_max_messages_per_group=int( - runtime_raw.get("history_max_messages_per_group", 40) - ), - memory_limit=int(runtime_raw.get("memory_limit", 6)), - memory_max_items_per_group=int(runtime_raw.get("memory_max_items_per_group", 200)), - max_prompt_chars=int(runtime_raw.get("max_prompt_chars", 4000)), - tool_calling_enabled=as_bool( - runtime_raw.get("tool_calling_enabled", False), default=False - ), - tool_max_rounds=int(runtime_raw.get("tool_max_rounds", 8)), - tool_max_calls_per_round=int(runtime_raw.get("tool_max_calls_per_round", 16)), - retry_max_attempts=int( - runtime_raw.get("retry_max_attempts", DEFAULT_RETRY_MAX_ATTEMPTS) - ), - retry_base_delay=float(runtime_raw.get("retry_base_delay", DEFAULT_RETRY_BASE_DELAY)), - retry_jitter=min( - 1.0, max(0.0, float(runtime_raw.get("retry_jitter", DEFAULT_RETRY_JITTER))) - ), - auto_memory_enabled=as_bool( - runtime_raw.get("auto_memory_enabled", False), default=False - ), - auto_memory_prompt=str(runtime_raw.get("auto_memory_prompt", "")).strip(), - auto_memory_max_tokens=max(32, int(runtime_raw.get("auto_memory_max_tokens", 256))), - epoch_context_tokens=int(runtime_raw.get("epoch_context_tokens", 8000)), - epoch_cold_idle_seconds=int(runtime_raw.get("epoch_cold_idle_seconds", 300)), - epoch_cold_target_tokens=int(runtime_raw.get("epoch_cold_target_tokens", 4000)), - epoch_cold_trigger_tokens=int(runtime_raw.get("epoch_cold_trigger_tokens", 5000)), - epoch_hot_target_tokens=int(runtime_raw.get("epoch_hot_target_tokens", 32000)), - epoch_cap_tokens=int(runtime_raw.get("epoch_cap_tokens", 64000)), - request_input_token_budget=max( - 1024, int(runtime_raw.get("request_input_token_budget", 96_000)) - ), - agent_record_retention_days=max( - 1, int(runtime_raw.get("agent_record_retention_days", 30)) - ), - agent_record_max_loops_per_scope=max( - 1, int(runtime_raw.get("agent_record_max_loops_per_scope", 1000)) - ), - agent_record_max_bytes_per_scope=max( - 1024, int(runtime_raw.get("agent_record_max_bytes_per_scope", 67_108_864)) - ), - agent_delivery_intermediate_enabled=as_bool( - runtime_raw.get("agent_delivery_intermediate_enabled"), - default=as_bool(legacy_agent_delivery, default=False), - ), - agent_delivery_final_enabled=as_bool( - runtime_raw.get("agent_delivery_final_enabled"), - default=as_bool(legacy_agent_delivery, default=False), - ), - agent_replay_loop_tokens=min( - AGENT_REPLAY_LOOP_TOKENS_CEILING, - max( - AGENT_REPLAY_LOOP_TOKENS_FLOOR, - int(runtime_raw.get("agent_replay_loop_tokens", 4096)), - ), - ), - reply_split_threshold_chars=max( - 1, int(runtime_raw.get("reply_split_threshold_chars", 800)) - ), - reply_chunk_max_chars=max( - 1, int(runtime_raw.get("reply_chunk_max_chars", 1200)) - ), - reply_send_interval_ms=min( - 10_000, max(0, int(runtime_raw.get("reply_send_interval_ms", 800))) - ), - reply_max_chunks_per_loop=min( - 256, max(1, int(runtime_raw.get("reply_max_chunks_per_loop", 64))) - ), - mention_cooldown_seconds=min( - 86_400.0, - max(0.0, float(runtime_raw.get("mention_cooldown_seconds", 600.0))), - ), - recent_context_token_budget=recent_context_token_budget, - recent_context_floor_seconds=recent_context_floor_seconds, - ), - triggers=TriggerConfig( - default_prefix=str(triggers_raw.get("default_prefix", "/ai")).strip() or "/ai", - allow_prefix=as_bool(triggers_raw.get("allow_prefix", True), default=True), - allow_at=as_bool(triggers_raw.get("allow_at", True), default=True), - empty_prompt_reply=str( - triggers_raw.get("empty_prompt_reply", "请在触发指令或艾特后面补上想说的话。") - ).strip() - or "请在触发指令或艾特后面补上想说的话。", - ), - auto_search=AutoSearchConfig( - enabled=as_bool(auto_search_raw.get("enabled", False), default=False), - search_max_calls_per_round=max( - 1, min(int(auto_search_raw.get("search_max_calls_per_round", 3)), 32) - ), - ), - quick_judge=QuickJudgeConfig( - provider_id=str(quick_judge_raw.get("provider_id", "")).strip(), - model=str(quick_judge_raw.get("model", "")).strip(), - timeout=max(0.5, float(quick_judge_raw.get("timeout", 2.0))), - max_tokens=max(8, int(quick_judge_raw.get("max_tokens", 64))), - ), - tools=ToolsConfig( - enabled=_enabled_tools, - enabled_mode=_enabled_mode, - discovery_mode=str(tools_raw.get("discovery_mode", "auto")).strip().lower() or "auto", - discovery_min_tools=max(1, int(tools_raw.get("discovery_min_tools", 10))), - discovery_search_limit=max(1, min(int(tools_raw.get("discovery_search_limit", 5)), 20)), - discovery_max_loaded_tools=max( - 1, min(int(tools_raw.get("discovery_max_loaded_tools", 12)), 64) - ), - always_loaded=[ - str(item).strip() - for item in tools_raw.get("always_loaded", []) - if str(item).strip() - ], - ), - skills=SkillsConfig( - enabled=as_bool(skills_raw.get("enabled", True), default=True), - catalog_dir=str(skills_raw.get("catalog_dir", "")).strip(), - catalog_max_bytes=_skills_positive_int( - skills_raw.get("catalog_max_bytes"), - default=8192, - label="catalog_max_bytes", - ), - resource_max_bytes=_skills_positive_int( - skills_raw.get("resource_max_bytes"), - default=65536, - label="resource_max_bytes", - ), - search_max_results=_skills_positive_int( - skills_raw.get("search_max_results"), - default=50, - label="search_max_results", - ), - search_max_output_bytes=_skills_positive_int( - skills_raw.get("search_max_output_bytes"), - default=32768, - label="search_max_output_bytes", - ), - script_timeout_ms=_skills_positive_int( - skills_raw.get("script_timeout_ms"), - default=30000, - label="script_timeout_ms", - maximum=MAX_SCRIPT_TIMEOUT_MS, - ), - script_max_output_bytes=_skills_positive_int( - skills_raw.get("script_max_output_bytes"), - default=65536, - label="script_max_output_bytes", - ), - ), - mcp=MCPConfig( - enabled=as_bool(mcp_raw.get("enabled", False), default=False), - servers=_read_mcp_servers(raw_mcp_servers if isinstance(raw_mcp_servers, list) else []), - ), - providers=_read_providers_safe(raw_providers, style_profiles), - personas=personas, - daily_summary=DailySummaryConfig( - enabled=as_bool(daily_summary_raw.get("enabled", False), default=False), - generate_cron=str(daily_summary_raw.get("generate_cron", "0 6 * * *")).strip() - or "0 6 * * *", - publish_cron=str(daily_summary_raw.get("publish_cron", "0 12 * * *")).strip() - or "0 12 * * *", - min_messages=max(1, int(daily_summary_raw.get("min_messages", 30))), - summary_length_hint=max(100, int(daily_summary_raw.get("summary_length_hint", 2000))), - model_cascade=[ - str(item).strip() - for item in daily_summary_raw.get("model_cascade", []) - if str(item).strip() - ], - ), - daily_briefing=DailyBriefingConfig( - enabled=as_bool(daily_briefing_raw.get("enabled", False), default=False), - morning_cron=str(daily_briefing_raw.get("morning_cron", "0 8 * * *")).strip() - or "0 8 * * *", - noon_cron=str(daily_briefing_raw.get("noon_cron", "0 12 * * *")).strip() - or "0 12 * * *", - evening_cron=str(daily_briefing_raw.get("evening_cron", "0 22 * * *")).strip() - or "0 22 * * *", - min_messages_for_llm=max(1, int(daily_briefing_raw.get("min_messages_for_llm", 5))), - active_users_limit=max(1, int(daily_briefing_raw.get("active_users_limit", 5))), - hot_words_limit=max(1, int(daily_briefing_raw.get("hot_words_limit", 5))), - sample_messages_limit=max(1, int(daily_briefing_raw.get("sample_messages_limit", 60))), - max_context_chars=max(1000, int(daily_briefing_raw.get("max_context_chars", 40000))), - max_output_chars=max(80, int(daily_briefing_raw.get("max_output_chars", 320))), - model_cascade=[ - str(item).strip() - for item in daily_briefing_raw.get("model_cascade", []) - if str(item).strip() - ], - ), - weekly_report=WeeklyReportConfig( - enabled=as_bool(weekly_report_raw.get("enabled", False), default=False), - generate_cron=str(weekly_report_raw.get("generate_cron", "0 9 * * 1")).strip() - or "0 9 * * 1", - publish_cron=str(weekly_report_raw.get("publish_cron", "0 10 * * *")).strip() - or "0 10 * * *", - min_messages=max(1, int(weekly_report_raw.get("min_messages", 100))), - length_hint=max(200, int(weekly_report_raw.get("length_hint", 2000))), - model_cascade=[ - str(item).strip() - for item in weekly_report_raw.get("model_cascade", []) - if str(item).strip() - ], - ), - monthly_report=MonthlyReportConfig( - enabled=as_bool(monthly_report_raw.get("enabled", False), default=False), - generate_cron=str(monthly_report_raw.get("generate_cron", "0 9 1 * *")).strip() - or "0 9 1 * *", - publish_cron=str(monthly_report_raw.get("publish_cron", "0 10 * * *")).strip() - or "0 10 * * *", - min_messages=max(1, int(monthly_report_raw.get("min_messages", 300))), - length_hint=max(200, int(monthly_report_raw.get("length_hint", 2500))), - input_char_budget=max( - 8_000, int(monthly_report_raw.get("input_char_budget", 240_000)) - ), - model_cascade=[ - str(item).strip() - for item in monthly_report_raw.get("model_cascade", []) - if str(item).strip() - ], - ), - image_preprocessing=ImagePreprocessingConfig( - enabled=as_bool(image_preprocessing_raw.get("enabled", False), default=False), - provider_id=str(image_preprocessing_raw.get("provider_id", "")).strip(), - model=str(image_preprocessing_raw.get("model", "")).strip(), - max_tokens=max( - 80, - min( - int(image_preprocessing_raw.get("max_tokens", 300)), - MAX_IMAGE_PREPROCESSING_TOKENS, - ), - ), - temperature=float(image_preprocessing_raw.get("temperature", 0.3)), - prompt=str(image_preprocessing_raw.get("prompt", "")).strip(), - ), - style_profiles=style_profiles, - pricing=_read_pricing(raw_pricing), - source_path=config_path, - ) - - # 上游重试策略盖章:[runtime] 段是唯一配置入口,统一注入各 provider, - # 使所有 LLM 调用方经 build_provider_client 零改动继承全局重试参数。 - for provider in config.providers.values(): - provider.retry_max_attempts = config.runtime.retry_max_attempts - provider.retry_base_delay = config.runtime.retry_base_delay - provider.retry_jitter = config.runtime.retry_jitter - - try: - _validate_and_fix_config(config) - except Exception: - logger.exception("配置校验阶段异常") - config.load_error = "配置校验时发生内部错误" - return config - + """Load legacy TOML for migration tooling and compatibility tests.""" + from quickquip.llm.config_legacy import load_llm_config as load_legacy -def _validate_and_fix_config(config: LLMConfig) -> None: - """Validate cross-references and fix salvageable issues. + return load_legacy(path) - Single-provider problems result in that provider being skipped (logged); - only truly fatal issues (no providers at all, no personas at all) set - ``load_error``. This ensures one bad provider config never blocks others. - Two observability principles: a fallback after the *explicitly configured* - default provider is pruned is recorded in ``load_error`` (fail-visible, - same as an unknown default_provider; auto-selected defaults re-fall-back - silently), and disabled features' model_cascades are not validated at all. - """ - errors: list[str] = [] - - if not config.providers: - config.load_error = "LLM 配置中没有可用的 providers" - return - - # -- default_provider fallback -- - # 用户在配置里显式写的默认 provider(可能为 None):剪除后回退只有对显式值 - # 才记 load_error;自动选择的再回退保持静默(维持"尽力可用"语义)。 - explicit_default_provider = config.runtime.default_provider - if config.runtime.default_provider is None: - config.runtime.default_provider = next(iter(config.providers)) - elif config.runtime.default_provider not in config.providers: - errors.append(f"默认 provider {config.runtime.default_provider!r} 不存在,已回退") - config.runtime.default_provider = next(iter(config.providers)) - - # -- personas -- - if not config.personas: - config.load_error = "LLM 配置中没有可用的人格" - return - if config.runtime.default_persona is None: - config.runtime.default_persona = next(iter(config.personas)) - elif config.runtime.default_persona not in config.personas: - fallback = next(iter(config.personas)) - errors.append( - f"默认 persona {config.runtime.default_persona!r} 不存在,已回退为 {fallback!r}" - ) - config.runtime.default_persona = fallback - - # -- tools -- - if config.tools.discovery_mode not in {"off", "on", "auto"}: - config.tools.discovery_mode = "auto" - - # -- per-provider validation: collect and prune -- - bad_providers: list[str] = [] - for pid, provider in config.providers.items(): - provider_errors: list[str] = [] - if provider.protocol not in {"openai", "claude", "gemini", "openai_responses"}: - provider_errors.append(f"未知协议 {provider.protocol!r}") - # openai_responses 专属键的词表校验(模块级常量单源,供 - # provider/openai_responses 反向引用与守护测试对齐)。 - if provider.protocol == "openai_responses": - if provider.responses_profile not in RESPONSES_PROFILE_IDS: - provider_errors.append( - f"非法 responses_profile {provider.responses_profile!r}" - f"(可用:{' / '.join(sorted(RESPONSES_PROFILE_IDS))})" - ) - # 思考档位全协议校验(wire 翻译/钳制见 thinking.resolve_thinking)。 - if provider.reasoning_effort not in ("", *REASONING_EFFORT_CHOICES): - provider_errors.append( - f"非法 reasoning_effort {provider.reasoning_effort!r}" - f"(可用:{'/'.join(REASONING_EFFORT_CHOICES)},留空不发送)" - ) - if provider.auth_method not in {"api_key", "bearer"}: - provider_errors.append( - f"未知 auth_method {provider.auth_method!r}(仅支持 api_key / bearer)" - ) - if provider.protocol == "claude" and provider.cache_ttl not in ("", "5m", "1h"): - provider_errors.append( - f"非法 cache_ttl {provider.cache_ttl!r}(claude 仅支持 5m / 1h,留空=默认 5min)" - ) - if provider.builtin_search and provider.protocol != "gemini": - # 非 gemini 协议不剪除 provider:键误配只影响该键本身,记录 - # warning 即可,请求级生效由 provider_builtin_search_active 兜底为惰性。 - logger.warning( - "provider %s 的 builtin_search 仅支持 gemini 协议,当前协议 %r 下该配置不生效", - pid, - provider.protocol, - ) - if not provider.base_url: - provider_errors.append("缺少 base_url") - if not provider.api_key_env: - provider_errors.append("缺少 api_key_env") - if not provider.default_model: - provider_errors.append("缺少 default_model") - if provider.default_model and provider.default_model not in provider.models: - provider.models.insert(0, provider.default_model) - logger.warning( - "provider %s 的 default_model %r 不在 models 列表中,已自动添加", - pid, - provider.default_model, - ) - - if provider_errors: - logger.error("provider %s 配置无效:%s,已跳过", pid, "; ".join(provider_errors)) - bad_providers.append(pid) - - for pid in bad_providers: - del config.providers[pid] - - # Re-validate default_provider in case it was among the deleted ones. - if config.runtime.default_provider not in config.providers: - if config.providers: - fallback = next(iter(config.providers)) - # Fail-visible,与上方 default_provider 指向不存在 id 的语义一致: - # 静默换用与配置不符的 provider 比显式拒绝更糟。全部剪除时不重复记录 - # (下方聚合错误已覆盖);自动选择的再回退不记录。 - if ( - explicit_default_provider is not None - and config.runtime.default_provider == explicit_default_provider - ): - errors.append( - f"默认 provider {explicit_default_provider!r} 已被剪除,已回退为 {fallback!r}" - ) - config.runtime.default_provider = fallback - else: - config.runtime.default_provider = None - - if bad_providers and not config.providers: - errors.append(f"全部 {len(bad_providers)} 个 provider 均被跳过:{', '.join(bad_providers)}") - - # -- cross-reference validation (non-fatal, augments errors) -- - if config.image_preprocessing.enabled and config.image_preprocessing.provider_id: - if config.image_preprocessing.provider_id not in config.providers: - errors.append( - f"image_preprocessing.provider_id {config.image_preprocessing.provider_id!r} 不存在" - ) +def load_personas_only(config_path: str | Path) -> dict[str, PersonaConfig]: + """Load independent persona files from the configuration directory.""" + from quickquip.llm.personas import load_personas - for cascade_name, feature_enabled, cascade_list in [ - ( - "daily_summary.model_cascade", - config.daily_summary.enabled, - config.daily_summary.model_cascade, - ), - ( - "daily_briefing.model_cascade", - config.daily_briefing.enabled, - config.daily_briefing.model_cascade, - ), - ( - "weekly_report.model_cascade", - config.weekly_report.enabled, - config.weekly_report.model_cascade, - ), - ( - "monthly_report.model_cascade", - config.monthly_report.enabled, - config.monthly_report.model_cascade, - ), - ]: - if not feature_enabled: - continue - for entry in cascade_list: - provider_id = entry.split("/", 1)[0].strip() - if provider_id == "@default": - continue - if provider_id not in config.providers: - errors.append(f"{cascade_name} 引用了不存在的 provider {provider_id!r}") - - if errors: - config.load_error = "; ".join(errors) - logger.warning("LLM 配置存在问题:%s", config.load_error) + return load_personas(Path(config_path).parent / "personas") diff --git a/src/quickquip/llm/config_cli.py b/src/quickquip/llm/config_cli.py new file mode 100644 index 00000000..a860aa15 --- /dev/null +++ b/src/quickquip/llm/config_cli.py @@ -0,0 +1,165 @@ +"""Configuration administration: python -m quickquip.llm.config_cli --help.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +import uuid + +from quickquip.common.env import load_root_env_file +from quickquip.common.paths import CONFIG_LLM_TOML +from quickquip.llm.config_document import ConfigError, validate_document +from quickquip.llm.config_service import ConfigService, DEFAULT_CONFIG_DB +from quickquip.llm.config_validation import validation_report + +MAX_IMPORT_BYTES = 1024 * 1024 + + +def read_document(path: Path) -> dict: + with path.open("rb") as stream: + body = stream.read(MAX_IMPORT_BYTES + 1) + if len(body) > MAX_IMPORT_BYTES: + raise ConfigError("body_too_large", "JSON 配置不得超过 1 MiB") + try: + document = json.loads(body) + except (UnicodeError, ValueError): + raise ConfigError("invalid_json", "配置文件须为 UTF-8 JSON") from None + return validate_document(document) + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="QuickQuip LLM 配置数据库管理") + p.add_argument("--db", type=Path, default=DEFAULT_CONFIG_DB, help="配置库路径") + commands = p.add_subparsers(dest="command", required=True) + for name in ("init", "migrate-legacy"): + q = commands.add_parser(name) + q.add_argument("--legacy", type=Path, default=CONFIG_LLM_TOML) + if name == "migrate-legacy": + q.add_argument("--expected-revision", type=int) + q = commands.add_parser("validate") + q.add_argument("file", nargs="?", type=Path) + q = commands.add_parser("export") + q.add_argument("--source", default="draft", help="draft / published / 快照修订号") + q.add_argument("--output", type=Path) + q = commands.add_parser("import") + q.add_argument("file", type=Path) + q.add_argument("--expected-revision", type=int, required=True) + q.add_argument("--preview", action="store_true") + q = commands.add_parser("diff") + q.add_argument("--source", default="draft") + q.add_argument("--target", default="published") + q = commands.add_parser("apply") + q.add_argument("--expected-revision", type=int, required=True) + q.add_argument("--request-id", default=None) + q = commands.add_parser("status") + q.add_argument("--operation", help="查询异步任务 ID") + q.add_argument("--require-loaded", action="store_true", help="检查 bot 心跳及发布版本已加载") + q = commands.add_parser("backup") + q.add_argument("file", type=Path) + q = commands.add_parser("restore") + source = q.add_mutually_exclusive_group(required=True) + source.add_argument("--file", type=Path) + source.add_argument("--snapshot", type=int) + q.add_argument( + "--services-stopped", action="store_true", help="确认 bot 和 Web 已停止,数据库恢复必须提供" + ) + q.add_argument("--expected-revision", type=int) + return p + + +def _source(value: str): + if value in ("draft", "published"): + return value + try: + return int(value) + except ValueError: + raise ConfigError("invalid_field", "配置来源须为 draft、published 或快照修订号") from None + + +def execute(args) -> dict: + service = ConfigService(args.db) + if args.command == "init": + return service.initialize(args.legacy) + if args.command == "validate" and args.file: + doc = read_document(args.file) + return validation_report(doc) + if args.command == "migrate-legacy": + if not service.path.exists(): + return service.initialize(args.legacy) + if args.expected_revision is None: + raise ConfigError("missing_revision", "导入旧配置到草稿须指定 --expected-revision") + return service.migrate_legacy(args.legacy, args.expected_revision) + # Existing database failures never fall back to the legacy file. + if not service.path.exists(): + raise ConfigError("not_initialized", "请先执行 init 初始化或迁移配置") + if args.command == "validate": + state = service.read() + return {**service.validate(state["document"]), "revision": state["revision"]} + if args.command == "export": + doc = service.read(_source(args.source))["document"] + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n" + ) + return {"output": str(args.output)} + return doc + if args.command == "import": + doc = read_document(args.file) + if args.preview: + return service.preview_import(doc, args.expected_revision) + return service.replace(doc, args.expected_revision) + if args.command == "diff": + return service.diff(_source(args.source), _source(args.target)) + if args.command == "apply": + return service.apply(args.expected_revision, args.request_id or uuid.uuid4().hex) + if args.command == "status": + status = service.tasks.get(args.operation) if args.operation else service.status() + if args.require_loaded: + bot = status.get("bot") + if not bot or bot["stale"] or bot["loaded_revision"] != status["published_revision"]: + raise ConfigError("bot_not_loaded", "bot 心跳过期或尚未加载发布版本") + return status + if args.command == "backup": + return {"backup": str(service.backup(args.file))} + if args.command == "restore": + if args.file: + return service.restore_database(args.file, stopped=args.services_stopped) + if args.expected_revision is None: + raise ConfigError("missing_revision", "恢复快照须指定 --expected-revision") + return service.restore_snapshot(args.snapshot, args.expected_revision) + raise ConfigError("invalid_command", "不支持的配置操作") + + +def main(argv=None) -> int: + args = parser().parse_args(argv) + load_root_env_file() + try: + result = execute(args) + except ConfigError as error: + print(json.dumps({"error": error.as_dict()}, ensure_ascii=False), file=sys.stderr) + return 1 + except OSError: + print( + json.dumps( + { + "error": { + "code": "file_error", + "message": "配置文件操作失败", + "field_path": "", + "details": None, + } + }, + ensure_ascii=False, + ), + file=sys.stderr, + ) + return 1 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quickquip/llm/config_document.py b/src/quickquip/llm/config_document.py new file mode 100644 index 00000000..2d99af52 --- /dev/null +++ b/src/quickquip/llm/config_document.py @@ -0,0 +1,550 @@ +"""Strict JSON exchange, reference validation and runtime configuration compilation.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict, replace +import json +from pathlib import Path +import re +from typing import Any +from urllib.parse import urlsplit, parse_qsl + +from quickquip.common.config_utils import expand_env_value +from quickquip.common.paths import CONFIG_DIR +from quickquip.llm import config as c +from quickquip.llm.config_errors import ConfigError +from quickquip.llm.config_fields import ( + SETTINGS, + PARAMETER_TYPES, + check_fields, + check_value, + parameters, + reject, + metadata, +) +from quickquip.llm.personas import load_personas + +__all__ = [ + "ConfigError", + "empty_document", + "validate_document", + "compile_document", + "metadata", + "dependencies", + "resolve_model_provider", + "effective_parameters", + "environment_references", +] +FORMAT_VERSION = 1 +ROUTES = ("daily_summary", "daily_briefing", "weekly_report", "monthly_report") +_VARIABLE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z") +_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") +_SECRET_KEY = re.compile( + r"(?:(?:.*[-_])?authorization|x-api-key|key|api[_-]?key|access[_-]?token|" + r"refresh[_-]?token|token|password|secret|cookie|.*[_-](?:token|secret|password|key))$", + re.I, +) + + +def empty_document() -> dict: + settings = {name: asdict(cls()) for name, cls in SETTINGS.items()} + for name in ROUTES: + settings[name].pop("model_cascade") + return { + "format_version": FORMAT_VERSION, + "providers": [], + "style_profiles": {}, + "pricing": [], + "routes": {name: [] for name in ROUTES}, + "settings": settings, + } + + +def _object(value: Any, path: str, allowed: set[str] | None = None) -> dict: + if not isinstance(value, dict): + reject(path, "需要对象") + if allowed is not None: + for key in value: + if key not in allowed: + reject(f"{path}.{key}", "未知字段") + return value + + +def _list(value: Any, path: str) -> list: + if not isinstance(value, list): + reject(path, "需要数组") + return value + + +def _text(value: Any, path: str, *, nonempty=False) -> str: + if not isinstance(value, str) or (nonempty and not value.strip()): + reject(path, "需要非空字符串" if nonempty else "需要字符串") + if "\x00" in value: + reject(path, "字符串包含非法字符") + return value + + +def _secrets(value: Any, path: str = "") -> None: + if isinstance(value, dict): + for key, child in value.items(): + location = f"{path}.{key}" if path else key + if _SECRET_KEY.fullmatch(key) and child: + if not isinstance(child, str) or not _REFERENCE.search(child): + reject(location, "认证值须迁入根目录 .env 并保存变量引用", "literal_secret") + residual = _REFERENCE.sub("", child).strip() + if residual.lower() not in ("", "bearer", "basic"): + reject(location, "认证字段只允许环境变量引用", "literal_secret") + _secrets(child, location) + elif isinstance(value, list): + for i, child in enumerate(value): + _secrets(child, f"{path}[{i}]") + elif isinstance(value, str) and value.startswith(("http://", "https://")): + parts = urlsplit(value) + if parts.username or parts.password: + reject(path, "地址中的认证值须使用独立变量引用", "literal_secret") + for key, child in parse_qsl(parts.query): + if _SECRET_KEY.fullmatch(key) and child: + if not _REFERENCE.search(child) or _REFERENCE.sub("", child).strip(): + reject(path, "地址中的认证值须完全使用环境变量引用", "literal_secret") + + +def environment_references(doc: dict) -> list[str]: + found = set() + + def visit(value: Any) -> None: + if isinstance(value, dict): + for key, child in value.items(): + if key == "api_key_env" and child: + found.add(child) + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + elif isinstance(value, str): + found.update(_REFERENCE.findall(value)) + + visit(doc) + return sorted(found) + + +def _validate_mcp(raw: dict) -> dict: + _object(raw, "settings.mcp", {"enabled", "servers"}) + check_value(raw.get("enabled", False), bool, "settings.mcp.enabled", "enabled") + result = {"enabled": raw.get("enabled", False), "servers": []} + seen = set() + for i, server in enumerate(_list(raw.get("servers", []), "settings.mcp.servers")): + path = f"settings.mcp.servers[{i}]" + check_fields(server, c.MCPServerConfig, path) + ident = _text(server.get("id"), path + ".id", nonempty=True) + if ident in seen: + reject(path + ".id", "MCP ID 重复") + seen.add(ident) + item = asdict(c.MCPServerConfig(**server)) + for key in ("transport", "negotiation", "pull_policy"): + check_value(item[key], str, path + "." + key, "mcp." + key) + if item["negotiation"] != "legacy": + if item["transport"] != "http" or not item["supported_protocol_versions"]: + reject(path, "auto/modern 协商需要 HTTP 与支持的协议版本") + result["servers"].append(item) + return result + + +def validate_document(document: Any) -> dict: + """Normalize a complete replacement; no secret expansion or silent clamping.""" + allowed = {"format_version", "providers", "style_profiles", "pricing", "routes", "settings"} + _object(document, "", allowed) + if type(document.get("format_version")) is not int or document["format_version"] != 1: + reject("format_version", "不支持的配置交换格式", "unsupported_format") + _secrets(document) + # Enforce JSON and reject NaN, unserializable objects and deep non-string key coercion. + try: + doc = json.loads(json.dumps(document, ensure_ascii=False, allow_nan=False)) + except (ValueError, TypeError, RecursionError): + reject("", "配置须为合法 JSON") + output = empty_document() + styles = _object(doc.get("style_profiles", {}), "style_profiles") + for key, value in styles.items(): + _text(key, "style_profiles", nonempty=True) + _text(value, f"style_profiles.{key}") + output["style_profiles"] = styles + providers = _list(doc.get("providers", []), "providers") + ids = set() + models = {} + for i, raw in enumerate(providers): + path = f"providers[{i}]" + _object( + raw, + path, + { + "id", + "protocol", + "base_url", + "api_key_env", + "default_model", + "enabled", + "headers", + "fallback_urls", + "proxy", + "auth_method", + "parameters", + "models", + "aliases", + }, + ) + ident = _text(raw.get("id"), path + ".id", nonempty=True) + if not re.fullmatch(r"[A-Za-z0-9_.-]+", ident) or ident in ids: + reject(path + ".id", "渠道 ID 须唯一且仅含字母、数字、点、下划线及连字符") + ids.add(ident) + if raw.get("protocol") not in metadata()["protocols"]: + reject(path + ".protocol", "不支持的协议") + for key in ("base_url", "api_key_env", "default_model"): + _text(raw.get(key), path + "." + key, nonempty=True) + if not _VARIABLE.fullmatch(raw["api_key_env"]): + reject(path + ".api_key_env", "需要环境变量名称") + if raw.get("auth_method", "api_key") not in ("api_key", "bearer"): + reject(path + ".auth_method", "认证方式无效") + base = { + "enabled": True, + "headers": {}, + "fallback_urls": [], + "proxy": "", + "auth_method": "api_key", + "aliases": [], + "parameters": {}, + } + base.update(raw) + check_value(base["enabled"], bool, path + ".enabled", "enabled") + check_value(base["headers"], dict[str, str], path + ".headers", "headers") + check_value(base["fallback_urls"], list[str], path + ".fallback_urls", "fallback_urls") + _text(base["proxy"], path + ".proxy") + parameters(base["parameters"], path + ".parameters") + names = set() + entries = [] + for j, entry in enumerate(_list(raw.get("models", []), path + ".models")): + location = f"{path}.models[{j}]" + _object(entry, location, {"model", "enabled", "vision", "context_window", "parameters"}) + name = _text(entry.get("model"), location + ".model", nonempty=True) + if name in names: + reject(location + ".model", "模型名重复") + names.add(name) + model = {"enabled": True, "vision": True, "context_window": None, "parameters": {}} + model.update(entry) + for key in ("enabled", "vision"): + check_value(model[key], bool, location + "." + key, key) + capacity = model["context_window"] + if capacity is not None and (type(capacity) is not int or capacity < 1024): + reject(location + ".context_window", "模型容量须为至少 1024 的整数或 null") + parameters(model["parameters"], location + ".parameters") + entries.append(model) + base["models"] = entries + models[ident] = names + if base["default_model"] not in names: + reject(path + ".default_model", "默认模型不存在", "dependency_conflict") + aliases = set() + for j, alias in enumerate(_list(base["aliases"], path + ".aliases")): + location = f"{path}.aliases[{j}]" + _object(alias, location, {"alias", "model"}) + name = _text(alias.get("alias"), location + ".alias", nonempty=True) + if name in aliases or name in names: + reject(location + ".alias", "别名重复或与模型名冲突") + aliases.add(name) + if alias.get("model") not in names: + reject(location + ".model", "别名目标不存在", "dependency_conflict") + for request in [base["parameters"], *[m["parameters"] for m in entries]]: + style = request.get("style_profile") + if style and style not in styles: + reject(path + ".parameters.style_profile", "风格引用不存在", "dependency_conflict") + output["providers"].append(base) + raw_settings = _object(doc.get("settings", {}), "settings", set(SETTINGS)) + for name, cls in SETTINGS.items(): + raw = raw_settings.get(name, {}) + if name == "mcp": + output["settings"][name] = _validate_mcp(raw) + else: + check_fields(raw, cls, "settings." + name, exclude=("model_cascade",)) + output["settings"][name].update(raw) + rt = output["settings"]["runtime"] + if rt["default_provider"] is not None and rt["default_provider"] not in ids: + reject("settings.runtime.default_provider", "默认渠道不存在", "dependency_conflict") + if rt["enabled"] and not providers: + reject("settings.runtime.enabled", "启用 LLM 需要至少一个渠道") + if rt["reply_split_threshold_chars"] > rt["reply_chunk_max_chars"]: + reject("settings.runtime.reply_split_threshold_chars", "拆分阈值不能超过分段上限") + routes = _object(doc.get("routes", {}), "routes", set(ROUTES)) + for name in ROUTES: + output["routes"][name] = _list(routes.get(name, []), "routes." + name) + for j, entry in enumerate(output["routes"][name]): + location = f"routes.{name}[{j}]" + if entry == "@default": + continue + _reference(entry, models, location) + for name in ("quick_judge", "image_preprocessing"): + selection = output["settings"][name] + if selection["provider_id"]: + _reference( + {"provider_id": selection["provider_id"], "model": selection["model"]}, + models, + "settings." + name, + allow_default=True, + ) + elif selection["model"]: + reject("settings." + name + ".provider_id", "指定模型需要渠道") + seen_prices = set() + for i, entry in enumerate(_list(doc.get("pricing", []), "pricing")): + location = f"pricing[{i}]" + _object(entry, location, {"provider_id", "model", *asdict(c.PricingRates())}) + _text(entry.get("model"), location + ".model", nonempty=True) + provider = entry.get("provider_id") + if provider is not None: + _text(provider, location + ".provider_id", nonempty=True) + price_id = (provider, entry["model"]) + if price_id in seen_prices: + reject(location, "价格规则重复") + seen_prices.add(price_id) + rates = {k: v for k, v in entry.items() if k not in ("provider_id", "model")} + check_fields(rates, c.PricingRates, location) + for key, value in rates.items(): + if key.endswith("per_mtok") and value is not None and value < 0: + reject(location + "." + key, "价格须为非负数或 null") + output["pricing"].append( + {"provider_id": provider, "model": entry["model"], **asdict(c.PricingRates(**rates))} + ) + _validate_epoch_graph(output) + return output + + +def _reference(entry: Any, models: dict, path: str, *, allow_default=False) -> None: + _object(entry, path, {"provider_id", "model"}) + provider, model = entry.get("provider_id"), entry.get("model") + if provider not in models or ( + model not in models[provider] and not (allow_default and model == "") + ): + reject(path, "渠道或模型引用不存在", "dependency_conflict", {"reference": entry}) + + +def _validate_epoch_graph(doc: dict) -> None: + runtime = c.RuntimeConfig(**doc["settings"]["runtime"]) + config = c.LLMConfig(runtime=runtime) + pairs = c._EPOCH_OVERRIDE_PAIRS + from quickquip.llm.epoch import EpochParams + + def valid(overrides: dict, path: str) -> None: + values = { + p: overrides.get(k) if overrides.get(k) is not None else getattr(runtime, k) + for k, p in pairs + } + if not c._epoch_params_valid(EpochParams(**values)): + reject(path, "纪元参数须满足 0 < cold_target < cold_trigger ≤ hot_target < cap") + + valid({}, "settings.runtime") + del config + for provider in doc["providers"]: + valid(provider["parameters"], f"providers.{provider['id']}.parameters") + for model in provider["models"]: + merged = {k: v for k, v in provider["parameters"].items() if v is not None} + merged.update({k: v for k, v in model["parameters"].items() if v is not None}) + valid(merged, f"providers.{provider['id']}.models.{model['model']}.parameters") + + +def dependencies(document: dict) -> list[dict]: + """List config edges using public identities, including disabled features.""" + edges = [] + + def edge(path: str, provider: str, model: str | None = None) -> None: + target = {"provider_id": provider} + if model: + target["model"] = model + edges.append({"field_path": path, "target": target}) + + rt = document["settings"]["runtime"] + if rt["default_provider"]: + edge("settings.runtime.default_provider", rt["default_provider"]) + for name, entries in document["routes"].items(): + for i, entry in enumerate(entries): + if entry != "@default": + edge(f"routes.{name}[{i}]", entry["provider_id"], entry["model"]) + for name in ("quick_judge", "image_preprocessing"): + s = document["settings"][name] + if s["provider_id"]: + edge("settings." + name, s["provider_id"], s["model"]) + for provider in document["providers"]: + pid = provider["id"] + edge(f"providers.{pid}.default_model", pid, provider["default_model"]) + for alias in provider["aliases"]: + edge(f"providers.{pid}.aliases.{alias['alias']}", pid, alias["model"]) + for location, params in [ + (f"providers.{pid}.parameters", provider["parameters"]), + *[ + (f"providers.{pid}.models.{m['model']}.parameters", m["parameters"]) + for m in provider["models"] + ], + ]: + if params.get("style_profile"): + edges.append( + { + "field_path": location + ".style_profile", + "target": {"style_profile": params["style_profile"]}, + } + ) + return edges + + +def compile_document( + document: dict, config_dir: Path | str | None = None, revision=0 +) -> c.LLMConfig: + doc = validate_document(document) + directory = Path(config_dir) if config_dir is not None else CONFIG_DIR + config = c.LLMConfig(revision=revision, source_path=directory.parent / "data/llm_config.db") + for name, cls in SETTINGS.items(): + if name == "mcp": + mcp = doc["settings"][name] + config.mcp = c.MCPConfig( + enabled=mcp["enabled"], + servers=[c.MCPServerConfig(**expand_env_value(s)) for s in mcp["servers"]], + ) + else: + setattr(config, name, cls(**deepcopy(doc["settings"][name]))) + config.personas = load_personas(directory / "personas") + config.style_profiles = deepcopy(expand_env_value(doc["style_profiles"])) + for raw in doc["providers"]: + fixed = { + k: expand_env_value(raw[k]) + for k in ( + "id", + "protocol", + "base_url", + "api_key_env", + "default_model", + "enabled", + "headers", + "fallback_urls", + "proxy", + "auth_method", + ) + } + values = {k: expand_env_value(v) for k, v in raw["parameters"].items() if v is not None} + profile = values.pop("style_profile", "") + append = values.get("style_overrides", "") + fixed.update(values) + provider = c.ProviderConfig(**fixed, models=[m["model"] for m in raw["models"]]) + provider.model_parameters = {m["model"]: deepcopy(m["parameters"]) for m in raw["models"]} + provider.model_enabled = {m["model"]: m["enabled"] for m in raw["models"]} + provider.non_vision_models = [m["model"] for m in raw["models"] if not m["vision"]] + provider.model_context_windows = { + m["model"]: m["context_window"] + for m in raw["models"] + if m["context_window"] is not None + } + provider.aliases = {a["alias"]: a["model"] for a in raw["aliases"]} + provider.style_profile, provider.style_append = profile, append + provider.style_overrides = _style(config, profile, append) + for key in ("retry_max_attempts", "retry_base_delay", "retry_jitter"): + if raw["parameters"].get(key) is None: + setattr(provider, key, getattr(config.runtime, key)) + config.providers[provider.id] = provider + if config.runtime.default_provider is None and config.providers: + config.runtime.default_provider = next( + (p.id for p in config.providers.values() if p.enabled), next(iter(config.providers)) + ) + if config.runtime.default_persona is None and config.personas: + config.runtime.default_persona = next(iter(config.personas)) + for name in ROUTES: + getattr(config, name).model_cascade = [ + "@default" if e == "@default" else f"{e['provider_id']}/{e['model']}" + for e in doc["routes"][name] + ] + for price in doc["pricing"]: + name = price["model"] + if price["provider_id"]: + name = price["provider_id"] + "/" + name + rates = c.PricingRates( + **{k: v for k, v in price.items() if k not in ("provider_id", "model")} + ) + config.pricing_references[(price["provider_id"], price["model"])] = rates + config.pricing[name] = rates + return config + + +def _style(config: c.LLMConfig, profile: str, append: str) -> str: + shared = config.style_profiles.get(profile, "") + return shared + ("\n" + append if shared and append else append) + + +def resolve_model_provider( + config: c.LLMConfig, provider_id: str, model: str, overrides: dict | None = None +) -> c.ProviderConfig: + provider = config.providers.get(provider_id) + if provider is None: + reject("provider_id", "渠道不存在", "dependency_conflict") + wire = provider.aliases.get(model, model or provider.default_model) + if wire not in provider.models: + reject("model", "模型不存在", "dependency_conflict") + if not provider.enabled or not provider.model_enabled.get(wire, True): + reject("model", "渠道或模型已停用", "disabled_model") + changes = { + k: expand_env_value(v) + for k, v in provider.model_parameters.get(wire, {}).items() + if v is not None + } + if overrides: + parameters(overrides, "overrides") + changes.update({k: v for k, v in overrides.items() if v is not None}) + profile = changes.pop("style_profile", provider.style_profile) + append = changes.pop( + "style_overrides", + provider.style_append if provider.style_append is not None else provider.style_overrides, + ) + if "style_overrides" not in changes: + changes["style_overrides"] = _style(config, profile, append) + changes.update(style_profile=profile, style_append=append) + result = replace(provider, **changes) + return result + + +def effective_parameters(document: dict, provider_id: str, model: str) -> dict: + """Return unexpanded values and source for UI; never expose resolved credentials.""" + doc = validate_document(document) + provider = next((p for p in doc["providers"] if p["id"] == provider_id), None) + if provider is None: + reject("provider_id", "渠道不存在", "dependency_conflict") + entry = next((m for m in provider["models"] if m["model"] == model), None) + if entry is None: + reject("model", "模型不存在", "dependency_conflict") + defaults = c.ProviderConfig("", "", "", "", "", []) + output = {} + for key in PARAMETER_TYPES: + value = getattr(defaults, key, "") + source = "builtin" + if ( + key.startswith("epoch_") + or key.startswith("retry_") + or key in ("request_input_token_budget", "agent_replay_loop_tokens") + ): + value = doc["settings"]["runtime"][key] + source = "runtime" + configured = { + "provider": provider["parameters"].get(key), + "model": entry["parameters"].get(key), + } + for layer, params in (("provider", provider["parameters"]), ("model", entry["parameters"])): + if params.get(key) is not None: + value, source = params[key], layer + output[key] = {"configured": configured, "value": value, "source": source} + from quickquip.llm.context_windows import resolve_context_window + + # Capacity lookup uses explicit windows and the existing model family registry. + provider_config = c.ProviderConfig("", "", "", "", "", []) + if entry["context_window"] is not None: + provider_config.model_context_windows[model] = entry["context_window"] + capacity = resolve_context_window(provider_config.model_context_windows, model) + output["context_window"] = { + "configured": entry["context_window"], + "value": capacity, + "source": "model" + if entry["context_window"] is not None + else "automatic" + if capacity + else "unknown", + } + return output diff --git a/src/quickquip/llm/config_durability.py b/src/quickquip/llm/config_durability.py new file mode 100644 index 00000000..8030c7da --- /dev/null +++ b/src/quickquip/llm/config_durability.py @@ -0,0 +1,44 @@ +"""Durable publication of configuration assets and migration recovery evidence.""" + +from __future__ import annotations + +import os +from pathlib import Path +import tempfile + + +def sync_directory(path: Path) -> None: + # Windows does not expose directory handles to fsync through Python's os API. + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def ensure_directory(path: Path) -> None: + missing = [] + directory = path + while not directory.exists(): + missing.append(directory) + directory = directory.parent + path.mkdir(parents=True, exist_ok=True) + for directory in reversed(missing): + sync_directory(directory.parent) + + +def durable_write(path: Path, content: bytes) -> None: + ensure_directory(path.parent) + descriptor, name = tempfile.mkstemp(prefix=".llm-config-write-", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + sync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) diff --git a/src/quickquip/llm/config_errors.py b/src/quickquip/llm/config_errors.py new file mode 100644 index 00000000..7820c963 --- /dev/null +++ b/src/quickquip/llm/config_errors.py @@ -0,0 +1,22 @@ +"""Stable, credential-free configuration errors shared by CLI and HTTP.""" + +from __future__ import annotations + +from typing import Any + + +class ConfigError(ValueError): + def __init__(self, code: str, message: str, field_path: str = "", details: Any = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.field_path = field_path + self.details = details + + def as_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "message": self.message, + "field_path": self.field_path, + "details": self.details, + } diff --git a/src/quickquip/llm/config_fields.py b/src/quickquip/llm/config_fields.py new file mode 100644 index 00000000..5ebe1220 --- /dev/null +++ b/src/quickquip/llm/config_fields.py @@ -0,0 +1,245 @@ +"""Field constraints and override metadata for configuration writes.""" + +from __future__ import annotations + +from dataclasses import MISSING, asdict, fields +import math +from types import UnionType +from typing import Any, Union, get_args, get_origin, get_type_hints + +from quickquip.llm import config as c +from quickquip.llm.config_errors import ConfigError +from quickquip.llm.skills import MAX_SCRIPT_TIMEOUT_MS + +SETTINGS = { + "runtime": c.RuntimeConfig, + "triggers": c.TriggerConfig, + "auto_search": c.AutoSearchConfig, + "quick_judge": c.QuickJudgeConfig, + "tools": c.ToolsConfig, + "skills": c.SkillsConfig, + "mcp": c.MCPConfig, + "daily_summary": c.DailySummaryConfig, + "daily_briefing": c.DailyBriefingConfig, + "weekly_report": c.WeeklyReportConfig, + "monthly_report": c.MonthlyReportConfig, + "image_preprocessing": c.ImagePreprocessingConfig, +} +PROVIDER_FIXED = { + "id", + "protocol", + "base_url", + "api_key_env", + "default_model", + "enabled", + "headers", + "fallback_urls", + "proxy", + "auth_method", + "models", + "aliases", + "non_vision_models", + "model_context_windows", + "model_parameters", + "model_enabled", + "style_profile", + "style_append", +} +PARAMETER_TYPES = { + k: v for k, v in get_type_hints(c.ProviderConfig).items() if k not in PROVIDER_FIXED +} +PARAMETER_TYPES["style_profile"] = str +# Numeric minima from request guard and legacy parser; values are rejected on new writes. +RANGES = { + "timeout_seconds": (0.01, None), + "temperature": (0, 2), + "max_output_tokens": (1, None), + "retry_max_attempts": (0, 20), + "retry_base_delay": (0, None), + "retry_jitter": (0, 1), + "epoch_context_tokens": (1, None), + "epoch_cold_idle_seconds": (0, None), + "epoch_cold_target_tokens": (1, None), + "epoch_cold_trigger_tokens": (1, None), + "epoch_hot_target_tokens": (1, None), + "epoch_cap_tokens": (1, None), + "request_input_token_budget": (1, None), + "max_inline_media_bytes": (0, None), + "agent_replay_loop_tokens": (512, 4_194_304), + "runtime.tool_max_rounds": (1, 16), + "runtime.tool_max_calls_per_round": (1, 32), + "runtime.history_limit": (0, None), + "runtime.history_max_messages_per_group": (1, None), + "runtime.memory_limit": (0, None), + "runtime.memory_max_items_per_group": (1, None), + "runtime.max_prompt_chars": (1, None), + "runtime.auto_memory_max_tokens": (32, None), + "runtime.recent_context_token_budget": (1, None), + "runtime.recent_context_floor_seconds": (0, None), + "runtime.agent_record_retention_days": (1, None), + "runtime.agent_record_max_loops_per_scope": (1, None), + "runtime.agent_record_max_bytes_per_scope": (1, None), + "runtime.reply_split_threshold_chars": (80, None), + "runtime.reply_chunk_max_chars": (80, None), + "runtime.reply_send_interval_ms": (0, 10_000), + "runtime.reply_max_chunks_per_loop": (1, 256), + "runtime.mention_cooldown_seconds": (0, 86_400), + "auto_search.search_max_calls_per_round": (1, 32), + "quick_judge.timeout": (0.5, None), + "quick_judge.max_tokens": (8, None), + "tools.discovery_min_tools": (1, None), + "tools.discovery_search_limit": (1, 20), + "tools.discovery_max_loaded_tools": (1, 64), + "skills.script_timeout_ms": (1, MAX_SCRIPT_TIMEOUT_MS), + "image_preprocessing.max_tokens": (80, c.MAX_IMAGE_PREPROCESSING_TOKENS), + "daily_summary.summary_length_hint": (100, None), + "daily_briefing.max_context_chars": (1000, None), + "daily_briefing.max_output_chars": (80, None), + "weekly_report.length_hint": (200, None), + "monthly_report.length_hint": (200, None), + "monthly_report.input_char_budget": (8_000, None), +} +ENUMS = { + "reasoning_effort": ("", *c.REASONING_EFFORT_CHOICES), + "responses_profile": tuple(sorted(c.RESPONSES_PROFILE_IDS)), + "cache_ttl": ("", "5m", "1h"), + "tools.enabled_mode": ("append", "replace"), + "tools.discovery_mode": ("off", "on", "auto"), + "mcp.transport": ("stdio", "http", "sse", "docker"), + "mcp.negotiation": ("legacy", "auto", "modern"), + "mcp.pull_policy": ("always", "missing", "never"), +} + + +def reject(path: str, message: str, code: str = "invalid_field", details: Any = None) -> None: + raise ConfigError(code, message, path, details) + + +def _matches(value: Any, annotation: Any) -> bool: + origin = get_origin(annotation) + if origin is UnionType: + return any(_matches(value, t) for t in get_args(annotation)) + if annotation is type(None): + return value is None + if annotation is Any: + return True + if annotation is float: + return type(value) in (float, int) and math.isfinite(value) + if annotation in (str, int, bool): + return type(value) is annotation + if origin is list: + return isinstance(value, list) and all(_matches(x, get_args(annotation)[0]) for x in value) + if origin is dict: + kt, vt = get_args(annotation) + return isinstance(value, dict) and all( + _matches(k, kt) and _matches(v, vt) for k, v in value.items() + ) + return isinstance(value, annotation) + + +def check_value(value: Any, annotation: Any, path: str, rule: str, *, inherit=False) -> None: + if value is None and inherit: + return + if not _matches(value, annotation): + reject(path, "字段类型无效") + if value is None: + return + bounds = RANGES.get(rule, RANGES.get(rule.rsplit(".", 1)[-1])) + if bounds and isinstance(value, (int, float)): + low, high = bounds + if value < low or (high is not None and value > high): + reject(path, "数值超出允许范围", details={"minimum": low, "maximum": high}) + choices = ENUMS.get(rule, ENUMS.get(rule.rsplit(".", 1)[-1])) + if choices is not None and value not in choices: + reject(path, "字段枚举无效", details={"enum": choices}) + + +def check_fields(raw: Any, cls: type, path: str, *, exclude=()) -> dict: + if not isinstance(raw, dict): + reject(path, "需要对象") + annotations = get_type_hints(cls) + for key, value in raw.items(): + if key not in annotations or key in exclude: + reject(f"{path}.{key}", "未知字段") + rule = f"{path.rsplit('.', 1)[-1]}.{key}" + check_value(value, annotations[key], f"{path}.{key}", rule) + if cls is c.SkillsConfig and key not in ("enabled", "catalog_dir") and value <= 0: + reject(f"{path}.{key}", "资源上限须为正整数") + if key in ( + "min_messages", + "active_users_limit", + "hot_words_limit", + "sample_messages_limit", + ): + if value < 1: + reject(f"{path}.{key}", "须为正整数") + return raw + + +def parameters(raw: Any, path: str) -> dict: + if not isinstance(raw, dict): + reject(path, "参数需要对象") + for key, value in raw.items(): + if key not in PARAMETER_TYPES: + reject(f"{path}.{key}", "渠道固定字段不能作为请求参数覆盖") + check_value(value, PARAMETER_TYPES[key], f"{path}.{key}", key, inherit=True) + return raw + + +def field_metadata(annotation: Any, rule: str, *, default: Any = None, inherit=False) -> dict: + # 只有 Union/Optional 需要解包取成员;泛型本体(list[str]、dict[str, Any] 等) + # 的 get_args 是类型参数,不能当作成员类型,否则会误判为 string。 + origin = get_origin(annotation) + if origin in (UnionType, Union): + args = get_args(annotation) + nullable = type(None) in args + base = next((x for x in args if x is not type(None)), annotation) + else: + nullable = False + base = annotation + base_origin = get_origin(base) + kind = {str: "string", int: "integer", float: "number", bool: "boolean"}.get( + base, {list: "array", dict: "object"}.get(base_origin, "object") + ) + result = {"type": kind, "nullable": nullable or inherit, "inherit": inherit, "default": default} + if rule in RANGES: + result.update(minimum=RANGES[rule][0], maximum=RANGES[rule][1]) + elif rule.rsplit(".", 1)[-1] in RANGES: + lo, hi = RANGES[rule.rsplit(".", 1)[-1]] + result.update(minimum=lo, maximum=hi) + if rule in ENUMS: + result["enum"] = ENUMS[rule] + elif rule.rsplit(".", 1)[-1] in ENUMS: + result["enum"] = ENUMS[rule.rsplit(".", 1)[-1]] + return result + + +def metadata() -> dict: + settings = {} + for name, cls in SETTINGS.items(): + defaults = asdict(cls()) + settings[name] = { + k: field_metadata(t, f"{name}.{k}", default=defaults[k]) + for k, t in get_type_hints(cls).items() + if k not in ("model_cascade", "servers") + } + parameter_defaults = {} + for f in fields(c.ProviderConfig): + if f.default is not MISSING: + parameter_defaults[f.name] = f.default + elif f.default_factory is not MISSING: + parameter_defaults[f.name] = f.default_factory() + return { + "format_version": 1, + "settings": settings, + "parameters": { + k: { + **field_metadata(t, k, default=parameter_defaults.get(k), inherit=True), + "provider_override": True, + "model_override": True, + } + for k, t in PARAMETER_TYPES.items() + }, + "protocols": ["openai", "claude", "gemini", "openai_responses"], + "route_names": ["daily_summary", "daily_briefing", "weekly_report", "monthly_report"], + } diff --git a/src/quickquip/llm/config_legacy.py b/src/quickquip/llm/config_legacy.py new file mode 100644 index 00000000..0ab53ac0 --- /dev/null +++ b/src/quickquip/llm/config_legacy.py @@ -0,0 +1,1011 @@ +"""Frozen 1.16.1 TOML parser used by migration and compatibility tests.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any +import tomllib + +from quickquip.common.config_utils import as_bool, as_dict, expand_env_value as _expand_env_value +from contextvars import ContextVar +from quickquip.llm.skills import MAX_SCRIPT_TIMEOUT_MS +from quickquip.llm.config import ( + TriggerConfig, + AutoSearchConfig, + QuickJudgeConfig, + RuntimeConfig, + ToolsConfig, + SkillsConfig, + MCPServerConfig, + MCPConfig, + PersonaConfig, + ImagePreprocessingConfig, + ProviderConfig, + PricingRates, + DailySummaryConfig, + DailyBriefingConfig, + WeeklyReportConfig, + MonthlyReportConfig, + LLMConfig, + MAX_IMAGE_PREPROCESSING_TOKENS, + DEFAULT_RETRY_MAX_ATTEMPTS, + DEFAULT_RETRY_BASE_DELAY, + DEFAULT_RETRY_JITTER, + AGENT_REPLAY_LOOP_TOKENS_FLOOR, + AGENT_REPLAY_LOOP_TOKENS_CEILING, + DEFAULT_MAX_INLINE_MEDIA_BYTES, + RESPONSES_PROFILE_IDS, + REASONING_EFFORT_CHOICES, + DEFAULT_RESPONSES_PROFILE_ID, +) + +logger = logging.getLogger(__name__) + +_KNOWN_PERSONA_KEYS = {"id", "display_name", "system_prompt", "style_prompt", "scope"} + + +def _read_personas(raw_personas: list[dict[str, Any]]) -> dict[str, PersonaConfig]: + personas: dict[str, PersonaConfig] = {} + for entry in raw_personas: + entry = expand_env_value(entry) + persona_id = str(entry.get("id", "")).strip() + if not persona_id: + continue + raw_scope = entry.get("scope", []) + if isinstance(raw_scope, str): + raw_scope = [raw_scope] + parsed_scope = [ + s for s in (str(s).strip().lower() for s in raw_scope) if s in {"group", "private"} + ] + extras = {k: v for k, v in entry.items() if k not in _KNOWN_PERSONA_KEYS} + personas[persona_id] = PersonaConfig( + id=persona_id, + display_name=str(entry.get("display_name", persona_id)).strip() or persona_id, + system_prompt=str(entry.get("system_prompt", "")).strip(), + style_prompt=str(entry.get("style_prompt", "")).strip(), + scope=parsed_scope, + extras=extras, + ) + return personas + + +def _optional_float(value: object) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _read_pricing(raw: dict[str, Any]) -> dict[str, PricingRates]: + """解析 [pricing.models.] 子表为 {model: PricingRates}。""" + models_raw = as_dict(raw.get("models")) + pricing: dict[str, PricingRates] = {} + for model_name, entry in models_raw.items(): + name = str(model_name).strip() + if not name or not isinstance(entry, dict): + continue + pricing[name] = PricingRates( + input_per_mtok=float(entry.get("input_per_mtok", 0.0) or 0.0), + output_per_mtok=float(entry.get("output_per_mtok", 0.0) or 0.0), + cache_read_per_mtok=_optional_float(entry.get("cache_read_per_mtok")), + cache_write_per_mtok=_optional_float(entry.get("cache_write_per_mtok")), + source=str(entry.get("source", "config")).strip() or "config", + confidence=str(entry.get("confidence", "configured")).strip() or "configured", + ) + return pricing + + +def _load_personas_from_dir(personas_dir: Path) -> list[dict[str, Any]]: + """Load personas from a directory of TOML files. + + Each file may define a persona using flat top-level keys (id, display_name, + system_prompt, style_prompt, plus any extras), or via a [[personas]] array + for compatibility. + + A special file ``_shared.toml`` may define: + - ``shared_system_prompt``: appended to every persona's system_prompt + - ``shared_style_prompt``: appended to every persona's style_prompt + + Files are loaded in lexicographic order. ``_shared.toml`` is processed + first and never treated as a persona definition itself. + """ + if not personas_dir.is_dir(): + return [] + + # Load shared rules first + shared_system = "" + shared_style = "" + shared_path = personas_dir / "_shared.toml" + if shared_path.exists(): + try: + with shared_path.open("rb") as fh: + shared_data = tomllib.load(fh) + shared_system = str(shared_data.get("shared_system_prompt", "")).strip() + shared_style = str(shared_data.get("shared_style_prompt", "")).strip() + except tomllib.TOMLDecodeError as exc: + logger.warning("跳过损坏的 _shared.toml:%s", exc) + + personas: list[dict[str, Any]] = [] + for toml_file in sorted(personas_dir.glob("*.toml")): + if toml_file.name.startswith("_"): + continue # skip _shared.toml and other reserved files + try: + with toml_file.open("rb") as fh: + data = tomllib.load(fh) + except tomllib.TOMLDecodeError as exc: + logger.warning("跳过损坏的人格文件 %s:%s", toml_file.name, exc) + continue + + # Support flat top-level keys (preferred) or [[personas]] array + if "id" in data: + entry = dict(data) + # Inject shared content + if shared_system: + existing = str(entry.get("system_prompt", "")).rstrip() + entry["system_prompt"] = ( + (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + ) + if shared_style: + existing = str(entry.get("style_prompt", "")).rstrip() + entry["style_prompt"] = ( + (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + ) + personas.append(entry) + elif "personas" in data: + for entry in data["personas"]: + entry = dict(entry) + if shared_system: + existing = str(entry.get("system_prompt", "")).rstrip() + entry["system_prompt"] = ( + (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + ) + if shared_style: + existing = str(entry.get("style_prompt", "")).rstrip() + entry["style_prompt"] = ( + (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + ) + personas.append(entry) + + return personas + + +def _read_providers( + raw_providers: list[dict[str, Any]], + *, + style_profiles: dict[str, str] | None = None, +) -> dict[str, ProviderConfig]: + style_profiles = style_profiles or {} + providers: dict[str, ProviderConfig] = {} + for entry in raw_providers: + entry = expand_env_value(entry) + provider_id = str(entry.get("id", "")).strip() + if not provider_id: + continue + + try: + provider = _parse_single_provider(entry, provider_id, style_profiles) + except Exception: + logger.exception("跳过配置无效的 provider %s", provider_id) + continue + providers[provider_id] = provider + return providers + + +def _parse_single_provider( + entry: dict[str, Any], provider_id: str, style_profiles: dict[str, str] +) -> ProviderConfig: + raw_headers = as_dict(entry.get("headers")) + + style_text = str(entry.get("style_overrides", "")).strip() + profile_name = str(entry.get("style_profile", "")).strip() + if profile_name: + if profile_name not in style_profiles: + available = ", ".join(sorted(style_profiles)) if style_profiles else "(无)" + logger.error( + "provider %s 引用了未知的 style_profile %r,已忽略该 profile(可用:%s)", + provider_id, + profile_name, + available, + ) + else: + shared = style_profiles[profile_name].strip() + style_text = shared + ("\n" + style_text if style_text else "") + + raw_aliases = expand_env_value(as_dict(entry.get("aliases"))) + models = [str(item).strip() for item in entry.get("models", []) if str(item).strip()] + aliases: dict[str, str] = {} + for raw_k, raw_v in raw_aliases.items(): + k = str(raw_k).strip() + v = str(raw_v).strip() + if not k or not v: + continue + if v not in models: + logger.warning("provider %s 的 alias %r 指向不存在的模型 %r,已跳过", provider_id, k, v) + continue + aliases[k] = v + + return ProviderConfig( + id=provider_id, + protocol=str(entry.get("protocol", "")).strip().lower(), + base_url=str(entry.get("base_url", "")).strip(), + api_key_env=str(entry.get("api_key_env", "")).strip(), + default_model=str(entry.get("default_model", "")).strip(), + models=models, + enabled=as_bool(entry.get("enabled", True), default=True), + non_vision_models=[ + str(item).strip() for item in entry.get("non_vision_models", []) if str(item).strip() + ], + timeout_seconds=float(entry.get("timeout_seconds", 45)), + temperature=float(entry.get("temperature", 0.8)), + max_output_tokens=int(entry.get("max_output_tokens", 800)), + style_overrides=style_text, + stream_enabled=as_bool(entry.get("stream_enabled", True), default=True), + headers={str(k): str(v) for k, v in raw_headers.items()}, + user_agent=str(entry.get("user_agent", "")).strip(), + extra_body=expand_env_value(as_dict(entry.get("extra_body"))), + aliases=aliases, + fallback_urls=[ + str(item).strip() for item in entry.get("fallback_urls", []) if str(item).strip() + ], + proxy=str(entry.get("proxy", "")).strip(), + prompt_caching=as_bool(entry.get("prompt_caching"), default=False), + cache_ttl=str(entry.get("cache_ttl", "")).strip(), + auth_method=str(entry.get("auth_method", "api_key")).strip().lower() or "api_key", + builtin_search=as_bool(entry.get("builtin_search"), default=False), + responses_profile=( + str(entry.get("responses_profile", DEFAULT_RESPONSES_PROFILE_ID)).strip().lower() + or DEFAULT_RESPONSES_PROFILE_ID + ), + reasoning_effort=str(entry.get("reasoning_effort", "")).strip().lower(), + epoch_context_tokens=_as_optional_int(entry.get("epoch_context_tokens")), + epoch_cold_idle_seconds=_as_optional_int(entry.get("epoch_cold_idle_seconds")), + epoch_cold_target_tokens=_as_optional_int(entry.get("epoch_cold_target_tokens")), + epoch_cold_trigger_tokens=_as_optional_int(entry.get("epoch_cold_trigger_tokens")), + epoch_hot_target_tokens=_as_optional_int(entry.get("epoch_hot_target_tokens")), + epoch_cap_tokens=_as_optional_int(entry.get("epoch_cap_tokens")), + request_input_token_budget=_nonpositive_as_none(entry.get("request_input_token_budget")), + max_inline_media_bytes=max( + 0, int(entry.get("max_inline_media_bytes", DEFAULT_MAX_INLINE_MEDIA_BYTES)) + ), + agent_replay_loop_tokens=_clamped_optional_int( + entry.get("agent_replay_loop_tokens"), + floor=AGENT_REPLAY_LOOP_TOKENS_FLOOR, + ceiling=AGENT_REPLAY_LOOP_TOKENS_CEILING, + ), + model_context_windows={ + str(name): max(1024, int(size)) + for name, size in as_dict(entry.get("model_context_windows")).items() + }, + ) + + +def _as_optional_int(raw: Any) -> int | None: + """provider 级可选整数键:缺省/空 = None(继承 runtime)。 + + TOML 数直转(float 截断,与既有 ``int(entry.get(...))`` 范式一致);字符串 + 走 ``int()``;解析失败回退 None 并告警——键级笔误不得扩大为整 provider 剪除。 + """ + if raw is None: + return None + if isinstance(raw, (int, float)): + return int(raw) + text = str(raw).strip() + if not text: + return None + try: + return int(text) + except ValueError: + logger.warning("epoch_* 覆盖值 %r 无法解析为整数,回退继承 [runtime]", raw) + return None + + +def _clamped_optional_int(raw: Any, *, floor: int, ceiling: int) -> int | None: + """provider 级可选整数键 + 范围钳制:缺省/不可解析回退 None,越界钳制到边界并告警。""" + value = _as_optional_int(raw) + if value is None: + return None + clamped = min(ceiling, max(floor, value)) + if clamped != value: + logger.warning( + "provider 覆盖值 %d 超出 [%d, %d],已钳制为 %d", value, floor, ceiling, clamped + ) + return clamped + + +def _nonpositive_as_none(raw: Any) -> int | None: + """非正数视为未配置(继承推导/缺省):0/负值不是合法预算,告警不致命。""" + value = _as_optional_int(raw) + if value is not None and value <= 0: + logger.warning("provider 覆盖值 %d 非正数,按未配置处理(继承推导/缺省)", value) + return None + return value + + +_MCP_NEGOTIATION_MODES = {"legacy", "auto", "modern"} + + +def _parse_enabled_mode(raw: Any) -> str: + """解析 [tools] enabled_mode;非法取值回退 append 并告警。""" + mode = str(raw if raw is not None else "append").strip().lower() + if mode not in ("append", "replace"): + if raw is not None: + logger.warning("[tools] enabled_mode 非法取值 %r,回退为 append", raw) + return "append" + return mode + + +def _skills_positive_int(raw: Any, *, default: int, label: str, maximum: int | None = None) -> int: + """[skills] 正整数键:不可解析/非正数回退默认并告警;超过 maximum 钳制并告警。""" + value: int + if isinstance(raw, bool) or raw is None: + value = default + if raw is not None: + logger.warning("[skills] %s 非法取值 %r,回退默认值 %d", label, raw, default) + elif isinstance(raw, (int, float)): + value = int(raw) + else: + try: + value = int(str(raw).strip()) + except ValueError: + logger.warning("[skills] %s 非法取值 %r,回退默认值 %d", label, raw, default) + return default + if value <= 0: + logger.warning("[skills] %s 须为正整数(当前 %r),回退默认值 %d", label, raw, default) + return default + if maximum is not None and value > maximum: + logger.warning("[skills] %s=%d 超过上限 %d,已钳制", label, value, maximum) + return maximum + return value + + +def _read_mcp_servers(raw_servers: list[dict[str, Any]]) -> list[MCPServerConfig]: + servers: list[MCPServerConfig] = [] + seen_ids: set[str] = set() + for entry in raw_servers: + entry = expand_env_value(entry) + server_id = str(entry.get("id", "")).strip() + if not server_id: + continue + + if server_id in seen_ids: + logger.warning("跳过重复的 MCP server id:%s", server_id) + continue + seen_ids.add(server_id) + + negotiation = str(entry.get("negotiation", "legacy")).strip().lower() or "legacy" + if negotiation not in _MCP_NEGOTIATION_MODES: + logger.warning( + "跳过 MCP server %s:未知 negotiation 模式 %r(仅支持 legacy/auto/modern)", + server_id, + negotiation, + ) + continue + + transport = str(entry.get("transport", "stdio")).strip().lower() or "stdio" + supported_versions = [ + str(v).strip() for v in entry.get("supported_protocol_versions", []) if str(v).strip() + ] + + if negotiation in {"auto", "modern"}: + if transport != "http": + logger.warning( + "跳过 MCP server %s:%r 协商模式仅支持 HTTP transport(当前 %r)", + server_id, + negotiation, + transport, + ) + continue + if not supported_versions: + logger.warning( + "跳过 MCP server %s:%r 协商模式需要 supported_protocol_versions", + server_id, + negotiation, + ) + continue + + raw_env = as_dict(entry.get("env")) + raw_headers = as_dict(entry.get("headers")) + servers.append( + MCPServerConfig( + id=server_id, + transport=transport, + enabled=as_bool(entry.get("enabled", True), default=True), + timeout_seconds=float(entry.get("timeout_seconds", 30)), + protocol_version=str(entry.get("protocol_version", "2025-03-26")).strip() + or "2025-03-26", + tool_prefix=str(entry.get("tool_prefix", "")).strip() or None, + include_tools=[ + str(item).strip() + for item in entry.get("include_tools", []) + if str(item).strip() + ], + exclude_tools=[ + str(item).strip() + for item in entry.get("exclude_tools", []) + if str(item).strip() + ], + allowed_tools=[ + str(item).strip() + for item in entry.get("allowed_tools", []) + if str(item).strip() + ], + command=str(entry.get("command", "")).strip(), + args=[str(item) for item in entry.get("args", []) if str(item).strip()], + cwd=str(entry.get("cwd", "")).strip() or None, + env={str(k): str(v) for k, v in raw_env.items()}, + url=str(entry.get("url", "")).strip(), + headers={str(k): str(v) for k, v in raw_headers.items()}, + image=str(entry.get("image", "")).strip(), + docker_command=str(entry.get("docker_command", "docker")).strip() or "docker", + docker_args=[ + str(item) for item in entry.get("docker_args", []) if str(item).strip() + ], + mounts=[str(item).strip() for item in entry.get("mounts", []) if str(item).strip()], + network=str(entry.get("network", "")).strip() or None, + container_workdir=str(entry.get("container_workdir", "")).strip() or None, + negotiation=negotiation, + supported_protocol_versions=supported_versions, + ) + ) + return servers + + +def load_personas_only(config_path: str | Path) -> dict[str, PersonaConfig]: + """Parse only the persona section of llm.toml plus ``config/personas/`` directory. + + Used by hot-reload paths that must not touch providers/MCP/runtime; returns + an empty dict if the config file is missing or defines no personas. + """ + path = Path(config_path) + if not path.exists(): + return {} + + try: + with path.open("rb") as file: + data = tomllib.load(file) + except tomllib.TOMLDecodeError as exc: + logger.warning("load_personas_only 跳过损坏的 TOML:%s", exc) + return {} + + personas_path = path.parent / "personas" + raw_personas = data.get("personas", []) + if not isinstance(raw_personas, list): + raw_personas = [] + if personas_path.is_dir(): + raw_personas = raw_personas + _load_personas_from_dir(personas_path) + return _read_personas(raw_personas) + + +def _read_providers_safe( + raw_providers: Any, style_profiles: dict[str, str] +) -> dict[str, ProviderConfig]: + try: + return _read_providers( + raw_providers if isinstance(raw_providers, list) else [], + style_profiles=style_profiles, + ) + except Exception: + logger.exception("provider 解析阶段发生未预期异常") + return {} + + +def _load_llm_config(path: str | Path) -> LLMConfig: + config_path = Path(path) + if not config_path.exists(): + return LLMConfig(load_error=f"未找到配置文件:{config_path}", source_path=config_path) + + try: + with config_path.open("rb") as file: + data = tomllib.load(file) + except tomllib.TOMLDecodeError as exc: + logger.exception("llm.toml 语法错误") + return LLMConfig(load_error=f"TOML 语法错误:{exc}", source_path=config_path) + + personas = load_personas_only(config_path) + + runtime_raw = expand_env_value(as_dict(data.get("runtime"))) + # 【现场】补丁两键防护:非法取值静默通过会让 budget=0 的「关停」意图 + # 实际仍服役最新 1 条且无告警——与相邻 epoch_* 键的回退范式对齐 + recent_context_token_budget = int(runtime_raw.get("recent_context_token_budget", 800)) + if recent_context_token_budget <= 0: + logger.warning( + "[runtime] recent_context_token_budget=%d 非法(须 > 0),回退默认值 800", + recent_context_token_budget, + ) + recent_context_token_budget = 800 + recent_context_floor_seconds = int(runtime_raw.get("recent_context_floor_seconds", 300)) + if recent_context_floor_seconds < 0: + logger.warning( + "[runtime] recent_context_floor_seconds=%d 非法(须 >= 0),回退默认值 300", + recent_context_floor_seconds, + ) + recent_context_floor_seconds = 300 + triggers_raw = expand_env_value(as_dict(data.get("triggers"))) + tools_raw = expand_env_value(as_dict(data.get("tools"))) + skills_raw = expand_env_value(as_dict(data.get("skills"))) + mcp_raw = expand_env_value(as_dict(data.get("mcp"))) + daily_summary_raw = expand_env_value(as_dict(data.get("daily_summary"))) + daily_briefing_raw = expand_env_value(as_dict(data.get("daily_briefing"))) + weekly_report_raw = expand_env_value(as_dict(data.get("weekly_report"))) + monthly_report_raw = expand_env_value(as_dict(data.get("monthly_report"))) + image_preprocessing_raw = expand_env_value(as_dict(data.get("image_preprocessing"))) + raw_style_profiles = expand_env_value(as_dict(data.get("style_profiles"))) + # 空内容家族是合法占位(引用方无风格附加块),只过滤空键——家族内容 + # 待调研定版期间以空串声明,避免被误判为未知引用。 + style_profiles = { + str(k).strip(): str(v).strip() for k, v in raw_style_profiles.items() if str(k).strip() + } + raw_pricing = as_dict(data.get("pricing")) + raw_providers = data.get("providers", []) + raw_mcp_servers = mcp_raw.get("servers", []) + auto_search_raw = expand_env_value(as_dict(triggers_raw.get("auto_search"))) + quick_judge_raw = expand_env_value(as_dict(triggers_raw.get("quick_judge"))) + + _enabled_tools = [ + str(item).strip() for item in tools_raw.get("enabled", []) if str(item).strip() + ] + _enabled_mode = _parse_enabled_mode(tools_raw.get("enabled_mode")) + if _enabled_tools and "enabled_mode" not in tools_raw: + # v1.11 及更早 enabled 非空 = 精确白名单;未显式声明 mode 的升级部署提示语义变化 + logger.warning( + "[tools] enabled 非空且未设置 enabled_mode," + "按 append 语义在默认白名单与 MCP 工具之上追加;" + '如需精确白名单请显式设置 enabled_mode = "replace"' + ) + + if int(runtime_raw.get("reply_split_threshold_chars", 800)) > int( + runtime_raw.get("reply_chunk_max_chars", 1200) + ): + return LLMConfig( + load_error="reply_split_threshold_chars 不能超过 reply_chunk_max_chars", + source_path=config_path, + ) + legacy_agent_delivery = runtime_raw.get("agent_delivery_enabled") + legacy_fallback_active = legacy_agent_delivery is not None and ( + "agent_delivery_intermediate_enabled" not in runtime_raw + or "agent_delivery_final_enabled" not in runtime_raw + ) + if legacy_fallback_active: + logger.warning( + "[runtime] agent_delivery_enabled 已废弃:存在未显式配置的交付域," + "该域正按旧键值映射,请迁移到 agent_delivery_intermediate_enabled / " + "agent_delivery_final_enabled" + ) + elif legacy_agent_delivery is not None: + logger.debug("[runtime] agent_delivery_enabled 已被两枚新键取代,忽略旧键") + config = LLMConfig( + runtime=RuntimeConfig( + enabled=as_bool(runtime_raw.get("enabled", False), default=False), + memory_enabled=as_bool(runtime_raw.get("memory_enabled", True), default=True), + default_provider=str(runtime_raw.get("default_provider", "")).strip() or None, + default_persona=str(runtime_raw.get("default_persona", "")).strip() or None, + history_limit=int(runtime_raw.get("history_limit", 10)), + history_max_messages_per_group=int( + runtime_raw.get("history_max_messages_per_group", 40) + ), + memory_limit=int(runtime_raw.get("memory_limit", 6)), + memory_max_items_per_group=int(runtime_raw.get("memory_max_items_per_group", 200)), + max_prompt_chars=int(runtime_raw.get("max_prompt_chars", 4000)), + tool_calling_enabled=as_bool( + runtime_raw.get("tool_calling_enabled", False), default=False + ), + tool_max_rounds=int(runtime_raw.get("tool_max_rounds", 8)), + tool_max_calls_per_round=int(runtime_raw.get("tool_max_calls_per_round", 16)), + retry_max_attempts=int( + runtime_raw.get("retry_max_attempts", DEFAULT_RETRY_MAX_ATTEMPTS) + ), + retry_base_delay=float(runtime_raw.get("retry_base_delay", DEFAULT_RETRY_BASE_DELAY)), + retry_jitter=min( + 1.0, max(0.0, float(runtime_raw.get("retry_jitter", DEFAULT_RETRY_JITTER))) + ), + auto_memory_enabled=as_bool( + runtime_raw.get("auto_memory_enabled", False), default=False + ), + auto_memory_prompt=str(runtime_raw.get("auto_memory_prompt", "")).strip(), + auto_memory_max_tokens=max(32, int(runtime_raw.get("auto_memory_max_tokens", 256))), + epoch_context_tokens=int(runtime_raw.get("epoch_context_tokens", 8000)), + epoch_cold_idle_seconds=int(runtime_raw.get("epoch_cold_idle_seconds", 300)), + epoch_cold_target_tokens=int(runtime_raw.get("epoch_cold_target_tokens", 4000)), + epoch_cold_trigger_tokens=int(runtime_raw.get("epoch_cold_trigger_tokens", 5000)), + epoch_hot_target_tokens=int(runtime_raw.get("epoch_hot_target_tokens", 32000)), + epoch_cap_tokens=int(runtime_raw.get("epoch_cap_tokens", 64000)), + request_input_token_budget=max( + 1024, int(runtime_raw.get("request_input_token_budget", 96_000)) + ), + agent_record_retention_days=max( + 1, int(runtime_raw.get("agent_record_retention_days", 30)) + ), + agent_record_max_loops_per_scope=max( + 1, int(runtime_raw.get("agent_record_max_loops_per_scope", 1000)) + ), + agent_record_max_bytes_per_scope=max( + 1024, int(runtime_raw.get("agent_record_max_bytes_per_scope", 67_108_864)) + ), + agent_delivery_intermediate_enabled=as_bool( + runtime_raw.get("agent_delivery_intermediate_enabled"), + default=as_bool(legacy_agent_delivery, default=False), + ), + agent_delivery_final_enabled=as_bool( + runtime_raw.get("agent_delivery_final_enabled"), + default=as_bool(legacy_agent_delivery, default=False), + ), + agent_replay_loop_tokens=min( + AGENT_REPLAY_LOOP_TOKENS_CEILING, + max( + AGENT_REPLAY_LOOP_TOKENS_FLOOR, + int(runtime_raw.get("agent_replay_loop_tokens", 4096)), + ), + ), + reply_split_threshold_chars=max( + 1, int(runtime_raw.get("reply_split_threshold_chars", 800)) + ), + reply_chunk_max_chars=max(1, int(runtime_raw.get("reply_chunk_max_chars", 1200))), + reply_send_interval_ms=min( + 10_000, max(0, int(runtime_raw.get("reply_send_interval_ms", 800))) + ), + reply_max_chunks_per_loop=min( + 256, max(1, int(runtime_raw.get("reply_max_chunks_per_loop", 64))) + ), + mention_cooldown_seconds=min( + 86_400.0, + max(0.0, float(runtime_raw.get("mention_cooldown_seconds", 600.0))), + ), + recent_context_token_budget=recent_context_token_budget, + recent_context_floor_seconds=recent_context_floor_seconds, + ), + triggers=TriggerConfig( + default_prefix=str(triggers_raw.get("default_prefix", "/ai")).strip() or "/ai", + allow_prefix=as_bool(triggers_raw.get("allow_prefix", True), default=True), + allow_at=as_bool(triggers_raw.get("allow_at", True), default=True), + empty_prompt_reply=str( + triggers_raw.get("empty_prompt_reply", "请在触发指令或艾特后面补上想说的话。") + ).strip() + or "请在触发指令或艾特后面补上想说的话。", + ), + auto_search=AutoSearchConfig( + enabled=as_bool(auto_search_raw.get("enabled", False), default=False), + search_max_calls_per_round=max( + 1, min(int(auto_search_raw.get("search_max_calls_per_round", 3)), 32) + ), + ), + quick_judge=QuickJudgeConfig( + provider_id=str(quick_judge_raw.get("provider_id", "")).strip(), + model=str(quick_judge_raw.get("model", "")).strip(), + timeout=max(0.5, float(quick_judge_raw.get("timeout", 2.0))), + max_tokens=max(8, int(quick_judge_raw.get("max_tokens", 64))), + ), + tools=ToolsConfig( + enabled=_enabled_tools, + enabled_mode=_enabled_mode, + discovery_mode=str(tools_raw.get("discovery_mode", "auto")).strip().lower() or "auto", + discovery_min_tools=max(1, int(tools_raw.get("discovery_min_tools", 10))), + discovery_search_limit=max(1, min(int(tools_raw.get("discovery_search_limit", 5)), 20)), + discovery_max_loaded_tools=max( + 1, min(int(tools_raw.get("discovery_max_loaded_tools", 12)), 64) + ), + always_loaded=[ + str(item).strip() + for item in tools_raw.get("always_loaded", []) + if str(item).strip() + ], + ), + skills=SkillsConfig( + enabled=as_bool(skills_raw.get("enabled", True), default=True), + catalog_dir=str(skills_raw.get("catalog_dir", "")).strip(), + catalog_max_bytes=_skills_positive_int( + skills_raw.get("catalog_max_bytes"), + default=8192, + label="catalog_max_bytes", + ), + resource_max_bytes=_skills_positive_int( + skills_raw.get("resource_max_bytes"), + default=65536, + label="resource_max_bytes", + ), + search_max_results=_skills_positive_int( + skills_raw.get("search_max_results"), + default=50, + label="search_max_results", + ), + search_max_output_bytes=_skills_positive_int( + skills_raw.get("search_max_output_bytes"), + default=32768, + label="search_max_output_bytes", + ), + script_timeout_ms=_skills_positive_int( + skills_raw.get("script_timeout_ms"), + default=30000, + label="script_timeout_ms", + maximum=MAX_SCRIPT_TIMEOUT_MS, + ), + script_max_output_bytes=_skills_positive_int( + skills_raw.get("script_max_output_bytes"), + default=65536, + label="script_max_output_bytes", + ), + ), + mcp=MCPConfig( + enabled=as_bool(mcp_raw.get("enabled", False), default=False), + servers=_read_mcp_servers(raw_mcp_servers if isinstance(raw_mcp_servers, list) else []), + ), + providers=_read_providers_safe(raw_providers, style_profiles), + personas=personas, + daily_summary=DailySummaryConfig( + enabled=as_bool(daily_summary_raw.get("enabled", False), default=False), + generate_cron=str(daily_summary_raw.get("generate_cron", "0 6 * * *")).strip() + or "0 6 * * *", + publish_cron=str(daily_summary_raw.get("publish_cron", "0 12 * * *")).strip() + or "0 12 * * *", + min_messages=max(1, int(daily_summary_raw.get("min_messages", 30))), + summary_length_hint=max(100, int(daily_summary_raw.get("summary_length_hint", 2000))), + model_cascade=[ + str(item).strip() + for item in daily_summary_raw.get("model_cascade", []) + if str(item).strip() + ], + ), + daily_briefing=DailyBriefingConfig( + enabled=as_bool(daily_briefing_raw.get("enabled", False), default=False), + morning_cron=str(daily_briefing_raw.get("morning_cron", "0 8 * * *")).strip() + or "0 8 * * *", + noon_cron=str(daily_briefing_raw.get("noon_cron", "0 12 * * *")).strip() + or "0 12 * * *", + evening_cron=str(daily_briefing_raw.get("evening_cron", "0 22 * * *")).strip() + or "0 22 * * *", + min_messages_for_llm=max(1, int(daily_briefing_raw.get("min_messages_for_llm", 5))), + active_users_limit=max(1, int(daily_briefing_raw.get("active_users_limit", 5))), + hot_words_limit=max(1, int(daily_briefing_raw.get("hot_words_limit", 5))), + sample_messages_limit=max(1, int(daily_briefing_raw.get("sample_messages_limit", 60))), + max_context_chars=max(1000, int(daily_briefing_raw.get("max_context_chars", 40000))), + max_output_chars=max(80, int(daily_briefing_raw.get("max_output_chars", 320))), + model_cascade=[ + str(item).strip() + for item in daily_briefing_raw.get("model_cascade", []) + if str(item).strip() + ], + ), + weekly_report=WeeklyReportConfig( + enabled=as_bool(weekly_report_raw.get("enabled", False), default=False), + generate_cron=str(weekly_report_raw.get("generate_cron", "0 9 * * 1")).strip() + or "0 9 * * 1", + publish_cron=str(weekly_report_raw.get("publish_cron", "0 10 * * *")).strip() + or "0 10 * * *", + min_messages=max(1, int(weekly_report_raw.get("min_messages", 100))), + length_hint=max(200, int(weekly_report_raw.get("length_hint", 2000))), + model_cascade=[ + str(item).strip() + for item in weekly_report_raw.get("model_cascade", []) + if str(item).strip() + ], + ), + monthly_report=MonthlyReportConfig( + enabled=as_bool(monthly_report_raw.get("enabled", False), default=False), + generate_cron=str(monthly_report_raw.get("generate_cron", "0 9 1 * *")).strip() + or "0 9 1 * *", + publish_cron=str(monthly_report_raw.get("publish_cron", "0 10 * * *")).strip() + or "0 10 * * *", + min_messages=max(1, int(monthly_report_raw.get("min_messages", 300))), + length_hint=max(200, int(monthly_report_raw.get("length_hint", 2500))), + input_char_budget=max(8_000, int(monthly_report_raw.get("input_char_budget", 240_000))), + model_cascade=[ + str(item).strip() + for item in monthly_report_raw.get("model_cascade", []) + if str(item).strip() + ], + ), + image_preprocessing=ImagePreprocessingConfig( + enabled=as_bool(image_preprocessing_raw.get("enabled", False), default=False), + provider_id=str(image_preprocessing_raw.get("provider_id", "")).strip(), + model=str(image_preprocessing_raw.get("model", "")).strip(), + max_tokens=max( + 80, + min( + int(image_preprocessing_raw.get("max_tokens", 300)), + MAX_IMAGE_PREPROCESSING_TOKENS, + ), + ), + temperature=float(image_preprocessing_raw.get("temperature", 0.3)), + prompt=str(image_preprocessing_raw.get("prompt", "")).strip(), + ), + style_profiles=style_profiles, + pricing=_read_pricing(raw_pricing), + source_path=config_path, + ) + + # 上游重试策略盖章:[runtime] 段是唯一配置入口,统一注入各 provider, + # 使所有 LLM 调用方经 build_provider_client 零改动继承全局重试参数。 + for provider in config.providers.values(): + provider.retry_max_attempts = config.runtime.retry_max_attempts + provider.retry_base_delay = config.runtime.retry_base_delay + provider.retry_jitter = config.runtime.retry_jitter + + try: + _validate_and_fix_config(config) + except Exception: + logger.exception("配置校验阶段异常") + config.load_error = "配置校验时发生内部错误" + return config + + +def _validate_and_fix_config(config: LLMConfig) -> None: + """Validate cross-references and fix salvageable issues. + + Single-provider problems result in that provider being skipped (logged); + only truly fatal issues (no providers at all, no personas at all) set + ``load_error``. This ensures one bad provider config never blocks others. + + Two observability principles: a fallback after the *explicitly configured* + default provider is pruned is recorded in ``load_error`` (fail-visible, + same as an unknown default_provider; auto-selected defaults re-fall-back + silently), and disabled features' model_cascades are not validated at all. + """ + errors: list[str] = [] + + if not config.providers: + config.load_error = "LLM 配置中没有可用的 providers" + return + + # -- default_provider fallback -- + # 用户在配置里显式写的默认 provider(可能为 None):剪除后回退只有对显式值 + # 才记 load_error;自动选择的再回退保持静默(维持"尽力可用"语义)。 + explicit_default_provider = config.runtime.default_provider + if config.runtime.default_provider is None: + config.runtime.default_provider = next(iter(config.providers)) + elif config.runtime.default_provider not in config.providers: + errors.append(f"默认 provider {config.runtime.default_provider!r} 不存在,已回退") + config.runtime.default_provider = next(iter(config.providers)) + + # -- personas -- + if not config.personas: + config.load_error = "LLM 配置中没有可用的人格" + return + if config.runtime.default_persona is None: + config.runtime.default_persona = next(iter(config.personas)) + elif config.runtime.default_persona not in config.personas: + fallback = next(iter(config.personas)) + errors.append( + f"默认 persona {config.runtime.default_persona!r} 不存在,已回退为 {fallback!r}" + ) + config.runtime.default_persona = fallback + + # -- tools -- + if config.tools.discovery_mode not in {"off", "on", "auto"}: + config.tools.discovery_mode = "auto" + + # -- per-provider validation: collect and prune -- + bad_providers: list[str] = [] + for pid, provider in config.providers.items(): + provider_errors: list[str] = [] + if provider.protocol not in {"openai", "claude", "gemini", "openai_responses"}: + provider_errors.append(f"未知协议 {provider.protocol!r}") + # openai_responses 专属键的词表校验(模块级常量单源,供 + # provider/openai_responses 反向引用与守护测试对齐)。 + if provider.protocol == "openai_responses": + if provider.responses_profile not in RESPONSES_PROFILE_IDS: + provider_errors.append( + f"非法 responses_profile {provider.responses_profile!r}" + f"(可用:{' / '.join(sorted(RESPONSES_PROFILE_IDS))})" + ) + # 思考档位全协议校验(wire 翻译/钳制见 thinking.resolve_thinking)。 + if provider.reasoning_effort not in ("", *REASONING_EFFORT_CHOICES): + provider_errors.append( + f"非法 reasoning_effort {provider.reasoning_effort!r}" + f"(可用:{'/'.join(REASONING_EFFORT_CHOICES)},留空不发送)" + ) + if provider.auth_method not in {"api_key", "bearer"}: + provider_errors.append( + f"未知 auth_method {provider.auth_method!r}(仅支持 api_key / bearer)" + ) + if provider.protocol == "claude" and provider.cache_ttl not in ("", "5m", "1h"): + provider_errors.append( + f"非法 cache_ttl {provider.cache_ttl!r}(claude 仅支持 5m / 1h,留空=默认 5min)" + ) + if provider.builtin_search and provider.protocol != "gemini": + # 非 gemini 协议不剪除 provider:键误配只影响该键本身,记录 + # warning 即可,请求级生效由 provider_builtin_search_active 兜底为惰性。 + logger.warning( + "provider %s 的 builtin_search 仅支持 gemini 协议,当前协议 %r 下该配置不生效", + pid, + provider.protocol, + ) + if not provider.base_url: + provider_errors.append("缺少 base_url") + if not provider.api_key_env: + provider_errors.append("缺少 api_key_env") + if not provider.default_model: + provider_errors.append("缺少 default_model") + if provider.default_model and provider.default_model not in provider.models: + provider.models.insert(0, provider.default_model) + logger.warning( + "provider %s 的 default_model %r 不在 models 列表中,已自动添加", + pid, + provider.default_model, + ) + + if provider_errors: + logger.error("provider %s 配置无效:%s,已跳过", pid, "; ".join(provider_errors)) + bad_providers.append(pid) + + for pid in bad_providers: + del config.providers[pid] + + # Re-validate default_provider in case it was among the deleted ones. + if config.runtime.default_provider not in config.providers: + if config.providers: + fallback = next(iter(config.providers)) + # Fail-visible,与上方 default_provider 指向不存在 id 的语义一致: + # 静默换用与配置不符的 provider 比显式拒绝更糟。全部剪除时不重复记录 + # (下方聚合错误已覆盖);自动选择的再回退不记录。 + if ( + explicit_default_provider is not None + and config.runtime.default_provider == explicit_default_provider + ): + errors.append( + f"默认 provider {explicit_default_provider!r} 已被剪除,已回退为 {fallback!r}" + ) + config.runtime.default_provider = fallback + else: + config.runtime.default_provider = None + + if bad_providers and not config.providers: + errors.append(f"全部 {len(bad_providers)} 个 provider 均被跳过:{', '.join(bad_providers)}") + + # -- cross-reference validation (non-fatal, augments errors) -- + if config.image_preprocessing.enabled and config.image_preprocessing.provider_id: + if config.image_preprocessing.provider_id not in config.providers: + errors.append( + f"image_preprocessing.provider_id {config.image_preprocessing.provider_id!r} 不存在" + ) + + for cascade_name, feature_enabled, cascade_list in [ + ( + "daily_summary.model_cascade", + config.daily_summary.enabled, + config.daily_summary.model_cascade, + ), + ( + "daily_briefing.model_cascade", + config.daily_briefing.enabled, + config.daily_briefing.model_cascade, + ), + ( + "weekly_report.model_cascade", + config.weekly_report.enabled, + config.weekly_report.model_cascade, + ), + ( + "monthly_report.model_cascade", + config.monthly_report.enabled, + config.monthly_report.model_cascade, + ), + ]: + if not feature_enabled: + continue + for entry in cascade_list: + provider_id = entry.split("/", 1)[0].strip() + if provider_id == "@default": + continue + if provider_id not in config.providers: + errors.append(f"{cascade_name} 引用了不存在的 provider {provider_id!r}") + + if errors: + config.load_error = "; ".join(errors) + logger.warning("LLM 配置存在问题:%s", config.load_error) + + +_PRESERVE_ENV = ContextVar("legacy_preserve_env", default=False) + + +def expand_env_value(value): + return value if _PRESERVE_ENV.get() else _expand_env_value(value) + + +def load_llm_config(path: str | Path, *, preserve_env=False) -> LLMConfig: + token = _PRESERVE_ENV.set(preserve_env) + try: + return _load_llm_config(path) + finally: + _PRESERVE_ENV.reset(token) diff --git a/src/quickquip/llm/config_migration.py b/src/quickquip/llm/config_migration.py new file mode 100644 index 00000000..438a081f --- /dev/null +++ b/src/quickquip/llm/config_migration.py @@ -0,0 +1,373 @@ +"""One-time legacy migration with recoverable asset preparation and private backups.""" + +from __future__ import annotations + +from dataclasses import asdict +import hashlib +import json +import os +import tempfile +from pathlib import Path +import tomllib +from typing import Any + +from filelock import FileLock + +from quickquip.llm.config_document import ( + ROUTES, + _secrets, + empty_document, + validate_document, +) +from quickquip.llm.config_fields import PARAMETER_TYPES, RANGES +from quickquip.llm.config_errors import ConfigError +from quickquip.llm.config_durability import durable_write, sync_directory +from quickquip.llm.config_legacy import load_llm_config + + +def _literal(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + return "[" + ", ".join(_literal(v) for v in value) + "]" + if isinstance(value, dict): + return ( + "{ " + + ", ".join(f"{_literal(k)} = {_literal(v)}" for k, v in value.items() if v is not None) + + " }" + ) + raise ConfigError("migration_asset_error", "无法编码旧配置字段") + + +def _toml(data: dict) -> str: + """Inline TOML values preserve nesting and avoid table ownership ambiguity.""" + return ( + "\n".join(f"{_literal(k)} = {_literal(v)}" for k, v in data.items() if v is not None) + "\n" + ) + + +def _write_asset(target: Path, data: dict, backup_dir: Path, report: dict) -> None: + content = _toml(data) + # Validate generated asset before any write. + tomllib.loads(content) + target.parent.mkdir(parents=True, exist_ok=True) + sync_directory(target.parent.parent) + staged = backup_dir / (target.name + ".staged") + durable_write(staged, content.encode("utf-8")) + report["assets"].append({"file": str(target.name), "status": "prepared"}) + journal = backup_dir / "report.json" + durable_write(journal, (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode()) + if target.exists(): + if target.read_text(encoding="utf-8") != content: + raise ConfigError( + "migration_asset_conflict", "目标文件已存在,请检查迁移报告", target.name + ) + else: + # Publish a complete file on the target filesystem without overwriting another writer. + descriptor, name = tempfile.mkstemp(prefix=".llm-migration-", dir=target.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.link(temporary, target) + sync_directory(target.parent) + finally: + temporary.unlink(missing_ok=True) + report["assets"][-1]["status"] = "ready" + + +def _prepare_assets(path: Path, raw: dict, report: dict, backup: Path) -> None: + personas_dir = path.parent / "personas" + existing_ids = set() + if personas_dir.exists(): + for file in personas_dir.glob("*.toml"): + if file.name.startswith("_"): + continue + try: + data = tomllib.loads(file.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError: + raise ConfigError("migration_asset_conflict", "独立人格文件语法错误", file.name) + entries = [data] if "id" in data else data.get("personas", []) + existing_ids.update(e.get("id") for e in entries) + inline = {} + for item in raw.get("personas", []): + inline[item.get("id", "")] = item + for i, persona in enumerate(inline.values()): + ident = persona.get("id", "") + if not ident: + report["warnings"].append({"field_path": f"personas[{i}]", "code": "empty_persona"}) + continue + digest = hashlib.sha256(ident.encode()).hexdigest()[:16] + target = personas_dir / f"migrated-{digest}.toml" + data = {**persona, "inherit_shared": False} + if ident in existing_ids: + # An interrupted extraction's exact file may already exist. + if target.exists() and target.read_text(encoding="utf-8") == _toml(data): + report["assets"].append({"file": target.name, "status": "ready"}) + else: + report["warnings"].append( + {"field_path": f"personas[{i}]", "code": "independent_persona_authoritative"} + ) + continue + _write_asset(target, data, backup, report) + generation = raw.get("generation", {}) + if not isinstance(generation, dict): + raise ConfigError("migration_asset_error", "旧 generation 字段需要对象") + sections = dict(generation) + for name in ("image", "audio", "music", "asr", "svg"): + value = raw.get(name, raw.get(name + "_generation")) + if name not in sections and value: + sections[name] = value + if sections: + target = path.parent / "generation.toml" + if target.exists(): + report["warnings"].append( + {"field_path": "generation", "code": "independent_generation_authoritative"} + ) + else: + _secrets(sections, "generation") + _write_asset(target, {"generation": sections}, backup, report) + + +def migrate_legacy(path: str | Path) -> tuple[dict, dict]: + """Return a strict document after asset preparation; never delete the legacy source.""" + path = Path(path) + try: + original = path.read_bytes() + raw = tomllib.loads(original.decode("utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError): + raise ConfigError("invalid_legacy", "旧 LLM 配置无法读取或解析") from None + _secrets(raw) + data_dir = path.parent.parent / "data" if path.parent.name == "config" else path.parent / "data" + digest = hashlib.sha256(original).hexdigest() + backup = data_dir / "llm-config-migration" / digest[:16] + backup.mkdir(parents=True, exist_ok=True) + with FileLock(str(backup / "migration.lock"), timeout=10): + report = { + "format_version": 1, + "source_sha256": digest, + "changes": [], + "warnings": [], + "assets": [], + "backup": str(backup), + } + source_copy = backup / "llm.toml" + if ( + not source_copy.exists() + or hashlib.sha256(source_copy.read_bytes()).hexdigest() != digest + ): + durable_write(source_copy, original) + complete = backup / "assets-backup-complete.json" + if not complete.exists(): + manifest = {} + for source in (path.parent / "personas", path.parent / "generation.toml"): + if not source.exists(): + continue + files = sorted(source.rglob("*")) if source.is_dir() else [source] + for file in files: + if not file.is_file(): + continue + relative = file.relative_to(path.parent) + contents = file.read_bytes() + target = backup / relative + # Repeat unfinished backup writes; originals are still authoritative here. + durable_write(target, contents) + manifest[str(relative)] = hashlib.sha256(contents).hexdigest() + durable_write(complete, json.dumps(manifest, sort_keys=True).encode()) + sync_directory(backup.parent) + sync_directory(backup.parent.parent) + manifest = json.loads(complete.read_text()) + for relative, expected in manifest.items(): + asset = backup / relative + if not asset.is_file() or hashlib.sha256(asset.read_bytes()).hexdigest() != expected: + raise ConfigError("migration_backup_invalid", "迁移回退资产缺失或损坏", relative) + # The frozen parser consumes the captured original and independent asset backup. + legacy = load_llm_config(source_copy, preserve_env=True) + doc = empty_document() + for name in doc["settings"]: + value = asdict(getattr(legacy, name)) + value.pop("model_cascade", None) + doc["settings"][name] = value + doc["style_profiles"] = dict(legacy.style_profiles) + doc["settings"]["tools"]["enabled_mode"] = legacy.tools.enabled_mode + if "enabled_mode" not in raw.get("tools", {}): + report["changes"].append( + { + "field_path": "settings.tools.enabled_mode", + "kind": "made_explicit", + "to": legacy.tools.enabled_mode, + } + ) + for key, value in list(doc["settings"]["runtime"].items()): + bounds = RANGES.get("runtime." + key) + if bounds and isinstance(value, (int, float)): + lo, hi = bounds + adjusted = max(lo, value) if hi is None else min(hi, max(lo, value)) + if adjusted != value: + report["changes"].append( + { + "field_path": "settings.runtime." + key, + "from": value, + "to": adjusted, + "kind": "clamped", + } + ) + doc["settings"]["runtime"][key] = adjusted + raw_providers = {p.get("id"): p for p in raw.get("providers", [])} + for pid, provider in legacy.providers.items(): + original_provider = raw_providers[pid] + values = asdict(provider) + request = { + k: values[k] for k in PARAMETER_TYPES if k in original_provider and k in values + } + request["style_profile"] = str(original_provider.get("style_profile", "")).strip() + request["style_overrides"] = str(original_provider.get("style_overrides", "")).strip() + # Frozen loader stamps global retry policy; explicit legacy provider keys were inert. + for key in ("retry_max_attempts", "retry_base_delay", "retry_jitter"): + request.pop(key, None) + if key in original_provider: + report["warnings"].append( + { + "field_path": f"providers.{pid}.{key}", + "code": "legacy_inert_override_removed", + } + ) + for key, value in list(request.items()): + bounds = RANGES.get(key) + if value is not None and bounds and isinstance(value, (int, float)): + lo, hi = bounds + request[key] = max(lo, value) if hi is None else min(hi, max(lo, value)) + for key in PARAMETER_TYPES: + if key in original_provider and original_provider[key] != request.get(key): + report["changes"].append( + { + "field_path": f"providers.{pid}.parameters.{key}", + "from": original_provider[key], + "to": request.get(key), + "kind": "normalized", + } + ) + known = set(PARAMETER_TYPES) | { + "id", + "protocol", + "base_url", + "api_key_env", + "default_model", + "enabled", + "headers", + "fallback_urls", + "proxy", + "auth_method", + "models", + "aliases", + "non_vision_models", + "model_context_windows", + } + for key in original_provider.keys() - known: + report["warnings"].append( + {"field_path": f"providers.{pid}.{key}", "code": "unrecognized_legacy_field"} + ) + doc["providers"].append( + { + "id": pid, + "protocol": provider.protocol, + "base_url": provider.base_url, + "api_key_env": provider.api_key_env, + "default_model": provider.default_model, + "enabled": provider.enabled, + "headers": provider.headers, + "fallback_urls": provider.fallback_urls, + "proxy": provider.proxy, + "auth_method": provider.auth_method, + "parameters": request, + "models": [ + { + "model": m, + "enabled": True, + "vision": m not in provider.non_vision_models, + "context_window": provider.model_context_windows.get(m), + "parameters": {}, + } + for m in provider.models + ], + "aliases": [{"alias": a, "model": m} for a, m in provider.aliases.items()], + } + ) + for key, rates in legacy.pricing.items(): + doc["pricing"].append({"provider_id": None, "model": key, **asdict(rates)}) + if "/" in key and key.split("/", 1)[0] in raw_providers: + provider_id, model = key.split("/", 1) + doc["pricing"].append({"provider_id": provider_id, "model": model, **asdict(rates)}) + report["changes"].append( + { + "field_path": "pricing." + key, + "kind": "qualified_and_response_fallback_preserved", + } + ) + report["source_counts"] = { + "providers": len(raw_providers), + "models": sum(len(p.models) for p in legacy.providers.values()), + "pricing": len(legacy.pricing), + "mcp_servers": len(legacy.mcp.servers), + } + for name in ROUTES: + for entry in getattr(legacy, name).model_cascade: + if entry == "@default": + doc["routes"][name].append(entry) + elif "/" in entry: + pid, model = entry.split("/", 1) + if not model and pid in legacy.providers: + model = legacy.providers[pid].default_model + doc["routes"][name].append({"provider_id": pid, "model": model}) + elif entry in legacy.providers: + doc["routes"][name].append( + {"provider_id": entry, "model": legacy.providers[entry].default_model} + ) + else: + raise ConfigError( + "invalid_legacy_reference", "级联引用无法转换", "routes." + name + ) + if "agent_delivery_enabled" in raw.get("runtime", {}): + report["changes"].append( + { + "field_path": "runtime.agent_delivery_enabled", + "kind": "mapped_to_delivery_domains", + } + ) + for name in ROUTES: + known = set(doc["settings"][name]) | {"model_cascade"} + for key in raw.get(name, {}): + if key not in known: + report["changes"].append({"field_path": name + "." + key, "kind": "removed"}) + if set(raw_providers) != set(legacy.providers): + raise ConfigError("invalid_legacy_provider", "旧解析器跳过了渠道,请先修复迁移输入") + # Complete structural validation before external assets are materialized. + doc = validate_document(doc) + for section, values in doc["settings"].items(): + source = raw.get(section, {}) + if section in ("auto_search", "quick_judge"): + source = raw.get("triggers", {}).get(section, {}) + for key, value in values.items(): + if key in source and source[key] != value: + report["changes"].append( + { + "field_path": f"settings.{section}.{key}", + "from": source[key], + "to": value, + "kind": "normalized", + } + ) + if hashlib.sha256(path.read_bytes()).hexdigest() != digest: + raise ConfigError("migration_source_changed", "迁移输入已变化,请重新执行迁移") + _prepare_assets(path, raw, report, backup) + durable_write( + backup / "report.json", + (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode(), + ) + return doc, report diff --git a/src/quickquip/llm/config_runtime.py b/src/quickquip/llm/config_runtime.py new file mode 100644 index 00000000..d49ef61f --- /dev/null +++ b/src/quickquip/llm/config_runtime.py @@ -0,0 +1,309 @@ +"""Bot-owned consumer of immutable configuration control operations.""" +from __future__ import annotations + +import asyncio +import errno +from dataclasses import asdict, replace +from importlib.metadata import PackageNotFoundError, version +import logging +import os +import time +import uuid + +from quickquip.llm.config_document import ( + compile_document, environment_references, resolve_model_provider, +) +from quickquip.llm.config_errors import ConfigError +from quickquip.llm.provider_health import probe_provider +from quickquip.llm.usage import configuration_pricing + +logger = logging.getLogger(__name__) + + +def initialize_for_bot(service, legacy_path=None, *, wait_seconds=20, sleep=time.sleep): + """A read-only bot waits for a writable Web/CLI initializer to prepare assets.""" + try: + service.initialize(legacy_path) + except OSError as exc: + if not isinstance(exc, PermissionError) and exc.errno != errno.EROFS: + raise + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if service.path.exists(): + return service.read("published") + sleep(min(0.25, max(0, deadline - time.monotonic()))) + raise ConfigError( + "migration_required", + "Writable Web/CLI initialization must prepare configuration assets", + ) from None + return service.read("published") + + +class ConfigRuntimeWorker: + """Prepare outside transactions, fence publication, then confirm actual load.""" + + def __init__(self, service, runtime, *, compiler=compile_document, schedules=None): + self.service = service + self.runtime = runtime + self.compiler = compiler + self.schedules = schedules + self.boot_id = uuid.uuid4().hex + self._tick_lock = asyncio.Lock() + try: + self.application_version = version("quickquip") + except PackageNotFoundError: + self.application_version = "unknown" + initial_config = runtime.config + self.boot_id = service.register_bot_boot( + self.boot_id, initial_config.revision, application_version=self.application_version + ) + runtime._configuration_boot_check = lambda: service.assert_bot_boot(self.boot_id) + # Once registration fences older boots, the published pointer stays stable until + # this worker begins consuming operations. A startup paused before registration + # must refresh the pointer rather than accepting new work with an older snapshot. + published = service.read("published") + if published["revision"] != runtime.config.revision: + current = runtime.config + config = self.compiler( + published["document"], revision=published["revision"], + config_dir=runtime.config_path.parent.parent / "config", + ) + snapshot = runtime.prepare_configuration(config) + if schedules is not None: + with runtime.configuration_view(snapshot): + schedules(current, config) + runtime.activate_configuration(snapshot) + if snapshot.resources.dirty: + with runtime.configuration_view(snapshot): + runtime.start_mcp_background() + + def components(self): + runtime = self.runtime + statuses = runtime.mcp_manager.get_statuses() + required = set(environment_references(self.service.read("published")["document"])) + required.update(environment_references(self.service.read("draft")["document"])) + return { + "environment": [{"name": name, "configured": bool(os.getenv(name))} + for name in sorted(required)], + "core": {"state": "ready" if not runtime.config.load_error else "failed"}, + "mcp": { + "state": "preparing" if ( + runtime.is_mcp_initializing() or runtime.is_mcp_dirty() + ) else ( + "degraded" if any(s.enabled and not s.connected for s in statuses) else "ready" + ), + "servers": [{"id": s.id, "connected": s.connected, + "failure_code": s.failure_kind} for s in statuses], + }, + } + + def report(self): + try: + self.service.report_bot( + self.boot_id, self.runtime.config.revision, self.components(), + application_version=self.application_version, + ) + except ConfigError as exc: + if exc.code == "runtime_superseded": + self.runtime.fence_configuration_runtime() + raise + + def _verify(self, operation): + self.service.assert_bot_boot(self.boot_id) + with self.service.store.connect() as conn: + self.service.tasks.assert_claim(conn, operation["id"], operation["claim_token"]) + + def _validate_local(self, config, document): + if config.load_error: + raise ConfigError("configuration_failed", "Configuration could not be compiled") + required_providers = set() + required_models = set() + def model(provider_id, selected, location): + try: + effective = resolve_model_provider(config, provider_id, selected) + required_providers.add(provider_id) + required_models.add((provider_id, effective.aliases.get(selected, selected))) + return effective + except ConfigError as exc: + raise ConfigError(exc.code, exc.message, location, exc.details) from None + + default_id = config.runtime.default_provider or "" + default_provider = config.providers.get(default_id) + default_model = default_provider.default_model if default_provider else "" + if config.runtime.enabled: + model(default_id, default_model, "settings.runtime.default_provider") + reports = ("daily_summary", "daily_briefing", "weekly_report", "monthly_report") + if config.runtime.enabled or any(getattr(config, name).enabled for name in reports): + if config.runtime.default_persona not in config.personas: + raise ConfigError("missing_persona", "Default persona file is missing", + "settings.runtime.default_persona") + for name in reports: + report = getattr(config, name) + if not report.enabled: + continue + for index, entry in enumerate(report.model_cascade or ["@default"]): + provider_id, selected = ( + (default_id, default_model) if entry == "@default" else entry.split("/", 1) + ) + model(provider_id, selected, f"routes.{name}[{index}]") + judge = config.quick_judge + if judge.provider_id or judge.model: + provider_id = judge.provider_id or default_id + provider = config.providers.get(provider_id) + selected = judge.model or (provider.default_model if provider else "") + model(provider_id, selected, "settings.quick_judge") + image = config.image_preprocessing + if image.enabled: + provider_id = image.provider_id + provider = config.providers.get(provider_id) + selected = image.model or (provider.default_model if provider else "") + effective = model(provider_id, selected, "settings.image_preprocessing") + wire = effective.aliases.get(selected, selected) + if wire in effective.non_vision_models: + raise ConfigError("vision_required", "Image preprocessing requires a vision model", + "settings.image_preprocessing.model") + active_providers = [{ + **provider, + "models": [m for m in provider["models"] + if (provider["id"], m["model"]) in required_models], + } for provider in document["providers"] if provider["id"] in required_providers] + missing = [name for name in environment_references({"providers": active_providers}) + if not os.getenv(name)] + if missing: + raise ConfigError("missing_environment", "Required environment variables missing", + details={"variables": sorted(set(missing))}) + + def _validate_overrides(self, document): + store = self.runtime.store + if store is None: + return + self.service.validate_group_overrides(document, store) + + async def _renew(self, operation): + while True: + await asyncio.sleep(5) + self.service.assert_bot_boot(self.boot_id) + self.service.tasks.heartbeat(operation["id"], operation["claim_token"]) + self.report() + + async def _probe(self, operation, config): + payload = operation["payload"] + provider = resolve_model_provider(config, payload["provider_id"], payload["model"]) + with configuration_pricing(config): + if "user_prompt" in payload: + from quickquip.llm.provider import ( + LLMRequest, RetryPolicy, build_provider_client, + ) + from quickquip.llm.tools import LLMConversationMessage + client = build_provider_client( + replace( + provider, fallback_urls=[], stream_enabled=payload.get("stream", False) + ), + retry_policy=RetryPolicy.disabled(), single_dispatch=True, + ) + request = LLMRequest( + model=payload["model"], system_prompt=payload.get("system_prompt", ""), + messages=[LLMConversationMessage(role="user", content=payload["user_prompt"])], + max_output_tokens=payload.get("max_output_tokens", 64), + temperature=0.0, tools=[], allow_tool_calls=False, + ) + response = await asyncio.wait_for(client.complete(request), timeout=30) + return {"revision": config.revision, "may_be_billable": True, + "automatic_retries": False, "text": response.text[:8192], + "model": response.model, "input_tokens": response.input_tokens, + "output_tokens": response.output_tokens, + "finish_reason": response.finish_reason} + result = await probe_provider(provider, model=payload["model"]) + return {"probe": asdict(result), "revision": operation["target_revision"], + "may_be_billable": True, "automatic_retries": False} + + async def _apply(self, operation, config, document): + self._verify(operation) + self._validate_local(config, document) + snapshot = self.runtime.prepare_configuration(config) + old_config = self.runtime.config + rollback = None + try: + self.service.tasks.update_stage(operation["id"], operation["claim_token"], "switching") + async with self.runtime._configuration_gate: + # Shared reference lock excludes concurrent group overrides across processes. + if self.schedules is not None: + with self.runtime.configuration_view(snapshot): + rollback = self.schedules(old_config, config) + self.service.publish( + operation["id"], operation["claim_token"], + before_publish=lambda: self._validate_overrides(document), + boot_id=self.boot_id, + ) + self._verify(operation) + self.runtime.activate_configuration(snapshot) + self._verify(operation) + with self.runtime.configuration_view(snapshot): + if snapshot.resources.dirty: + self.runtime.start_mcp_background() + self.report() + except BaseException as exc: + if isinstance(exc, ConfigError) and exc.code == "runtime_superseded": + self.runtime.fence_configuration_runtime() + if rollback is not None and snapshot is not self.runtime._runtime_snapshot: + rollback() + if snapshot is not self.runtime._runtime_snapshot: + if snapshot.resources is not self.runtime._runtime_snapshot.resources: + await self.runtime._close_resources(snapshot.resources) + raise + return {"loaded_revision": config.revision, "components": self.components()} + + async def tick(self): + """Called every five seconds by the bot adapter; Web never executes this.""" + if self._tick_lock.locked(): + return + async with self._tick_lock: + self.report() + operation = self.service.tasks.claim(self.boot_id, boot_id=self.boot_id) + if operation is None: + return + heartbeat = asyncio.create_task(self._renew(operation)) + try: + target = self.service.read(operation["target_revision"]) + self._verify(operation) + config = self.compiler( + target["document"], revision=target["revision"], + config_dir=self.runtime.config_path.parent.parent / "config", + ) + execution = asyncio.create_task( + self._apply(operation, config, target["document"]) + if operation["kind"] == "apply" + else self._probe(operation, config) + ) + done, _ = await asyncio.wait( + {execution, heartbeat}, return_when=asyncio.FIRST_COMPLETED + ) + if heartbeat in done: + execution.cancel() + await asyncio.gather(execution, return_exceptions=True) + heartbeat.result() + result = await execution + self._verify(operation) + self.service.tasks.finish(operation["id"], operation["claim_token"], result=result) + except asyncio.CancelledError: + # Leave the fenced lease for restart recovery; probes are never replayed. + if "execution" in locals(): + execution.cancel() + await asyncio.gather(execution, return_exceptions=True) + raise + except Exception as exc: + if isinstance(exc, ConfigError) and exc.code == "runtime_superseded": + self.runtime.fence_configuration_runtime() + return + error = exc.as_dict() if isinstance(exc, ConfigError) else { + "code": "configuration_failed", "message": "Configuration operation failed", + "field_path": "", "details": {"exception_type": type(exc).__name__}, + } + try: + self.service.tasks.finish(operation["id"], operation["claim_token"], + status="failed", error=error) + except ConfigError: + logger.warning("Configuration operation ownership expired") + finally: + heartbeat.cancel() + await asyncio.gather(heartbeat, return_exceptions=True) diff --git a/src/quickquip/llm/config_service.py b/src/quickquip/llm/config_service.py new file mode 100644 index 00000000..8139f21f --- /dev/null +++ b/src/quickquip/llm/config_service.py @@ -0,0 +1,485 @@ +"""Application service shared by configuration CLI, Web Admin and bot control.""" + +from __future__ import annotations + +import copy +import json +import os +import sqlite3 +import time +import uuid +from functools import lru_cache +from pathlib import Path + +from filelock import FileLock + +from quickquip.common.paths import CONFIG_LLM_TOML, DATA_DIR +from quickquip.llm.config_document import ConfigError, empty_document, validate_document +from quickquip.llm.config_store import ConfigStore, SCHEMA_VERSION, dumps, project_document +from quickquip.llm.config_durability import ensure_directory +from quickquip.llm.config_tasks import ConfigTaskQueue, operation + +from quickquip.llm.config_validation import document_diff as document_diff, validation_report + +DEFAULT_CONFIG_DB = DATA_DIR / "llm_config.db" + + +class ConfigService: + def __init__(self, path=DEFAULT_CONFIG_DB): + self.path = Path(path) + self._references_lock = FileLock(str(self.path) + ".references.lock", timeout=10) + self.store = ConfigStore(self.path) + self.tasks = ConfigTaskQueue(self.store) + + @property + def shared_lock(self): + return self._references_lock + + def initialize(self, legacy_path=None): + ensure_directory(self.path.parent) + with FileLock(str(self.path) + ".init.lock", timeout=60): + if self.path.exists(): + # An existing database is authoritative, including failure/new-schema cases. + self.store.verify() + return self.read() + report = None + legacy = Path(legacy_path) if legacy_path else None + if legacy and legacy.exists(): + from quickquip.llm.config_migration import migrate_legacy + + document, report = migrate_legacy(legacy) + else: + document = empty_document() + document = validate_document(document) + self.store.create(document, report, str(legacy or "")) + return self.read() + + def read(self, source="draft"): + with self.store.connect() as conn: + meta = conn.execute("SELECT * FROM config_meta WHERE singleton=1").fetchone() + if source == "draft": + return { + "revision": meta["draft_revision"], + "document": json.loads(meta["document"]), + } + try: + revision = meta["published_revision"] if source == "published" else int(source) + except (TypeError, ValueError): + raise ConfigError( + "invalid_field", "Invalid configuration source", field_path="source" + ) from None + row = conn.execute( + "SELECT document FROM config_snapshots WHERE revision=?", (revision,) + ).fetchone() + if row is None: + raise ConfigError("not_found", "Snapshot not found", details={"revision": revision}) + return {"revision": revision, "document": json.loads(row[0])} + + @staticmethod + def _check_revision(conn, expected_revision): + meta = conn.execute("SELECT * FROM config_meta WHERE singleton=1").fetchone() + if expected_revision != meta["draft_revision"]: + raise ConfigError( + "revision_conflict", + "Draft changed since it was read", + details={"expected": expected_revision, "actual": meta["draft_revision"]}, + ) + return meta + + def migrate_legacy(self, path, expected_revision): + from quickquip.llm.config_migration import migrate_legacy + + with self.shared_lock: + if self.read()["revision"] != expected_revision: + raise ConfigError( + "revision_conflict", "Draft changed before legacy asset preparation" + ) + document, report = migrate_legacy(path) + return {**self.replace(document, expected_revision), "migration": report} + + def replace(self, document, expected_revision): + document = validate_document(copy.deepcopy(document)) + with self.shared_lock, self.store.connect(write=True) as conn: + meta = self._check_revision(conn, expected_revision) + if dumps(document) == meta["document"]: + return {"revision": expected_revision, "document": document} + revision = expected_revision + 1 + conn.execute( + "UPDATE config_meta SET draft_revision=?,document=? WHERE singleton=1", + (revision, dumps(document)), + ) + project_document(conn, document) + return {"revision": revision, "document": document} + + def update(self, expected_revision, transform): + draft = self.read() + if draft["revision"] != expected_revision: + raise ConfigError( + "revision_conflict", + "Draft changed since it was read", + details={"actual": draft["revision"]}, + ) + document = copy.deepcopy(draft["document"]) + transform(document) + return self.replace(document, expected_revision) + + def patch_provider(self, provider_id, fields, expected_revision): + if "id" in fields: + raise ConfigError("invalid_field", "Provider identity is immutable", "fields.id") + + def transform(document): + provider = next((p for p in document["providers"] if p["id"] == provider_id), None) + if provider is None: + raise ConfigError("not_found", "Provider not found") + for key, value in fields.items(): + if key == "parameters": + if not isinstance(value, dict): + raise ConfigError( + "invalid_field", "Parameters must be an object", "fields.parameters" + ) + # Parameter omissions keep their value; complex parameter objects replace whole. + provider["parameters"].update(value) + else: + provider[key] = value + + return self.update(expected_revision, transform) + + def _enqueue(self, kind, source, request_id, payload=None, expected_revision=None): + if not isinstance(request_id, str) or not request_id or len(request_id) > 128: + raise ConfigError( + "invalid_field", + "request_id must contain 1 to 128 characters", + field_path="request_id", + ) + with self.shared_lock, self.store.connect(write=True) as conn: + existing = conn.execute( + "SELECT * FROM config_operations WHERE request_id=?", (request_id,) + ).fetchone() + if existing: + if ( + existing["kind"] != kind + or ( + expected_revision is not None + and existing["target_revision"] != expected_revision + ) + or existing["payload"] != dumps(payload or {}) + ): + raise ConfigError( + "operation_conflict", "Request ID belongs to another operation" + ) + return self.tasks.get(existing["id"]) + meta = conn.execute("SELECT * FROM config_meta WHERE singleton=1").fetchone() + if expected_revision is not None: + self._check_revision(conn, expected_revision) + if ( + kind == "apply" + and conn.execute("""SELECT 1 FROM config_operations + WHERE kind='apply' AND status IN ('queued','running')""").fetchone() + ): + raise ConfigError("operation_conflict", "A configuration apply is already pending") + if source == "draft": + revision, document = meta["draft_revision"], json.loads(meta["document"]) + else: + revision = meta["published_revision"] if source == "published" else int(source) + row = conn.execute( + "SELECT document FROM config_snapshots WHERE revision=?", (revision,) + ).fetchone() + if row is None: + raise ConfigError("not_found", "Snapshot not found") + document = json.loads(row[0]) + validate_document(document) + if kind == "probe": + provider = next( + (p for p in document["providers"] if p["id"] == payload["provider_id"]), None + ) + if not provider or not any( + m["model"] == payload["model"] for m in provider["models"] + ): + raise ConfigError( + "invalid_reference", "Unknown probe target", field_path="model" + ) + conn.execute( + "INSERT OR IGNORE INTO config_snapshots VALUES(?,?,?)", + (revision, dumps(document), time.time()), + ) + identifier = uuid.uuid4().hex + now = time.time() + conn.execute( + """INSERT INTO config_operations + (id,request_id,kind,target_revision,status,stage,payload,created_at,updated_at) + VALUES(?,?,?,?, 'queued','queued',?,?,?)""", + (identifier, request_id, kind, revision, dumps(payload or {}), now, now), + ) + row = conn.execute( + "SELECT * FROM config_operations WHERE id=?", (identifier,) + ).fetchone() + result = operation(row) + result.pop("claim_token", None) + return result + + def apply(self, expected_revision, request_id): + return self._enqueue("apply", "draft", request_id, expected_revision=expected_revision) + + def probe(self, source, provider_id, model, request_id, payload=None): + if source not in ("draft", "published"): + try: + source = int(source) + except (TypeError, ValueError): + raise ConfigError( + "invalid_field", "Invalid configuration source", field_path="source" + ) from None + return self._enqueue( + "probe", + source, + request_id, + {**(payload or {}), "provider_id": provider_id, "model": model, "source": source}, + ) + + def publish(self, operation_id, token, before_publish=None, boot_id=None): + with self.shared_lock: + if before_publish is not None: + before_publish() + return self._publish_locked(operation_id, token, boot_id=boot_id) + + def _publish_locked(self, operation_id, token, boot_id=None): + with self.store.connect(write=True) as conn: + if boot_id is not None: + self._assert_bot_boot(conn, boot_id) + row = self.tasks.assert_claim(conn, operation_id, token) + if row["kind"] != "apply": + raise ConfigError("operation_conflict", "Only apply operations can publish") + conn.execute( + "UPDATE config_meta SET published_revision=? WHERE singleton=1", + (row["target_revision"],), + ) + conn.execute( + "UPDATE config_operations SET stage='published',updated_at=? WHERE id=?", + (time.time(), operation_id), + ) + return row["target_revision"] + + @staticmethod + def validate_group_reference(document, provider_id, model, field_path="group_settings"): + if not provider_id and not model: + return + pid = provider_id or document["settings"]["runtime"]["default_provider"] + if pid is None: + pid = next((p["id"] for p in document["providers"] if p["enabled"]), None) + provider = next((p for p in document["providers"] if p["id"] == pid and p["enabled"]), None) + if provider is None: + raise ConfigError( + "dependency_conflict", + "Group override references unavailable provider", + field_path=field_path, + details={"provider_id": pid}, + ) + if model and not any(m["model"] == model and m["enabled"] for m in provider["models"]): + # Aliases resolve through the same enabled model list. + alias = next((a["model"] for a in provider["aliases"] if a["alias"] == model), None) + if not alias or not any( + m["model"] == alias and m["enabled"] for m in provider["models"] + ): + raise ConfigError( + "dependency_conflict", + "Group override references unavailable model", + field_path=field_path, + details={"provider_id": pid, "model": model}, + ) + + def validate_group_overrides(self, document, store): + if not store.path.exists(): + return + with store._connect() as conn: + rows = conn.execute("SELECT group_id,provider_id,model FROM group_settings").fetchall() + for row in rows: + self.validate_group_reference( + document, row["provider_id"], row["model"], f"group_settings.{row['group_id']}" + ) + + def update_group_override(self, store, group_id, **payload): + if "provider_id" not in payload and "model" not in payload: + return store.update_group_settings(group_id, **payload) + with self.shared_lock: + existing = store.get_group_settings(group_id) + self.validate_group_reference( + self.read("published")["document"], + payload.get("provider_id", existing.provider_id), + payload.get("model", existing.model), + ) + return store.update_group_settings(group_id, **payload) + + @staticmethod + def _assert_bot_boot(conn, boot_id): + row = conn.execute("SELECT boot_id FROM bot_runtime WHERE singleton=1").fetchone() + if row is None or row[0] != boot_id: + raise ConfigError("runtime_superseded", "A newer bot boot owns configuration control") + + def assert_bot_boot(self, boot_id): + with self.store.connect() as conn: + self._assert_bot_boot(conn, boot_id) + + def register_bot_boot(self, boot_id, loaded_revision, application_version=""): + """Atomically advance the boot epoch; registration is separate from heartbeats.""" + with self.store.connect(write=True) as conn: + row = conn.execute("SELECT boot_id FROM bot_runtime WHERE singleton=1").fetchone() + try: + previous_epoch = int(row[0].split(":", 1)[0]) if row else 0 + except ValueError: + previous_epoch = 0 + token = f"{previous_epoch + 1}:{boot_id}" + conn.execute( + """INSERT INTO bot_runtime VALUES(1,?,?,?,?,?) + ON CONFLICT(singleton) DO UPDATE SET boot_id=excluded.boot_id, + loaded_revision=excluded.loaded_revision,application_version=excluded.application_version, + heartbeat_at=excluded.heartbeat_at,components=excluded.components""", + ( + token, + loaded_revision, + application_version, + time.time(), + dumps({}), + ), + ) + now = time.time() + conn.execute( + """UPDATE config_operations SET lease_until=0,updated_at=? + WHERE status='running' AND kind='apply'""", + (now,), + ) + conn.execute( + """UPDATE config_operations SET status='failed',stage='unconfirmed', + error=?,updated_at=?,lease_until=NULL WHERE status='running' AND kind='probe'""", + ( + dumps( + { + "code": "probe_result_unconfirmed", + "message": "Probe interrupted by a newer bot boot", + } + ), + now, + ), + ) + return token + + def report_bot(self, boot_id, loaded_revision, components=None, application_version=""): + with self.store.connect(write=True) as conn: + self._assert_bot_boot(conn, boot_id) + conn.execute( + """UPDATE bot_runtime SET loaded_revision=?,application_version=?, + heartbeat_at=?,components=? WHERE singleton=1 AND boot_id=?""", + ( + loaded_revision, + application_version, + time.time(), + dumps(components or {}), + boot_id, + ), + ) + + def status(self): + observed = time.time() + with self.store.connect() as conn: + meta = conn.execute("SELECT * FROM config_meta WHERE singleton=1").fetchone() + row = conn.execute("SELECT * FROM bot_runtime WHERE singleton=1").fetchone() + bot = dict(row) if row else None + if bot: + bot.pop("singleton") + bot["components"] = json.loads(bot["components"]) + bot["observed_at"] = observed + bot["stale"] = observed - bot["heartbeat_at"] > 20 + return { + "schema_version": SCHEMA_VERSION, + "draft_revision": meta["draft_revision"], + "published_revision": meta["published_revision"], + "bot": bot, + } + + def history(self): + with self.store.connect() as conn: + rows = conn.execute( + "SELECT revision,created_at FROM config_snapshots ORDER BY revision DESC" + ) + return {"snapshots": [dict(row) for row in rows]} + + def restore_snapshot(self, revision, expected_revision): + return self.replace(self.read(revision)["document"], expected_revision) + + def validate(self, document=None): + return validation_report(self.read()["document"] if document is None else document) + + def preview_import(self, document, expected_revision): + draft = self.read() + if draft["revision"] != expected_revision: + raise ConfigError( + "revision_conflict", + "Draft changed since it was read", + details={"actual": draft["revision"]}, + ) + normalized = validate_document(document) + return { + **validation_report(normalized, draft["document"]), + "document": normalized, + "expected_draft_revision": draft["revision"], + } + + def diff(self, source="draft", target="published"): + return validation_report(self.read(source)["document"], self.read(target)["document"]) + + def backup(self, destination): + destination = Path(destination) + if destination.resolve() == self.path.resolve(): + raise ConfigError("invalid_field", "Backup must use a separate file") + ensure_directory(destination.parent) + with self.store.connect() as source: + target = sqlite3.connect(destination) + try: + source.backup(target) + finally: + target.close() + with destination.open("rb") as stream: + os.fsync(stream.fileno()) + from quickquip.llm.config_durability import sync_directory + + sync_directory(destination.parent) + return destination + + def restore_database(self, source, *, stopped=False): + if not stopped: + raise ConfigError("services_running", "Stop bot and Web before database restore") + source = Path(source) + if not source.is_file(): + raise ConfigError("not_found", "Backup database does not exist") + if source.resolve() == self.path.resolve(): + raise ConfigError("invalid_field", "Restore must use a separate backup file") + candidate = ConfigStore(source) + candidate.verify() + recovered = ConfigService(source) + validate_document(recovered.read()["document"]) + validate_document(recovered.read("published")["document"]) + backup = self.path.with_name(f"{self.path.name}.{time.time_ns()}.backup") + self.backup(backup) + with self.shared_lock, candidate.connect() as origin, sqlite3.connect(self.path) as target: + origin.backup(target) + with target: + target.execute("DELETE FROM bot_runtime") + target.execute( + """UPDATE config_operations SET status='failed',stage='restored', + claim_token=NULL,lease_until=NULL,error=? + WHERE status IN ('queued','running')""", + ( + dumps( + { + "code": "database_restored", + "message": "Operation cleared by database restore", + } + ), + ), + ) + return {"backup": str(backup), **self.status()} + + +@lru_cache(maxsize=1) +def get_config_service(): + service = ConfigService() + service.initialize(CONFIG_LLM_TOML) + return service diff --git a/src/quickquip/llm/config_store.py b/src/quickquip/llm/config_store.py new file mode 100644 index 00000000..0e64fd66 --- /dev/null +++ b/src/quickquip/llm/config_store.py @@ -0,0 +1,328 @@ +"""SQLite persistence for LLM configuration and immutable execution snapshots.""" + +from __future__ import annotations + +import json +import os +import re +import uuid +import sqlite3 +from contextlib import contextmanager +from functools import lru_cache +from pathlib import Path + +SCHEMA_VERSION = 1 + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS config_meta ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), schema_version INTEGER NOT NULL, + draft_revision INTEGER NOT NULL, published_revision INTEGER NOT NULL, + document TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS config_snapshots ( + revision INTEGER PRIMARY KEY, document TEXT NOT NULL, created_at REAL NOT NULL); +CREATE TRIGGER IF NOT EXISTS snapshots_immutable_update BEFORE UPDATE ON config_snapshots + BEGIN SELECT RAISE(ABORT, 'immutable snapshot'); END; +CREATE TRIGGER IF NOT EXISTS snapshots_immutable_delete BEFORE DELETE ON config_snapshots + BEGIN SELECT RAISE(ABORT, 'immutable snapshot'); END; +CREATE TABLE IF NOT EXISTS providers ( + id TEXT PRIMARY KEY, position INTEGER NOT NULL, document TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS provider_models ( + provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + model TEXT NOT NULL, position INTEGER NOT NULL, document TEXT NOT NULL, + PRIMARY KEY(provider_id,model)); +CREATE TABLE IF NOT EXISTS model_aliases ( + provider_id TEXT NOT NULL, alias TEXT NOT NULL, model TEXT NOT NULL, position INTEGER NOT NULL, + FOREIGN KEY(provider_id,model) REFERENCES provider_models(provider_id,model), + PRIMARY KEY(provider_id,alias)); +CREATE TABLE IF NOT EXISTS style_profiles (id TEXT PRIMARY KEY, content TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS pricing_rules (position INTEGER PRIMARY KEY, document TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS model_routes (id TEXT PRIMARY KEY); +CREATE TABLE IF NOT EXISTS route_entries ( + route_id TEXT REFERENCES model_routes(id) ON DELETE CASCADE, + position INTEGER NOT NULL, document TEXT NOT NULL, PRIMARY KEY(route_id,position)); +CREATE TABLE IF NOT EXISTS config_settings (id TEXT PRIMARY KEY, document TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS config_migrations ( + id INTEGER PRIMARY KEY, source TEXT NOT NULL, report TEXT NOT NULL, created_at REAL NOT NULL); +CREATE TABLE IF NOT EXISTS config_operations ( + id TEXT PRIMARY KEY, request_id TEXT UNIQUE NOT NULL, kind TEXT NOT NULL, + target_revision INTEGER NOT NULL REFERENCES config_snapshots(revision), + status TEXT NOT NULL CHECK(status IN ('queued','running','succeeded','failed','cancelled')), + stage TEXT NOT NULL, payload TEXT NOT NULL, result TEXT, error TEXT, + created_at REAL NOT NULL, updated_at REAL NOT NULL, owner TEXT, + claim_token TEXT, lease_until REAL); +CREATE UNIQUE INDEX IF NOT EXISTS one_pending_apply ON config_operations(kind) + WHERE kind='apply' AND status IN ('queued','running'); +CREATE TABLE IF NOT EXISTS bot_runtime ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), boot_id TEXT NOT NULL, + loaded_revision INTEGER NOT NULL, application_version TEXT NOT NULL, + heartbeat_at REAL NOT NULL, components TEXT NOT NULL); +""" + + +def _columns(fields): + sql_types = {"boolean": "INTEGER", "integer": "INTEGER", "number": "REAL"} + return ", ".join( + f'"{name}" {sql_types.get(spec["type"], "TEXT")}' for name, spec in fields.items() + ) + + +def declared_schema(): + from quickquip.llm.config_document import metadata + + registry = metadata() + columns = _columns(registry["parameters"]) + statements = [ + f"CREATE TABLE IF NOT EXISTS provider_parameters (" + f"provider_id TEXT PRIMARY KEY REFERENCES providers(id) ON DELETE CASCADE, {columns});", + f"CREATE TABLE IF NOT EXISTS model_parameters (provider_id TEXT NOT NULL," + f"model TEXT NOT NULL, {columns}, PRIMARY KEY(provider_id,model)," + f"FOREIGN KEY(provider_id,model) REFERENCES provider_models(provider_id,model) " + f"ON DELETE CASCADE);", + ] + for name, fields in registry["settings"].items(): + fields = {key: spec for key, spec in fields.items() if key != "model_cascade"} + if name == "mcp": + fields["servers"] = {"type": "array"} + statements.append( + f'CREATE TABLE IF NOT EXISTS "{name}_settings" ' + f"(singleton INTEGER PRIMARY KEY CHECK(singleton=1), {_columns(fields)});" + ) + return "\n".join(statements) + + +def _schema_tokens(sql): + """Normalize spelling while retaining constraint, predicate and trigger tokens.""" + tokens = re.findall( + r"'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`[^`]*`|\[[^]]*\]|" + r"[A-Za-z_][A-Za-z_0-9]*|[0-9.]+|\S", + sql, + ) + return tuple( + token + if token.startswith("'") + else ( + token[1:-1].replace('""', '"').lower() + if token.startswith(('"', "`", "[")) + else token.lower() + ) + for token in tokens + ) + + +@lru_cache(maxsize=1) +def expected_schema_objects(): + """Use SQLite's own stored DDL as the schema version's reference contract.""" + conn = sqlite3.connect(":memory:") + try: + conn.executescript(_SCHEMA + declared_schema()) + return { + (row[0], row[1]): _schema_tokens(row[2]) + for row in conn.execute("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL") + } + finally: + conn.close() + + +def _database_value(value): + return dumps(value) if isinstance(value, (dict, list)) else value + + +def _insert_fields(conn, table, identity, values): + fields = {**identity, **values} + columns = ",".join(f'"{field}"' for field in fields) + placeholders = ",".join("?" for _ in fields) + conn.execute( + f'INSERT INTO "{table}" ({columns}) VALUES({placeholders})', + tuple(_database_value(value) for value in fields.values()), + ) + + +def dumps(value) -> str: + return json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False + ) + + +class ConfigStore: + def __init__(self, path: Path | str): + self.path = Path(path) + + @contextmanager + def connect(self, *, write=False): + from quickquip.llm.config_document import ConfigError + + if not self.path.exists(): + raise ConfigError("not_initialized", "Configuration database is not initialized") + conn = sqlite3.connect(self.path, timeout=10, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=10000") + conn.execute("PRAGMA synchronous=FULL") + row = conn.execute( + "SELECT schema_version FROM config_meta WHERE singleton=1" + ).fetchone() + if row is None or row[0] != SCHEMA_VERSION: + raise ConfigError( + "unsupported_schema", + "Unsupported configuration schema", + details={"supported": SCHEMA_VERSION, "actual": row[0] if row else None}, + ) + if write: + conn.execute("BEGIN IMMEDIATE") + self._verify_schema(conn) + yield conn + if write: + conn.commit() + except sqlite3.OperationalError as exc: + if conn.in_transaction: + conn.rollback() + if "locked" in str(exc): + raise ConfigError("database_busy", "Configuration database is busy") from None + raise ConfigError( + "unsupported_schema", "Configuration database schema is invalid" + ) from None + except sqlite3.DatabaseError: + if conn.in_transaction: + conn.rollback() + raise ConfigError("database_corrupt", "Configuration database is unreadable") from None + except BaseException: + if conn.in_transaction: + conn.rollback() + raise + finally: + conn.close() + + @staticmethod + def _verify_schema(conn): + from quickquip.llm.config_document import ConfigError + + expected = expected_schema_objects() + actual = { + (row[0], row[1]): _schema_tokens(row[2]) + for row in conn.execute("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL") + } + missing = expected.keys() - actual.keys() + changed = {key for key in expected.keys() & actual.keys() if expected[key] != actual[key]} + if missing or changed: + raise ConfigError( + "unsupported_schema", + "Configuration schema objects are invalid", + details={ + "missing": sorted(name for _, name in missing), + "changed": sorted(name for _, name in changed), + }, + ) + + def verify(self): + """Check a schema-compatible backup before it can replace live state.""" + from quickquip.llm.config_document import ConfigError + + with self.connect() as conn: + self._verify_schema(conn) + if conn.execute("PRAGMA quick_check").fetchone()[0] != "ok": + raise ConfigError( + "database_corrupt", "Configuration database integrity check failed" + ) + if conn.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise ConfigError( + "database_corrupt", "Configuration database references are invalid" + ) + + def create(self, document, report=None, source=""): + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name(f".{self.path.name}.{uuid.uuid4().hex}.initializing") + try: + ConfigStore(temporary)._create(document, report, source) + with temporary.open("rb") as stream: + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + from quickquip.llm.config_durability import sync_directory + + sync_directory(self.path.parent) + finally: + for path in (temporary, Path(str(temporary) + "-wal"), Path(str(temporary) + "-shm")): + path.unlink(missing_ok=True) + + def _create(self, document, report=None, source=""): + import time + + self.path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(self.path, timeout=10) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA synchronous=FULL") + conn.executescript(_SCHEMA + declared_schema()) + with conn: + conn.execute( + "INSERT INTO config_meta VALUES (1,?,?,?,?)", + (SCHEMA_VERSION, 1, 1, dumps(document)), + ) + conn.execute( + "INSERT INTO config_snapshots VALUES (?,?,?)", (1, dumps(document), time.time()) + ) + project_document(conn, document) + if report is not None: + conn.execute( + "INSERT INTO config_migrations(source,report,created_at) VALUES(?,?,?)", + (source, dumps(report), time.time()), + ) + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + finally: + conn.close() + + +def project_document(conn, document): + """Maintain relational indexes in the same transaction as the draft.""" + from quickquip.llm.config_document import metadata + + registry = metadata() + for name in registry["settings"]: + conn.execute(f'DELETE FROM "{name}_settings"') + for table in ( + "model_aliases", + "model_parameters", + "provider_parameters", + "provider_models", + "providers", + "style_profiles", + "pricing_rules", + "route_entries", + "model_routes", + "config_settings", + ): + conn.execute(f"DELETE FROM {table}") + for position, provider in enumerate(document["providers"]): + pid = provider["id"] + conn.execute("INSERT INTO providers VALUES(?,?,?)", (pid, position, dumps(provider))) + _insert_fields( + conn, "provider_parameters", {"provider_id": pid}, provider.get("parameters", {}) + ) + for order, model in enumerate(provider.get("models", [])): + conn.execute( + "INSERT INTO provider_models VALUES(?,?,?,?)", + (pid, model["model"], order, dumps(model)), + ) + _insert_fields( + conn, + "model_parameters", + {"provider_id": pid, "model": model["model"]}, + model.get("parameters", {}), + ) + for order, alias in enumerate(provider.get("aliases", [])): + conn.execute( + "INSERT INTO model_aliases VALUES(?,?,?,?)", + (pid, alias["alias"], alias["model"], order), + ) + for name, text in document.get("style_profiles", {}).items(): + conn.execute("INSERT INTO style_profiles VALUES(?,?)", (name, text)) + for order, rule in enumerate(document.get("pricing", [])): + conn.execute("INSERT INTO pricing_rules VALUES(?,?)", (order, dumps(rule))) + for name, entries in document.get("routes", {}).items(): + conn.execute("INSERT INTO model_routes VALUES(?)", (name,)) + for order, entry in enumerate(entries): + conn.execute("INSERT INTO route_entries VALUES(?,?,?)", (name, order, dumps(entry))) + for name, settings in document.get("settings", {}).items(): + conn.execute("INSERT INTO config_settings VALUES(?,?)", (name, dumps(settings))) + _insert_fields(conn, f"{name}_settings", {"singleton": 1}, settings) diff --git a/src/quickquip/llm/config_tasks.py b/src/quickquip/llm/config_tasks.py new file mode 100644 index 00000000..70c90942 --- /dev/null +++ b/src/quickquip/llm/config_tasks.py @@ -0,0 +1,146 @@ +"""Cross-process configuration operation queue with fencing tokens.""" + +from __future__ import annotations + +import json +import time +import uuid + +from quickquip.llm.config_store import dumps + + +def operation(row): + if row is None: + return None + result = dict(row) + for key in ("payload", "result", "error"): + result[key] = json.loads(result[key]) if result[key] else None + return result + + +class ConfigTaskQueue: + def __init__(self, store): + self.store = store + + def get(self, operation_id): + from quickquip.llm.config_document import ConfigError + + with self.store.connect() as conn: + row = conn.execute( + "SELECT * FROM config_operations WHERE id=?", (operation_id,) + ).fetchone() + if row is None: + raise ConfigError("not_found", "Operation not found") + result = operation(row) + result.pop("claim_token", None) + return result + + def list(self, limit=20): + with self.store.connect() as conn: + rows = conn.execute( + "SELECT * FROM config_operations ORDER BY created_at DESC LIMIT ?", (limit,) + ).fetchall() + results = [operation(row) for row in rows] + for result in results: + result.pop("claim_token", None) + return results + + def claim(self, owner, lease_seconds=30, boot_id=None): + now = time.time() + with self.store.connect(write=True) as conn: + if boot_id is not None: + from quickquip.llm.config_service import ConfigService + ConfigService._assert_bot_boot(conn, boot_id) + conn.execute( + """UPDATE config_operations SET status='failed',stage='unconfirmed', + error=?,updated_at=? WHERE kind='probe' AND status='running' AND lease_until dict[str, PersonaConfig]: + personas: dict[str, PersonaConfig] = {} + for entry in raw_personas: + entry = expand_env_value(entry) + persona_id = str(entry.get("id", "")).strip() + if not persona_id: + continue + raw_scope = entry.get("scope", []) + if isinstance(raw_scope, str): + raw_scope = [raw_scope] + parsed_scope = [ + s for s in (str(s).strip().lower() for s in raw_scope) if s in {"group", "private"} + ] + extras = {k: v for k, v in entry.items() if k not in _KNOWN_PERSONA_KEYS} + personas[persona_id] = PersonaConfig( + id=persona_id, + display_name=str(entry.get("display_name", persona_id)).strip() or persona_id, + system_prompt=str(entry.get("system_prompt", "")).strip(), + style_prompt=str(entry.get("style_prompt", "")).strip(), + scope=parsed_scope, + extras=extras, + ) + return personas + + +def _load_personas_from_dir(personas_dir: Path) -> list[dict[str, Any]]: + """Load personas from a directory of TOML files. + + Each file may define a persona using flat top-level keys (id, display_name, + system_prompt, style_prompt, plus any extras), or via a [[personas]] array + for compatibility. + + A special file ``_shared.toml`` may define: + - ``shared_system_prompt``: appended to every persona's system_prompt + - ``shared_style_prompt``: appended to every persona's style_prompt + + Files are loaded in lexicographic order. ``_shared.toml`` is processed + first and never treated as a persona definition itself. + """ + if not personas_dir.is_dir(): + return [] + + # Load shared rules first + shared_system = "" + shared_style = "" + shared_path = personas_dir / "_shared.toml" + if shared_path.exists(): + try: + with shared_path.open("rb") as fh: + shared_data = tomllib.load(fh) + shared_system = str(shared_data.get("shared_system_prompt", "")).strip() + shared_style = str(shared_data.get("shared_style_prompt", "")).strip() + except tomllib.TOMLDecodeError as exc: + logger.warning("跳过损坏的 _shared.toml:%s", exc) + + personas: list[dict[str, Any]] = [] + for toml_file in sorted(personas_dir.glob("*.toml")): + if toml_file.name.startswith("_"): + continue # skip _shared.toml and other reserved files + try: + with toml_file.open("rb") as fh: + data = tomllib.load(fh) + except tomllib.TOMLDecodeError as exc: + logger.warning("跳过损坏的人格文件 %s:%s", toml_file.name, exc) + continue + + # Support flat top-level keys (preferred) or [[personas]] array + if "id" in data: + entry = dict(data) + # Inject shared content + if shared_system and entry.get("inherit_shared", True): + existing = str(entry.get("system_prompt", "")).rstrip() + entry["system_prompt"] = ( + (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + ) + if shared_style and entry.get("inherit_shared", True): + existing = str(entry.get("style_prompt", "")).rstrip() + entry["style_prompt"] = ( + (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + ) + personas.append(entry) + elif "personas" in data: + for entry in data["personas"]: + entry = dict(entry) + if shared_system and entry.get("inherit_shared", True): + existing = str(entry.get("system_prompt", "")).rstrip() + entry["system_prompt"] = ( + (existing + "\n\n" + shared_system).lstrip() if existing else shared_system + ) + if shared_style and entry.get("inherit_shared", True): + existing = str(entry.get("style_prompt", "")).rstrip() + entry["style_prompt"] = ( + (existing + "\n\n" + shared_style).lstrip() if existing else shared_style + ) + personas.append(entry) + + return personas + + +def load_personas(personas_dir: Path) -> dict[str, PersonaConfig]: + return _read_personas(_load_personas_from_dir(personas_dir)) diff --git a/src/quickquip/llm/pricing.py b/src/quickquip/llm/pricing.py index 0951514f..b4a1f0c3 100644 --- a/src/quickquip/llm/pricing.py +++ b/src/quickquip/llm/pricing.py @@ -6,9 +6,10 @@ 避免在朴素实现里写反互相掩盖。参照同机 agent-gateway 的 usage_from_mapping + estimate_cost 切分。 -价格全由 ``llm.toml`` 的 ``[pricing.models.]`` 提供(不内置易过时的官方 +价格由已发布配置快照的价格表提供(不内置易过时的官方 价表);未覆盖的模型 → ``priced=False``,由仪表盘"未定价"可见性提示补填。 """ + from __future__ import annotations from dataclasses import dataclass @@ -99,9 +100,7 @@ def estimate_cost_components( "cache_creation_cost_usd": 0.0, }, False cache_read_rate = ( - rates.cache_read_per_mtok - if rates.cache_read_per_mtok is not None - else rates.input_per_mtok + rates.cache_read_per_mtok if rates.cache_read_per_mtok is not None else rates.input_per_mtok ) cache_write_rate = ( rates.cache_write_per_mtok @@ -118,7 +117,7 @@ def estimate_cost_components( def match_pricing( - provider_id: str, model: str, configured: dict[str, PricingRates] + provider_id: str, model: str, configured: dict[str | tuple[str | None, str], PricingRates] ) -> PricingRates | None: """先查 ``provider_id/model``(per-provider 中转实际价),miss 回退纯 ``model`` (官方价默认),再 miss → None(未定价)。 @@ -126,6 +125,9 @@ def match_pricing( key 为 ``f"{provider_id}/{model}"`` 精确拼接——即使 model 名本身含 ``/``(如 OpenRouter 的 ``google/gemma-4-31b-it``),拼接后 ``openrouter/google/gemma-4-31b-it`` 仍与 configured 同名 key 精确匹配,无歧义;``is not None`` 判断避免依赖 truthiness。""" + if any(isinstance(key, tuple) for key in configured): + specific = configured.get((provider_id, model)) + return specific if specific is not None else configured.get((None, model)) specific = configured.get(f"{provider_id}/{model}") return specific if specific is not None else configured.get(model) diff --git a/src/quickquip/llm/profile.py b/src/quickquip/llm/profile.py index 245a1e7c..9b90b40e 100644 --- a/src/quickquip/llm/profile.py +++ b/src/quickquip/llm/profile.py @@ -4,6 +4,8 @@ from quickquip.llm.config import LLMConfig from quickquip.llm.provider import LLMProviderError, LLMRequest, build_provider_client +from quickquip.llm.config_document import resolve_model_provider +from quickquip.llm.usage import configuration_pricing_entry from quickquip.llm.tools import LLMConversationMessage from quickquip.llm.usage import set_usage_scope @@ -102,6 +104,7 @@ def _fit_recent_samples( return selected, len(selected) < len(recent_samples) +@configuration_pricing_entry async def generate_profile( target_name: str, message_count: int, @@ -164,6 +167,7 @@ async def generate_profile( raise LLMProviderError(f"Provider 未配置:{provider_id}") if not provider_config.enabled: raise LLMProviderError(f"Provider 已禁用:{provider_id}(enabled = false)") + provider_config = resolve_model_provider(llm_config, provider_id, model) client = build_provider_client(replace(provider_config, stream_enabled=False)) response = await client.complete(LLMRequest( model=model, diff --git a/src/quickquip/llm/provider/base.py b/src/quickquip/llm/provider/base.py index d88ec7b7..55a802de 100644 --- a/src/quickquip/llm/provider/base.py +++ b/src/quickquip/llm/provider/base.py @@ -431,8 +431,12 @@ def sanitize_gemini_schema(schema: Any) -> Any: class BaseProviderClient: - def __init__(self, config: ProviderConfig, retry_policy: RetryPolicy | None = None): + def __init__( + self, config: ProviderConfig, retry_policy: RetryPolicy | None = None, + *, single_dispatch: bool = False, + ): self.config = config + self.single_dispatch = single_dispatch self.retry_policy = retry_policy or RetryPolicy( max_attempts=config.retry_max_attempts, base_delay=config.retry_base_delay, @@ -477,6 +481,8 @@ async def _dispatch_with_retry( 每次尝试内部已含 fallback_urls 链,退避等待发生在链与链之间; 被重试吸收的失败不产生额外 usage 记录(明细见 HTTP trace)。 """ + if self.single_dispatch: + return await dispatch(request) policy = self.retry_policy attempt = 0 while True: @@ -515,6 +521,8 @@ async def complete(self, request: LLMRequest) -> LLMResponse: except LLMProviderError: raise except Exception: + if self.single_dispatch: + raise stream_used = False response = await self._dispatch_with_retry(self._complete_non_stream, request) else: @@ -687,7 +695,7 @@ async def _execute_with_fallback( 失败的可重试错误切换下一候选;不可重试立即抛。调用方用返回的 URL 构造实际 owner,不共享可变"最后成功端点"字段。 """ - if not self.config.fallback_urls: + if self.single_dispatch or not self.config.fallback_urls: return await fn(url, headers, payload), url last_exc: LLMProviderError | None = None for candidate in self._candidate_urls(url): diff --git a/src/quickquip/llm/provider/factory.py b/src/quickquip/llm/provider/factory.py index 87340753..3817353a 100644 --- a/src/quickquip/llm/provider/factory.py +++ b/src/quickquip/llm/provider/factory.py @@ -16,14 +16,23 @@ def build_provider_client( - config: ProviderConfig, *, retry_policy: RetryPolicy | None = None + config: ProviderConfig, *, retry_policy: RetryPolicy | None = None, + single_dispatch: bool = False, ) -> BaseProviderClient: if config.protocol == "openai": - return OpenAIProviderClient(config, retry_policy=retry_policy) + return OpenAIProviderClient( + config, retry_policy=retry_policy, single_dispatch=single_dispatch + ) if config.protocol == "claude": - return ClaudeProviderClient(config, retry_policy=retry_policy) + return ClaudeProviderClient( + config, retry_policy=retry_policy, single_dispatch=single_dispatch + ) if config.protocol == "gemini": - return GeminiProviderClient(config, retry_policy=retry_policy) + return GeminiProviderClient( + config, retry_policy=retry_policy, single_dispatch=single_dispatch + ) if config.protocol == "openai_responses": - return OpenAIResponsesProviderClient(config, retry_policy=retry_policy) + return OpenAIResponsesProviderClient( + config, retry_policy=retry_policy, single_dispatch=single_dispatch + ) raise LLMProviderError(f"未知 provider 协议:{config.protocol}") diff --git a/src/quickquip/llm/provider_health.py b/src/quickquip/llm/provider_health.py index 4a3692e0..506e653f 100644 --- a/src/quickquip/llm/provider_health.py +++ b/src/quickquip/llm/provider_health.py @@ -9,10 +9,11 @@ import asyncio import os import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from quickquip.llm.config import LLMConfig, ProviderConfig +from quickquip.llm.config_document import resolve_model_provider from quickquip.llm.usage import set_usage_scope PROBE_TIMEOUT_SECONDS = 5.0 @@ -79,7 +80,10 @@ async def probe_provider( max_output_tokens=_PROBE_MAX_TOKENS, ) # 探活要反映 provider 当前真实可用性,关闭自动重试 - client = build_provider_client(provider, retry_policy=RetryPolicy.disabled()) + client = build_provider_client( + replace(provider, fallback_urls=[], stream_enabled=False), + retry_policy=RetryPolicy.disabled(), single_dispatch=True, + ) started = time.monotonic() try: await asyncio.wait_for(client.complete(request), timeout=timeout) @@ -109,9 +113,20 @@ async def probe_all_providers( """并发探活所有 provider,总耗时 ≈ 单次 timeout,不随 provider 数线性增长。""" if not config.providers: return [] + providers = [] + unavailable = [] + for provider in config.providers.values(): + if not provider.enabled: + continue + try: + providers.append(resolve_model_provider(config, provider.id, provider.default_model)) + except ValueError: + unavailable.append(ProviderHealth( + provider.id, provider.default_model, "skipped", error="model_unavailable" + )) return await asyncio.gather( - *(probe_provider(p, timeout=timeout) for p in config.providers.values() if p.enabled) - ) + *(probe_provider(p, timeout=timeout) for p in providers) + ) + unavailable def format_probe_results(results: list[ProviderHealth]) -> str: diff --git a/src/quickquip/llm/quick_judge.py b/src/quickquip/llm/quick_judge.py index a6b608c3..5b26e59a 100644 --- a/src/quickquip/llm/quick_judge.py +++ b/src/quickquip/llm/quick_judge.py @@ -18,6 +18,7 @@ from time import monotonic from quickquip.llm.config import LLMConfig, ProviderConfig +from quickquip.llm.config_document import resolve_model_provider from quickquip.llm.provider import ( BaseProviderClient, LLMRequest, @@ -93,9 +94,15 @@ async def run_quick_judge_detailed( model="", ) provider_id = provider.id # 诊断字段跟随实际降级后的 provider - judge_provider = replace(provider, stream_enabled=False) - - model = qj.model if qj.model else judge_provider.default_model + model = qj.model if qj.model else provider.default_model + try: + judge_provider = replace( + resolve_model_provider(config, provider.id, model), stream_enabled=False + ) + except ValueError: + return QuickJudgeResult( + text='', outcome="no_provider", provider_id=provider_id, model=model + ) request = LLMRequest( model=model, diff --git a/src/quickquip/llm/runtime_snapshot.py b/src/quickquip/llm/runtime_snapshot.py new file mode 100644 index 00000000..3ffa2188 --- /dev/null +++ b/src/quickquip/llm/runtime_snapshot.py @@ -0,0 +1,376 @@ +"""Task leases over published configuration and MCP resource generations.""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from functools import wraps + +from quickquip.llm.config import LLMConfig +from quickquip.llm.mcp import MCPClientManager +from quickquip.llm.tool_registry import ToolRegistry + + +@dataclass(eq=False) +class RuntimeResources: + manager: MCPClientManager + registry: ToolRegistry + mcp_names: set[str] = field(default_factory=set) + dirty: bool = True + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + startup_task: asyncio.Task | None = None + leases: int = 0 + retired: bool = False + closed: bool = False + drained: asyncio.Event = field(default_factory=asyncio.Event) + + def __post_init__(self): + self.drained.set() + + +@dataclass(eq=False) +class RuntimeSnapshot: + config: LLMConfig + resources: RuntimeResources + image_preprocessor: object = None + + +@dataclass +class TaskToolBindings: + owner: object + registry: ToolRegistry + skills: dict + skill_tools_registered: bool + skill_registered_names: list[str] + + +_TASK_TOOLS: ContextVar[TaskToolBindings | None] = ContextVar("quickquip_task_tools", default=None) + + +_LEASE: ContextVar[tuple[object, RuntimeSnapshot] | None] = ContextVar( + "quickquip_configuration_lease", default=None +) +_REQUEST_PROVIDER: ContextVar[tuple[object, object] | None] = ContextVar( + "quickquip_request_provider", default=None +) + + +def current_snapshot(owner) -> RuntimeSnapshot | None: + lease = _LEASE.get() + return lease[1] if lease is not None and lease[0] is owner else None + + +def snapshot_entry(method): + """Pin all service reads through nested tools and asynchronous billing.""" + @wraps(method) + async def wrapped(self, *args, **kwargs): + async with self.configuration_lease(): + return await method(self, *args, **kwargs) + return wrapped + + +def chat_model_entry(method): + @wraps(method) + async def wrapped(self, request): + async with self.configuration_lease(): + if self.config.load_error or self.store is None: + return await method(self, request) + from quickquip.llm.config_document import resolve_model_provider + settings = self.get_chat_settings(request.chat_id, chat_type=request.chat_type) + try: + provider = resolve_model_provider(self.config, settings.provider_id, settings.model) + except ValueError: + provider = None + token = _REQUEST_PROVIDER.set((self, provider)) + try: + return await method(self, request) + finally: + _REQUEST_PROVIDER.reset(token) + return wrapped + + +def leased_service(get_service): + """Adapter/job entry with an injected service getter.""" + def decorate(function): + @wraps(function) + async def wrapped(*args, **kwargs): + service = get_service(*args, **kwargs) + if hasattr(service, "configuration_lease"): + async with service.configuration_lease(): + return await function(*args, **kwargs) + return await function(*args, **kwargs) + return wrapped + return decorate + + +class RuntimeSnapshotMixin: + def effective_request_provider(self, provider_id): + entry = _REQUEST_PROVIDER.get() + if entry is not None and entry[0] is self: + provider = entry[1] + if provider is not None and provider.id == provider_id: + return provider + return self.config.providers.get(provider_id) + + def _init_configuration_leases(self): + self._configuration_gate = asyncio.Lock() + self._configuration_stopping = False + self._configuration_fenced = False + self._configuration_boot_check = None + self._retired_resources: set[RuntimeResources] = set() + self._resource_cleanup_tasks: set[asyncio.Task] = set() + self._runtime_snapshot = RuntimeSnapshot( + self._config, RuntimeResources(self._mcp_manager, self._tool_registry), + self._image_preprocessor, + ) + + def _snapshot(self): + return current_snapshot(self) or getattr(self, "_runtime_snapshot", None) + + @property + def config(self): + if current_snapshot(self) is None: + checker = getattr(self, "_configuration_boot_check", None) + if checker is not None: + try: + checker() + except Exception: + self.fence_configuration_runtime() + raise + snapshot = self._snapshot() + return snapshot.config if snapshot is not None else self._config + + @config.setter + def config(self, value): + # Assignment creates a new view; active leases keep their old view. + snapshot = getattr(self, "_runtime_snapshot", None) + self._config = value + if snapshot is not None: + self._runtime_snapshot = RuntimeSnapshot( + value, snapshot.resources, snapshot.image_preprocessor + ) + + @property + def mcp_manager(self): + snapshot = self._snapshot() + return snapshot.resources.manager if snapshot is not None else self._mcp_manager + + @mcp_manager.setter + def mcp_manager(self, value): + self._mcp_manager = value + snapshot = self._snapshot() + if snapshot is not None: + snapshot.resources.manager = value + + @property + def tool_registry(self): + binding = _TASK_TOOLS.get() + if binding is not None and binding.owner is self: + return binding.registry + snapshot = self._snapshot() + return snapshot.resources.registry if snapshot is not None else self._tool_registry + + @tool_registry.setter + def tool_registry(self, value): + self._tool_registry = value + snapshot = self._snapshot() + if snapshot is not None: + snapshot.resources.registry = value + + @property + def image_preprocessor(self): + snapshot = self._snapshot() + return snapshot.image_preprocessor if snapshot is not None else self._image_preprocessor + + @image_preprocessor.setter + def image_preprocessor(self, value): + self._image_preprocessor = value + snapshot = self._snapshot() + if snapshot is not None: + snapshot.image_preprocessor = value + + @contextmanager + def configuration_view(self, snapshot): + """Prepare resources or scheduler descriptions with an explicit view.""" + previous = current_snapshot(self) + token = _LEASE.set((self, snapshot)) + tools_token = _TASK_TOOLS.set(None) if previous is not snapshot else None + try: + yield snapshot + finally: + _LEASE.reset(token) + if tools_token is not None: + _TASK_TOOLS.reset(tools_token) + + @asynccontextmanager + async def configuration_lease(self): + # Lightweight degraded/test hosts retain the same entry contract. + if not hasattr(self, "_runtime_snapshot"): + from quickquip.llm.usage import configuration_pricing + with configuration_pricing(self.config): + yield self.config + return + async with self._configuration_gate: + if self._configuration_fenced and current_snapshot(self) is None: + from quickquip.llm.config_errors import ConfigError + raise ConfigError( + "runtime_superseded", "A newer bot boot owns configuration control" + ) + if self._configuration_stopping and current_snapshot(self) is None: + raise RuntimeError("LLM configuration runtime is shutting down") + if current_snapshot(self) is None and self._configuration_boot_check is not None: + try: + self._configuration_boot_check() + except Exception: + self.fence_configuration_runtime() + raise + snapshot = current_snapshot(self) or self._runtime_snapshot + snapshot.resources.leases += 1 + snapshot.resources.drained.clear() + from quickquip.llm.usage import configuration_pricing + try: + with self.configuration_view(snapshot), configuration_pricing(snapshot.config): + existing = _TASK_TOOLS.get() + tools_token = None + if existing is None or existing.owner is not self: + tools_token = _TASK_TOOLS.set(TaskToolBindings( + self, snapshot.resources.registry.fork(), + dict(getattr(self, "_initial_skill_catalog", {})), + getattr(self, "_initial_skill_registered", False), + list(getattr(self, "_initial_skill_names", [])), + )) + try: + yield snapshot.config + finally: + if tools_token is not None: + _TASK_TOOLS.reset(tools_token) + finally: + snapshot.resources.leases -= 1 + if snapshot.resources.leases == 0: + snapshot.resources.drained.set() + self._release_retired(snapshot.resources) + + def _release_retired(self, resources): + if resources.retired and resources.leases == 0 and not resources.closed: + resources.closed = True + task = asyncio.create_task(self._close_resources(resources)) + self._resource_cleanup_tasks.add(task) + task.add_done_callback(self._resource_cleanup_tasks.discard) + + def create_configuration_task(self, coroutine): + """Reserve the parent lease before scheduling a background child.""" + snapshot = self._snapshot() + if snapshot is None: + return asyncio.create_task(coroutine) + snapshot.resources.leases += 1 + snapshot.resources.drained.clear() + tools = _TASK_TOOLS.get() + + async def run(): + from quickquip.llm.usage import configuration_pricing + try: + with self.configuration_view(snapshot), configuration_pricing(snapshot.config): + token = _TASK_TOOLS.set(tools) + try: + return await coroutine + finally: + _TASK_TOOLS.reset(token) + finally: + snapshot.resources.leases -= 1 + if snapshot.resources.leases == 0: + snapshot.resources.drained.set() + self._release_retired(snapshot.resources) + return asyncio.create_task(run()) + + async def _close_resources(self, resources): + if resources.startup_task is not None: + resources.startup_task.cancel() + await asyncio.gather(resources.startup_task, return_exceptions=True) + await resources.manager.aclose() + self._retired_resources.discard(resources) + + def prepare_configuration(self, config, *, force_mcp=False): + old = self._runtime_snapshot + resources = old.resources + if force_mcp or old.config.mcp != config.mcp: + resources = RuntimeResources(MCPClientManager(), ToolRegistry()) + snapshot = RuntimeSnapshot(config, resources) + with self.configuration_view(snapshot): + if resources is not old.resources: + self._register_builtin_tools() + self.register_skill_tools() + self.rebuild_image_preprocessor() + return snapshot + + def activate_configuration(self, snapshot): + old = self._runtime_snapshot + self._runtime_snapshot = snapshot + self._config = snapshot.config + if old.resources is not snapshot.resources: + old.resources.retired = True + self._retired_resources.add(old.resources) + self._release_retired(old.resources) + + def fence_configuration_runtime(self): + """Stop new work; tasks with a pre-existing lease may finish.""" + self._configuration_fenced = True + self._configuration_stopping = True + + async def drain_configuration_resources(self): + self._configuration_stopping = True + generations = {*self._retired_resources, self._runtime_snapshot.resources} + await asyncio.gather(*(resources.drained.wait() for resources in generations)) + for resources in generations: + if not resources.closed: + resources.closed = True + await self._close_resources(resources) + if self._resource_cleanup_tasks: + await asyncio.gather(*self._resource_cleanup_tasks, return_exceptions=True) + + +def _resource_property(name): + def get(self): + snapshot = self._snapshot() + if snapshot is not None: + return getattr(snapshot.resources, name) + return getattr(self, "_initial_" + name) + + def set_(self, value): + snapshot = self._snapshot() + if snapshot is not None: + setattr(snapshot.resources, name, value) + else: + setattr(self, "_initial_" + name, value) + return property(get, set_) + + +for _attribute, _field in { + "_mcp_tool_names": "mcp_names", "_mcp_dirty": "dirty", "_mcp_lock": "lock", + "_mcp_startup_task": "startup_task", +}.items(): + setattr(RuntimeSnapshotMixin, _attribute, _resource_property(_field)) + + +def _task_tools_property(field, initial): + def get(self): + binding = _TASK_TOOLS.get() + if binding is not None and binding.owner is self: + return getattr(binding, field) + return getattr(self, initial) + + def set_(self, value): + binding = _TASK_TOOLS.get() + if binding is not None and binding.owner is self: + setattr(binding, field, value) + else: + setattr(self, initial, value) + return property(get, set_) + + +for _attribute, _field, _initial in ( + ("_skills_catalog_by_name", "skills", "_initial_skill_catalog"), + ("_skill_tools_registered", "skill_tools_registered", "_initial_skill_registered"), + ("_skill_registered_names", "skill_registered_names", "_initial_skill_names"), +): + setattr(RuntimeSnapshotMixin, _attribute, _task_tools_property(_field, _initial)) diff --git a/src/quickquip/llm/service.py b/src/quickquip/llm/service.py index cf2b599d..602bce5c 100644 --- a/src/quickquip/llm/service.py +++ b/src/quickquip/llm/service.py @@ -30,8 +30,6 @@ LLMConfig, PersonaConfig, ProviderConfig, - load_llm_config, - load_personas_only, provider_builtin_search_active, ) from quickquip.llm.history_projection import HistoryProjectionError, project_loops_with_budget @@ -133,6 +131,7 @@ from quickquip.llm.settings import ResolvedGroupSettings, resolve_group_settings from quickquip.llm.store import LLMStore from quickquip.llm.tool_registry import ToolRegistry +from quickquip.llm.runtime_snapshot import RuntimeSnapshotMixin, chat_model_entry, snapshot_entry from quickquip.llm.tool_loop import run_tool_call_loop from quickquip.llm.tools import ( LLMConversationMessage, @@ -142,7 +141,6 @@ ) from quickquip.llm.vocab import VocabIndex from quickquip.common.paths import ( - CONFIG_LLM_TOML, LLM_DB_PATH, LLM_IDENTITIES_YAML_PATH, LLM_VOCAB_YAML_PATH, @@ -155,7 +153,9 @@ from quickquip.common.recent_message_buffer import RecentMessageBuffer -CONFIG_PATH = CONFIG_LLM_TOML +from quickquip.common.paths import DATA_DIR + +CONFIG_PATH = DATA_DIR / "llm_config.db" DB_PATH = LLM_DB_PATH VOCAB_PATH = LLM_VOCAB_YAML_PATH IDENTITY_PATH = LLM_IDENTITIES_YAML_PATH @@ -172,6 +172,7 @@ class LLMService( + RuntimeSnapshotMixin, ScopeMixin, ToolMixin, McpLifecycleMixin, @@ -191,6 +192,8 @@ def __init__( vocab_path: str | Path = VOCAB_PATH, identity_path: str | Path = IDENTITY_PATH, image_preprocessor: ImagePreprocessor | None = None, + config: LLMConfig | None = None, + config_service=None, ): self.config_path = Path(config_path) self.vocab_path = Path(vocab_path) @@ -215,7 +218,25 @@ def __init__( self._init_error: str | None = None self._register_builtin_tools() - self.config = load_llm_config(self.config_path) + self.config_service = config_service + if config is None: + if self.config_service is None: + from quickquip.llm.config_service import ConfigService + self.config_service = ConfigService(self.config_path) + from quickquip.common.paths import CONFIG_LLM_TOML + from quickquip.llm.config_runtime import initialize_for_bot + initialize_for_bot( + self.config_service, + CONFIG_LLM_TOML if self.config_path == CONFIG_PATH else None + ) + from quickquip.llm.config_document import compile_document + published = self.config_service.read("published") + config = compile_document( + published["document"], config_dir=self.config_path.parent.parent / "config", + revision=published["revision"], + ) + self.config = config + self._init_configuration_leases() # skills 需要在 config 就位后做启动注册;空目录时留待每轮构建系统 # 提示时惰性注册(热部署,无 reload 钩子)。 self._init_skills() @@ -283,18 +304,27 @@ def group_identities(self, group_id: str) -> IdentityIndex: return self._resolve_identities(group_id) def reload_config(self) -> LLMConfig: - self.config = load_llm_config(self.config_path) + if self.config_service is None: + return self.config + from quickquip.llm.config_document import compile_document + published = self.config_service.read("published") + config = compile_document( + published["document"], config_dir=self.config_path.parent.parent / "config", + revision=published["revision"], + ) + if config.mcp != self.config.mcp: + raise RuntimeError("MCP 配置更新须通过配置应用任务执行") + self.activate_configuration(self.prepare_configuration(config)) _reload_sensitive_filter() self.rebuild_image_preprocessor() self.vocab = VocabIndex.from_file(self.vocab_path) self._identity_repository.invalidate() identities.invalidate() self._group_vocabs.clear() - self.mark_mcp_dirty() return self.config def reload_personas(self) -> tuple[int, str | None]: - """Reload only the personas section (``[[personas]]`` + ``config/personas/``). + """Reload independent files from ``config/personas/``. Does **not** touch providers / MCP / runtime — those stay as loaded at startup, since reloading them requires restarting provider clients and @@ -302,14 +332,16 @@ def reload_personas(self) -> tuple[int, str | None]: error or an empty result, the previous ``self.config.personas`` is kept. """ try: - new_personas = load_personas_only(self.config_path) + from quickquip.llm.personas import load_personas + new_personas = load_personas(self.config_path.parent.parent / "config" / "personas") except Exception as exc: return 0, str(exc) if not new_personas: return 0, "配置中没有可用的人格" - self.config.personas = new_personas - if self.config.runtime.default_persona not in new_personas: - self.config.runtime.default_persona = next(iter(new_personas)) + runtime = self.config.runtime + if runtime.default_persona not in new_personas: + runtime = replace(runtime, default_persona=next(iter(new_personas))) + self.config = replace(self.config, personas=new_personas, runtime=runtime) return len(new_personas), None def get_chat_settings( @@ -332,7 +364,11 @@ def get_group_settings(self, group_id: int | str) -> ResolvedGroupSettings: def _update_chat_settings( self, chat_id: int | str, chat_type: str = "group", **fields: object ) -> None: - self.store.update_group_settings(self.build_chat_scope_key(chat_id, chat_type), **fields) + scope_key = self.build_chat_scope_key(chat_id, chat_type) + if self.config_service is not None: + self.config_service.update_group_override(self.store, scope_key, **fields) + else: + self.store.update_group_settings(scope_key, **fields) def _build_system_prompt( self, @@ -417,6 +453,7 @@ def _collect_mention_profiles( quoted_user_id=quoted_user_id, ) + @snapshot_entry async def quick_judge(self, prompt: str, max_tokens: int | None = None) -> str: """ 一次性极速判定的纯文本契约(现存调用方:draw_svg 内容裁决)。 @@ -431,6 +468,7 @@ async def quick_judge(self, prompt: str, max_tokens: int | None = None) -> str: self.config, prompt, max_tokens, client_builder=build_provider_client ) + @snapshot_entry async def quick_judge_detailed( self, prompt: str, max_tokens: int | None = None ) -> QuickJudgeResult: @@ -933,7 +971,7 @@ def _persist_turn_and_build_reply( if trigger_auto_memory and settings.auto_memory_enabled and settings.memory_enabled: generation_at_schedule, _ = self.store.agent_scope_state(scope_key) - asyncio.create_task( + self.create_configuration_task( self._extract_auto_memory( scope_key=scope_key, user_id=user_id, @@ -986,6 +1024,7 @@ async def _generate_reply_for_scope(self, request: ChatTurnRequest) -> ReplyResu ) return await self._generate_reply_for_scope_locked(request) + @chat_model_entry async def _generate_reply_for_scope_locked( self, request: ChatTurnRequest ) -> ReplyResult: @@ -1028,6 +1067,14 @@ async def _generate_reply_for_scope_locked( DISABLED_PROVIDER_REPLY.format(provider_id=settings.provider_id), llm_used=False ) + from quickquip.llm.config_document import resolve_model_provider + try: + provider = resolve_model_provider( + self.config, provider.id, settings.model or provider.default_model + ) + except ValueError: + return reply_result("当前模型不可用。", llm_used=False) + persona = self.config.personas.get(settings.persona_id) if persona is None: return reply_result(f"当前 persona 不存在:{settings.persona_id}", llm_used=False) diff --git a/src/quickquip/llm/service_parts/health.py b/src/quickquip/llm/service_parts/health.py index 6bbb7897..1dafeeda 100644 --- a/src/quickquip/llm/service_parts/health.py +++ b/src/quickquip/llm/service_parts/health.py @@ -16,6 +16,7 @@ from quickquip.llm.provider.openai_responses.request import reasoning_control from quickquip.llm.provider_health import format_probe_results, probe_all_providers, probe_provider from quickquip.llm.settings import ResolvedGroupSettings +from quickquip.llm.runtime_snapshot import snapshot_entry from quickquip.llm.thinking import family_defaults_to_max_thinking, resolve_thinking from quickquip.llm.mcp.types import ( MCP_FAILURE_AUTH, @@ -311,6 +312,7 @@ def format_current(self, group_id: int | str, chat_type: str = "group") -> str: lines.append(f"临时上下文:仅触发当下向前最多 {MAX_TRIGGER_CONTEXT_MESSAGES} 条群消息") return "\n".join(lines) + @snapshot_entry async def build_health_report( self, group_id: int | str, chat_type: str = "group", *, probe_provider: bool = False ) -> HealthReport: @@ -357,11 +359,13 @@ async def format_health( verbose=verbose, ) + @snapshot_entry async def format_provider_probe(self) -> str: """并发探活所有 provider 并格式化结果(/llm probe 用,每次调用即每次计费)。""" results = await probe_all_providers(self.config) return format_probe_results(results) + @snapshot_entry async def format_current_provider_probe( self, group_id: int | str, chat_type: str = "group" ) -> str: @@ -372,6 +376,10 @@ async def format_current_provider_probe( return f"当前 provider 不存在:{settings.provider_id}" if not provider.enabled: return DISABLED_PROVIDER_REPLY.format(provider_id=settings.provider_id) + from quickquip.llm.config_document import resolve_model_provider + provider = resolve_model_provider( + self.config, provider.id, settings.model or provider.default_model + ) result = await probe_provider(provider, model=settings.model or None) body = format_probe_results([result]) if result.status == "ok": diff --git a/src/quickquip/llm/service_parts/mcp_lifecycle.py b/src/quickquip/llm/service_parts/mcp_lifecycle.py index 962339fa..56134a5c 100644 --- a/src/quickquip/llm/service_parts/mcp_lifecycle.py +++ b/src/quickquip/llm/service_parts/mcp_lifecycle.py @@ -61,9 +61,10 @@ def _clear_mcp_tools(self) -> None: def _register_mcp_tools(self) -> None: self._clear_mcp_tools() + manager = self.mcp_manager for binding in self.mcp_manager.bindings.values(): async def _handler(arguments, context, *, alias=binding.alias): - return await self.mcp_manager.execute(alias, arguments, context) + return await manager.execute(alias, arguments, context) self.tool_registry.register( LLMToolSpec( @@ -81,6 +82,7 @@ async def _handler(arguments, context, *, alias=binding.alias): async def ensure_mcp_ready(self, force: bool = False, force_pull: bool = False) -> None: async with self._mcp_lock: if not force and not self._mcp_dirty: + self._register_mcp_tools() return await self.mcp_manager.sync(self.config.mcp, force_pull=force_pull) self._register_mcp_tools() @@ -114,6 +116,9 @@ async def startup(self, *, background: bool = False) -> None: await self.ensure_mcp_ready(force=True) async def shutdown(self) -> None: + if hasattr(self, "_runtime_snapshot"): + await self.drain_configuration_resources() + return if self._mcp_startup_task is not None: self._mcp_startup_task.cancel() await asyncio.gather(self._mcp_startup_task, return_exceptions=True) @@ -122,6 +127,27 @@ async def shutdown(self) -> None: async def reload_mcp(self, *, background: bool = False) -> None: """重连所有 MCP 服务器,对 docker transport 强制 pull 最新镜像。""" + if hasattr(self, "_runtime_snapshot"): + base = self._runtime_snapshot + snapshot = self.prepare_configuration(self.config, force_mcp=True) + try: + if not background: + with self.configuration_view(snapshot): + await self.ensure_mcp_ready(force=True, force_pull=True) + async with self._configuration_gate: + if self._runtime_snapshot is not base: + raise RuntimeError( + "Configuration changed during MCP preparation; retry reload" + ) + self.activate_configuration(snapshot) + if background: + with self.configuration_view(snapshot): + self.start_mcp_background(force=True, force_pull=True) + except BaseException: + if snapshot is not self._runtime_snapshot: + await self._close_resources(snapshot.resources) + raise + return self._mcp_dirty = True if background: self.start_mcp_background(force=True, force_pull=True) diff --git a/src/quickquip/llm/service_parts/single_shot.py b/src/quickquip/llm/service_parts/single_shot.py index b8141de4..9c16a311 100644 --- a/src/quickquip/llm/service_parts/single_shot.py +++ b/src/quickquip/llm/service_parts/single_shot.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any +from quickquip.llm.runtime_snapshot import snapshot_entry from quickquip.llm.single_shot import ( CommandSingleShotSpec, @@ -86,6 +87,7 @@ def _turmfluch_reply_text(raw_text: str) -> str | None: class SingleShotEntriesMixin: """defectify / turmfluch / card_le_nearest 的命令入口与被动入口。""" + @snapshot_entry async def generate_defectify_reply( self, *, @@ -116,6 +118,7 @@ async def generate_defectify_reply( quoted_user_id=quoted_user_id, ) + @snapshot_entry async def generate_turmfluch_reply( self, *, @@ -147,6 +150,7 @@ async def generate_turmfluch_reply( quoted_user_id=quoted_user_id, ) + @snapshot_entry async def generate_card_le_nearest( self, *, diff --git a/src/quickquip/llm/service_parts/skills.py b/src/quickquip/llm/service_parts/skills.py index e2701ee1..0cf93cfb 100644 --- a/src/quickquip/llm/service_parts/skills.py +++ b/src/quickquip/llm/service_parts/skills.py @@ -216,7 +216,7 @@ def format_skill_list(self, chat_id: int | str, chat_type: str = "group") -> str 不改会话与历史状态;会刷新记忆化的漂移名单(分叉状态翻转时记日志)。 """ if not self.config.skills.enabled: - return "Skill 功能当前未启用(config/llm.toml [skills] enabled = false)。" + return "Skill 功能当前未启用,请在 LLM 配置管理中启用。" catalog_dir = resolve_catalog_dir(self.config.skills.catalog_dir) skills = self._drop_blocked_skill_descriptions(scan_skills(catalog_dir)) scope = self.build_chat_scope_key(chat_id, chat_type) @@ -231,7 +231,7 @@ def format_skill_list(self, chat_id: int | str, chat_type: str = "group") -> str def _skills_disabled_result(self) -> LLMToolOutput | None: if not self.config.skills.enabled: return LLMToolOutput( - content="Skill 功能当前未启用(config/llm.toml [skills] enabled = false)。", + content="Skill 功能当前未启用,请在 LLM 配置管理中启用。", is_error=True, ) return None diff --git a/src/quickquip/llm/service_parts/tools.py b/src/quickquip/llm/service_parts/tools.py index d9367d0f..5fcbd883 100644 --- a/src/quickquip/llm/service_parts/tools.py +++ b/src/quickquip/llm/service_parts/tools.py @@ -10,11 +10,15 @@ from quickquip.llm.image_preprocessor import ImagePreprocessor, VisionImagePreprocessor from quickquip.llm.provider import build_provider_client from quickquip.llm.service_parts.constants import ( + ACTIVATE_SKILL_TOOL_NAME, DEFAULT_ALWAYS_LOADED_TOOLS, DEFAULT_ENABLED_TOOLS, MAX_TRIGGER_CONTEXT_MESSAGES, PRIVATE_UNAVAILABLE_TOOLS, + READ_SKILL_RESOURCE_TOOL_NAME, + RUN_SKILL_SCRIPT_TOOL_NAME, SEARCH_TOOL_NAME, + SEARCH_SKILL_RESOURCES_TOOL_NAME, TOOL_LIST_NAME, TOOL_SEARCH_NAME, ) @@ -260,7 +264,11 @@ def _builtin_search_active(self, provider_id: str | None) -> bool: """该 provider 会话是否由内置搜索接管联网检索(从而移除 search_web)。""" if not provider_id: return False - provider = self.config.providers.get(provider_id) + provider = ( + self.effective_request_provider(provider_id) + if hasattr(self, "effective_request_provider") + else self.config.providers.get(provider_id) + ) return provider is not None and provider_builtin_search_active(provider) def _get_enabled_tool_names( @@ -274,6 +282,12 @@ def _get_enabled_tool_names( else: # append:默认白名单 + MCP 工具之上追加,opt-in 工具的启用路径 names = [*DEFAULT_ENABLED_TOOLS, *sorted(self.mcp_tool_names), *configured] names = list(dict.fromkeys(names)) + if not self.config.skills.enabled: + skill_tools = { + ACTIVATE_SKILL_TOOL_NAME, READ_SKILL_RESOURCE_TOOL_NAME, + SEARCH_SKILL_RESOURCES_TOOL_NAME, RUN_SKILL_SCRIPT_TOOL_NAME, + } + names = [name for name in names if name not in skill_tools] if chat_type == "private": names = [name for name in names if name not in PRIVATE_UNAVAILABLE_TOOLS] if self._builtin_search_active(provider_id): @@ -384,6 +398,14 @@ def rebuild_image_preprocessor(self) -> None: return vision_model = img_cfg.model or vis_provider_cfg.default_model + from quickquip.llm.config_document import resolve_model_provider + try: + vis_provider_cfg = resolve_model_provider( + self.config, vis_provider_cfg.id, vision_model + ) + except ValueError: + self.image_preprocessor = None + return if vision_model in vis_provider_cfg.non_vision_models: logger.warning( "image_preprocessing model %r is declared non-vision by provider %r", @@ -403,11 +425,9 @@ def rebuild_image_preprocessor(self) -> None: ) async def reload_runtime(self, *, background: bool = False) -> LLMConfig: - self.reload_config() - if background: - self.start_mcp_background(force=True) - return self.config - await self.ensure_mcp_ready(force=True) + async with self._configuration_gate: + self.reload_config() + await self.reload_mcp(background=background) return self.config async def _tool_get_identity( diff --git a/src/quickquip/llm/settings.py b/src/quickquip/llm/settings.py index e612be65..47e125bd 100644 --- a/src/quickquip/llm/settings.py +++ b/src/quickquip/llm/settings.py @@ -37,6 +37,14 @@ def resolve_group_settings(store, config, group_id: int | str) -> ResolvedGroupS provider_id = overrides.provider_id or config.runtime.default_provider or "" provider = config.providers.get(provider_id) model = overrides.model or (provider.default_model if provider else "") + if provider is not None: + model = provider.aliases.get(model, model) + if provider is not None and model: + from quickquip.llm.config_document import resolve_model_provider + try: + provider = resolve_model_provider(config, provider_id, model) + except ValueError: + pass return ResolvedGroupSettings( enabled=overrides.enabled if overrides.enabled is not None else config.runtime.enabled, diff --git a/src/quickquip/llm/single_shot.py b/src/quickquip/llm/single_shot.py index 75c3cbc3..b2257815 100644 --- a/src/quickquip/llm/single_shot.py +++ b/src/quickquip/llm/single_shot.py @@ -28,6 +28,7 @@ scan_and_log, ) from quickquip.llm.config import DISABLED_PROVIDER_REPLY, LLMConfig, ProviderConfig +from quickquip.llm.config_document import resolve_model_provider from quickquip.llm.provider import ( BaseProviderClient, LLMProviderError, @@ -184,6 +185,12 @@ async def run_command_single_shot( return _early_result(spec, f"当前 provider 不存在:{settings.provider_id}") if not provider.enabled: return _early_result(spec, DISABLED_PROVIDER_REPLY.format(provider_id=settings.provider_id)) + try: + provider = resolve_model_provider( + config, provider.id, settings.model or provider.default_model + ) + except ValueError: + return _early_result(spec, "当前模型不可用。") prompt_pack = spec.prompt_builder( prompt=normalized_prompt, @@ -267,6 +274,10 @@ async def run_card_le_nearest( return None prompt_pack = build_nearest_prompt(captured=captured) + try: + provider = resolve_model_provider(config, provider.id, qj.model or provider.default_model) + except ValueError: + return None request = _build_request( provider=provider, model=qj.model if qj.model else provider.default_model, diff --git a/src/quickquip/llm/skills/catalog.py b/src/quickquip/llm/skills/catalog.py index 98b95239..5bbfdb50 100644 --- a/src/quickquip/llm/skills/catalog.py +++ b/src/quickquip/llm/skills/catalog.py @@ -21,7 +21,6 @@ import os import re import stat -import tomllib from dataclasses import dataclass, field from pathlib import Path @@ -117,24 +116,14 @@ def resolve_catalog_dir(configured: str = "") -> Path: def read_configured_catalog_dir(config_path: Path) -> str: - """从 llm.toml 直读 ``[skills].catalog_dir`` 原始字符串。 - - 文件缺失/不可读、TOML 解析失败、节或键缺失、值非字符串一律回退 ``""`` - (即默认目录)。分工说明:运行时类型化配置由 ``llm/config.py`` 在自身 - 解析管线内读取同一键(含校验与默认值),本函数服务 Web Admin 等只需要 - 生效目录原始值、不想连带装入整套运行时配置的场景;两侧共用 - ``resolve_catalog_dir`` 的解析语义,config.py 侧维持现状不复用本函数。 - """ - try: - with Path(config_path).open("rb") as handle: - data = tomllib.load(handle) - except (OSError, tomllib.TOMLDecodeError): - return "" - section = data.get("skills") - if not isinstance(section, dict): + """Read the published Skill directory from the configuration database.""" + from quickquip.llm.config_service import ConfigService + from quickquip.llm.config_document import compile_document + if not Path(config_path).exists(): return "" - value = section.get("catalog_dir") - return value if isinstance(value, str) else "" + snapshot = ConfigService(config_path).read("published") + config = compile_document(snapshot["document"], revision=snapshot["revision"]) + return config.skills.catalog_dir def derive_catalog_budget_bytes( diff --git a/src/quickquip/llm/summarize.py b/src/quickquip/llm/summarize.py index d3f4efa8..df52db11 100644 --- a/src/quickquip/llm/summarize.py +++ b/src/quickquip/llm/summarize.py @@ -17,10 +17,11 @@ ProviderConfig, ) from quickquip.llm.context_windows import resolve_context_window +from quickquip.llm.config_document import resolve_model_provider from quickquip.llm.provider import LLMProviderError, LLMRequest, build_provider_client from quickquip.llm.response_acceptance import is_response_accepted from quickquip.llm.tools import LLMConversationMessage -from quickquip.llm.usage import new_usage_run_id, set_usage_scope +from quickquip.llm.usage import configuration_pricing_entry, new_usage_run_id, set_usage_scope logger = logging.getLogger(__name__) @@ -149,6 +150,10 @@ def _resolve_cascade( if not provider_config.enabled: logger.info("summary cascade: provider %r disabled, skipping", provider_id) continue + try: + provider_config = resolve_model_provider(llm_config, provider_id, model) + except ValueError: + continue resolved.append((provider_id, model, provider_config)) return resolved @@ -266,6 +271,7 @@ async def _run_summary_cascade( raise RuntimeError(f"所有模型均调用失败,最后错误:{last_error}") +@configuration_pricing_entry async def generate_daily_summary( messages: list[dict], persona: PersonaConfig, @@ -376,6 +382,7 @@ def _build_period_system_prompt( return "\n\n".join(parts) +@configuration_pricing_entry async def generate_period_report( messages: list[dict], persona: PersonaConfig, diff --git a/src/quickquip/llm/tool_registry.py b/src/quickquip/llm/tool_registry.py index 136397bc..cf62e9c7 100644 --- a/src/quickquip/llm/tool_registry.py +++ b/src/quickquip/llm/tool_registry.py @@ -147,6 +147,12 @@ def validate_tool_arguments(schema: dict[str, Any], payload: Any) -> dict[str, A class ToolRegistry: + def fork(self) -> ToolRegistry: + """Freeze registered bindings for one task; later registrations stay local.""" + registry = ToolRegistry() + registry._tools = dict(self._tools) + return registry + def __init__(self) -> None: self._tools: dict[str, RegisteredTool] = {} diff --git a/src/quickquip/llm/usage.py b/src/quickquip/llm/usage.py index a629ca87..4aac77a9 100644 --- a/src/quickquip/llm/usage.py +++ b/src/quickquip/llm/usage.py @@ -5,6 +5,7 @@ ``_schedule_usage_record`` fire-and-forget 调度,计量失败**绝不影响主聊天 路径**——全程 try/except 只 logger.exception。 """ + from __future__ import annotations import asyncio @@ -14,6 +15,8 @@ import uuid from contextlib import contextmanager from contextvars import ContextVar +from functools import wraps +from inspect import signature from dataclasses import dataclass from datetime import datetime, timezone from typing import Iterator @@ -32,7 +35,8 @@ class UsageScope: _USAGE_SCOPE: ContextVar[UsageScope | None] = ContextVar( - "quickquip_llm_usage_scope", default=None, + "quickquip_llm_usage_scope", + default=None, ) @@ -77,7 +81,8 @@ def set_usage_scope( _ENVELOPE_TOKENS: ContextVar[int | None] = ContextVar( - "quickquip_llm_envelope_tokens", default=None, + "quickquip_llm_envelope_tokens", + default=None, ) @@ -96,7 +101,8 @@ def envelope_meter(tokens: int | None) -> Iterator[None]: _EPOCH_HISTORY_TOKENS: ContextVar[int | None] = ContextVar( - "quickquip_llm_epoch_history_tokens", default=None, + "quickquip_llm_epoch_history_tokens", + default=None, ) @@ -114,7 +120,8 @@ def epoch_meter(tokens: int | None) -> Iterator[None]: _EPOCH_HISTORY_ROWS: ContextVar[int | None] = ContextVar( - "quickquip_llm_epoch_history_rows", default=None, + "quickquip_llm_epoch_history_rows", + default=None, ) @@ -132,7 +139,8 @@ def epoch_rows_meter(rows: int | None) -> Iterator[None]: _MEDIA_IMAGE_COUNT: ContextVar[int | None] = ContextVar( - "quickquip_llm_media_image_count", default=None, + "quickquip_llm_media_image_count", + default=None, ) @@ -152,7 +160,8 @@ def media_meter(count: int | None) -> Iterator[None]: _PATCH_TOKENS: ContextVar[int | None] = ContextVar( - "quickquip_llm_patch_tokens", default=None, + "quickquip_llm_patch_tokens", + default=None, ) @@ -172,13 +181,46 @@ def patch_meter(tokens: int | None) -> Iterator[None]: def _configured_pricing() -> dict: """从 llm_service 取 [pricing.models](延迟 import 避免 provider↔service 循环)。""" + pinned = _CONFIGURATION_PRICING.get() + if pinned is not None: + return pinned[1] try: from quickquip.llm.service import get_llm_service - return get_llm_service().config.pricing + + config = get_llm_service().config + return config.pricing_references or config.pricing except Exception: return {} +_CONFIGURATION_PRICING: ContextVar[tuple[int, dict] | None] = ContextVar( + "quickquip_configuration_pricing", default=None +) + + +@contextmanager +def configuration_pricing(config): + token = _CONFIGURATION_PRICING.set( + (getattr(config, "revision", 0), config.pricing_references or config.pricing) + ) + try: + yield + finally: + _CONFIGURATION_PRICING.reset(token) + + +def configuration_pricing_entry(function): + parameters = signature(function) + + @wraps(function) + async def wrapped(*args, **kwargs): + config = parameters.bind(*args, **kwargs).arguments["llm_config"] + with configuration_pricing(config): + return await function(*args, **kwargs) + + return wrapped + + async def _record_usage( client, request, @@ -198,6 +240,7 @@ async def _record_usage( try: scope = _USAGE_SCOPE.get() from quickquip.llm.provider.trace import current_agent_loop_id + loop_id = current_agent_loop_id() # 复用 trace.py 的 agent loop 边界,零接线 model = response.model if response is not None else request.model duration_ms = (time.monotonic() - started) * 1000 @@ -246,7 +289,8 @@ async def _record_usage( if rates is not None: pricing_model = ( f"{client.config.id}/{model}" - if f"{client.config.id}/{model}" in configured + if (client.config.id, model) in configured + or f"{client.config.id}/{model}" in configured else model ) pricing_source = rates.source @@ -254,6 +298,11 @@ async def _record_usage( row = { "provider_id": client.config.id, + "config_revision": ( + _CONFIGURATION_PRICING.get()[0] + if _CONFIGURATION_PRICING.get() is not None + else None + ), "protocol": client.config.protocol, "model": model, "feature": scope.feature if scope else None, @@ -301,8 +350,10 @@ async def _record_usage( row["response_outcome"] = "provider_error" else: from quickquip.llm.response_acceptance import classify_response + row["response_outcome"] = classify_response(response, feature=scope.feature) from quickquip.llm.usage_store import usage_store + await asyncio.to_thread(usage_store.record, row) except (OSError, sqlite3.Error): logger.exception("LLM usage record failed") diff --git a/src/quickquip/llm/usage_store.py b/src/quickquip/llm/usage_store.py index 74547212..c4e7ced8 100644 --- a/src/quickquip/llm/usage_store.py +++ b/src/quickquip/llm/usage_store.py @@ -4,6 +4,7 @@ 与 ``trace.py`` 的 debug-only 全正文 trace 一刀切:trace=调试、14 天、标志门控; usage=常驻计量、90 天、永不被标志门控。两者唯一共享面是 ``LLMResponse``。 """ + from __future__ import annotations import logging @@ -170,6 +171,7 @@ def _ensure_schema(self) -> None: # 因此每列 ALTER 前重查表结构;仍撞上 duplicate column name # (该错误只会源于列已存在)同样视为已存在,保证两边都不抛。 migrations = { + "config_revision": "INTEGER", "feature": "TEXT", "group_id": "TEXT", "persona_id": "TEXT", @@ -196,15 +198,12 @@ def _ensure_schema(self) -> None: } for name, definition in migrations.items(): columns = { - row[1] - for row in conn.execute("PRAGMA table_info(llm_usage_events)") + row[1] for row in conn.execute("PRAGMA table_info(llm_usage_events)") } if name in columns: continue try: - conn.execute( - f"ALTER TABLE llm_usage_events ADD COLUMN {name} {definition}" - ) + conn.execute(f"ALTER TABLE llm_usage_events ADD COLUMN {name} {definition}") except sqlite3.OperationalError as error: if "duplicate column name" not in str(error): raise @@ -355,9 +354,7 @@ def summary(self, cutoff: str, **filters: str | None) -> dict: "avg_envelope_tokens": round(total["avg_envelope"], 1) if total["avg_envelope"] is not None else 0.0, - "envelope_coverage": round( - total["envelope_tracked"] / total["successes"], 4 - ) + "envelope_coverage": round(total["envelope_tracked"] / total["successes"], 4) if total["successes"] else 0.0, # 第五张账本【纪元】:[anchor, head) history 段 token 估算;同信封口径 @@ -448,24 +445,31 @@ def timeline( ) if not fill_buckets: return [ - {"date": r["d"], "cost": round(r["cost"], 6), "tokens": r["tokens"], - "requests": r["requests"], "errors": r["errors"], - "duration": round(r["duration"], 2), - "value": self._timeline_value(r, metric)} + { + "date": r["d"], + "cost": round(r["cost"], 6), + "tokens": r["tokens"], + "requests": r["requests"], + "errors": r["errors"], + "duration": round(r["duration"], 2), + "value": self._timeline_value(r, metric), + } for r in rows ] while cursor <= end: key = cursor.strftime(fmt) row = indexed.get(key) - result.append({ - "date": key, - "cost": round(row["cost"], 6) if row else 0.0, - "tokens": row["tokens"] if row else 0, - "requests": row["requests"] if row else 0, - "errors": row["errors"] if row else 0, - "duration": round(row["duration"], 2) if row else 0.0, - "value": self._timeline_value(row, metric), - }) + result.append( + { + "date": key, + "cost": round(row["cost"], 6) if row else 0.0, + "tokens": row["tokens"] if row else 0, + "requests": row["requests"] if row else 0, + "errors": row["errors"] if row else 0, + "duration": round(row["duration"], 2) if row else 0.0, + "value": self._timeline_value(row, metric), + } + ) cursor += step return result diff --git a/tests/fixtures/configs.py b/tests/fixtures/configs.py index 6cf7a9d2..faf5fa05 100644 --- a/tests/fixtures/configs.py +++ b/tests/fixtures/configs.py @@ -3,6 +3,7 @@ Provides minimal-but-valid TOML/YAML bodies for LLMService bootstrap, plus a pytest fixture that writes them to a tmp dir and returns a live LLMService. """ + from __future__ import annotations import textwrap @@ -99,11 +100,19 @@ def write_llm_config_bundle( identities_yaml: str = IDENTITIES_YAML, ) -> dict[str, Path]: """Write the four canonical files into `base_dir` and return their paths.""" - config_path = base_dir / "llm.toml" + legacy_path = base_dir / "config" / "llm.toml" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + config_path = base_dir / "data" / "llm_config.db" db_path = base_dir / "llm.db" vocab_path = base_dir / "vocab.yaml" identity_path = base_dir / "identities.yaml" - config_path.write_text(config_toml, encoding="utf-8") + if "[skills]" not in config_toml: + import json + config_toml += "\n[skills]\ncatalog_dir = " + json.dumps(str(base_dir / "skills")) + "\n" + legacy_path.write_text(config_toml, encoding="utf-8") + from quickquip.llm.config_service import ConfigService + + ConfigService(config_path).initialize(legacy_path) vocab_path.write_text(vocab_yaml, encoding="utf-8") identity_path.write_text(identities_yaml, encoding="utf-8") return { diff --git a/tests/integration/test_agent_loop_request_lifecycle.py b/tests/integration/test_agent_loop_request_lifecycle.py index f15b20ad..fcd363ab 100644 --- a/tests/integration/test_agent_loop_request_lifecycle.py +++ b/tests/integration/test_agent_loop_request_lifecycle.py @@ -61,15 +61,14 @@ async def complete(request): client = AsyncMock() client.complete.side_effect = complete patch_provider_builder(lambda provider: client) - original_execute = llm_service.tool_registry.execute - - async def execute(call, context): - if stage == "tool" and call.id == "call-1": + async def execute(arguments, context): + if stage == "tool" and execute_mock.await_count == 2: await wait_for_cancellation() - return await original_execute(call, context) + return await llm_service._tool_get_identity(arguments, context) execute_mock = AsyncMock(side_effect=execute) - monkeypatch.setattr(llm_service.tool_registry, "execute", execute_mock) + spec = llm_service.tool_registry.get_specs(["get_identity"])[0] + llm_service.tool_registry.register(spec, execute_mock) sink = CollectingSink() async def deliver(delivery_id, payload): diff --git a/tests/integration/test_llm_service.py b/tests/integration/test_llm_service.py index 276a280b..200499ae 100644 --- a/tests/integration/test_llm_service.py +++ b/tests/integration/test_llm_service.py @@ -754,138 +754,78 @@ async def test_memory_crud_basic(wired_service): assert wired_service.list_group_memories(1001) == [] +def _replace_persona_files(service, entries): + directory = service.config_path.parent.parent / "config" / "personas" + for file in directory.glob("*.toml"): + file.unlink() + for ident, text in entries.items(): + (directory / f"{ident}.toml").write_text(text, encoding="utf-8") + + def test_reload_personas_swaps_config_personas_only(llm_service): original_providers = llm_service.config.providers original_runtime = llm_service.config.runtime - - # Overwrite llm.toml with a different persona set (and different providers - # to prove reload_personas does NOT touch providers). - new_toml = """ -[runtime] -enabled = true -default_provider = "openai-main" -default_persona = "chatter" -history_limit = 6 -history_max_messages_per_group = 8 -memory_limit = 3 -memory_max_items_per_group = 20 -max_prompt_chars = 1000 -tool_calling_enabled = true - -[triggers] -default_prefix = "/ai" -allow_prefix = true -allow_at = true - -[[personas]] -id = "chatter" -display_name = "健谈人格" -system_prompt = "你特别健谈。" - -[[personas]] -id = "silent" -display_name = "寡言人格" -system_prompt = "你话少。" - -[[providers]] -id = "ghost-provider" -protocol = "openai" -base_url = "https://ghost.example/v1" -api_key_env = "GHOST_API_KEY" -default_model = "ghost-model" -""" - llm_service.config_path.write_text(new_toml.lstrip(), encoding="utf-8") - + _replace_persona_files(llm_service, { + "chatter": 'id = "chatter"\ndisplay_name = "健谈人格"\nsystem_prompt = "健谈"\n', + "silent": 'id = "silent"\nsystem_prompt = "寡言"\n', + }) count, error = llm_service.reload_personas() assert error is None assert count == 2 - assert set(llm_service.config.personas.keys()) == {"chatter", "silent"} + assert set(llm_service.config.personas) == {"chatter", "silent"} assert llm_service.config.personas["chatter"].display_name == "健谈人格" - - # Providers and runtime untouched — this is the whole point. assert llm_service.config.providers is original_providers - assert llm_service.config.runtime is original_runtime + assert llm_service.config.runtime.enabled == original_runtime.enabled assert "openai-main" in llm_service.config.providers def test_reload_personas_falls_back_when_default_removed(llm_service): - assert llm_service.config.runtime.default_persona == "default" - - new_toml = """ -[runtime] -enabled = true -default_provider = "openai-main" -default_persona = "default" - -[triggers] -default_prefix = "/ai" - -[[personas]] -id = "freshling" -display_name = "新来人格" -system_prompt = "hi" - -[[providers]] -id = "openai-main" -protocol = "openai" -base_url = "https://example.test/v1" -api_key_env = "OPENAI_API_KEY" -default_model = "gpt-test" -""" - llm_service.config_path.write_text(new_toml.lstrip(), encoding="utf-8") - + _replace_persona_files(llm_service, {"freshling": 'id = "freshling"\nsystem_prompt = "hi"\n'}) count, error = llm_service.reload_personas() assert error is None assert count == 1 - # default_persona no longer exists → falls back to first available assert llm_service.config.runtime.default_persona == "freshling" def test_reload_personas_empty_keeps_previous(llm_service): original = llm_service.config.personas - llm_service.config_path.write_text( - '[runtime]\ndefault_provider = "x"\n', encoding="utf-8" - ) + _replace_persona_files(llm_service, {}) count, error = llm_service.reload_personas() assert count == 0 assert "没有可用的人格" in error assert llm_service.config.personas is original -def test_reload_config_rebuilds_image_preprocessor(llm_service, monkeypatch): - from tests.fixtures.configs import MIN_LLM_CONFIG_TOML +def _publish_document(service, document): + config_service = service.config_service + saved = config_service.replace(document, config_service.read()["revision"]) + operation = config_service.apply(saved["revision"], str(saved["revision"])) + claimed = config_service.tasks.claim("test-bot") + assert operation["id"] == claimed["id"] + config_service.publish(claimed["id"], claimed["claim_token"]) + config_service.tasks.finish(claimed["id"], claimed["claim_token"]) - built = [] - class _StubClient: +def test_reload_config_rebuilds_image_preprocessor(llm_service, monkeypatch): + built = [] + class StubClient: pass - - def _build(provider): + def build(provider): built.append(provider) - return _StubClient() - - monkeypatch.setattr("quickquip.llm.service_parts.tools.build_provider_client", _build) - llm_service.config_path.write_text( - MIN_LLM_CONFIG_TOML - + """ - -[image_preprocessing] -enabled = true -provider_id = "openai-main" -model = "gpt-test" -""", - encoding="utf-8", + return StubClient() + monkeypatch.setattr("quickquip.llm.service_parts.tools.build_provider_client", build) + document = llm_service.config_service.read()["document"] + document["settings"]["image_preprocessing"].update( + enabled=True, provider_id="openai-main", model="gpt-test" ) - + _publish_document(llm_service, document) llm_service.reload_config() - assert llm_service.image_preprocessor is not None assert llm_service.image_preprocessor._model == "gpt-test" - assert built and built[-1].id == "openai-main" - - llm_service.config_path.write_text(MIN_LLM_CONFIG_TOML, encoding="utf-8") + assert built[-1].id == "openai-main" + document["settings"]["image_preprocessing"]["enabled"] = False + _publish_document(llm_service, document) llm_service.reload_config() - assert llm_service.image_preprocessor is None diff --git a/tests/integration/test_llm_skills.py b/tests/integration/test_llm_skills.py index 8ee81dae..df8c501b 100644 --- a/tests/integration/test_llm_skills.py +++ b/tests/integration/test_llm_skills.py @@ -645,7 +645,6 @@ async def test_hot_deploy_lazy_registration(tmp_path, patch_provider_builder): assert second_request.system_prompt.endswith("") assert "- late-demo: 热部署演示 skill。" in second_request.system_prompt for name in SKILL_TOOL_NAMES: - assert service.tool_registry.has_tool(name) assert name in _spec_names(second_request) (activate_spec,) = [ spec for spec in second_request.tools if spec.name == "activate_skill" diff --git a/tests/unit/adapters/test_config_control.py b/tests/unit/adapters/test_config_control.py new file mode 100644 index 00000000..0774c060 --- /dev/null +++ b/tests/unit/adapters/test_config_control.py @@ -0,0 +1,57 @@ +"""Owned report jobs and rollback preserve scheduling without sending missed reports.""" +from __future__ import annotations + +import asyncio +from dataclasses import replace +import sys +from types import SimpleNamespace + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from quickquip.adapters.nonebot.config_control import replace_report_schedules +from quickquip.llm.config import LLMConfig + + +async def test_disabling_report_removes_owned_jobs_and_rollback_preserves_next_run(monkeypatch): + from quickquip.adapters.nonebot import daily_briefing_plugin as briefing + from quickquip.adapters.nonebot import daily_summary_plugin as summary + scheduler = AsyncIOScheduler() + monkeypatch.setitem( + sys.modules, "nonebot_plugin_apscheduler", SimpleNamespace(scheduler=scheduler) + ) + monkeypatch.setattr(summary, "scheduler", scheduler) + monkeypatch.setattr(briefing, "scheduler", scheduler) + monkeypatch.setattr(summary, "_ensure_llm_bindings", lambda: None) + old = LLMConfig() + old.daily_summary.enabled = True + new = replace(old, daily_summary=replace(old.daily_summary, enabled=False)) + monkeypatch.setattr(summary, "get_llm_service", lambda: SimpleNamespace(config=new)) + calls = [] + def send(): + calls.append("sent") + scheduler.add_job(send, "cron", hour=23, id="daily_summary_generate") + scheduler.add_job(send, "cron", hour=23, id="daily_summary_publish") + scheduler.add_job(send, "cron", hour=23, id="other_domain") + scheduler.start(paused=True) + saved = {job.id: job.next_run_time for job in scheduler.get_jobs()} + try: + rollback = replace_report_schedules(old, new) + assert [job.id for job in scheduler.get_jobs()] == ["other_domain"] + assert calls == [] + rollback() + assert {job.id: job.next_run_time for job in scheduler.get_jobs()} == saved + assert calls == [] + finally: + scheduler.shutdown(wait=False) + await asyncio.sleep(0) + + +async def test_unrelated_change_does_not_reconnect_or_reschedule(monkeypatch): + old = LLMConfig() + new = replace(old, runtime=replace(old.runtime, max_prompt_chars=2200)) + class Scheduler: + def get_jobs(self): + raise AssertionError("Report scheduler must remain untouched") + monkeypatch.setitem(sys.modules, "nonebot_plugin_apscheduler", + SimpleNamespace(scheduler=Scheduler())) + replace_report_schedules(old, new)() diff --git a/tests/unit/adapters/test_profile_snapshot.py b/tests/unit/adapters/test_profile_snapshot.py new file mode 100644 index 00000000..4b585f38 --- /dev/null +++ b/tests/unit/adapters/test_profile_snapshot.py @@ -0,0 +1,88 @@ +"""A profile command pins persona/style/model through asynchronous archive reads.""" +from __future__ import annotations + +import asyncio +import threading +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from quickquip.adapters.nonebot.command_parts import history +from quickquip.llm.config_runtime import ConfigRuntimeWorker + + +async def test_profile_keeps_model_style_and_revision_during_config_application( + llm_service, monkeypatch +): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + service = llm_service.config_service + worker = ConfigRuntimeWorker(service, llm_service) + draft = service.read() + draft["document"]["providers"][0]["parameters"]["style_overrides"] = "CHANNEL-STYLE" + draft["document"]["providers"][0]["models"][0]["parameters"]["style_overrides"] = "MODEL-OLD" + saved = service.replace(draft["document"], draft["revision"]) + service.apply(saved["revision"], "profile-initial") + await worker.tick() + initial_revision = llm_service.config.revision + matchers = {} + class Matcher: + def handle(self): + def install(function): + self.handler = function + return function + return install + async def send(self, message): + pass + async def finish(self, message): + raise AssertionError(f"Profile failed: {message}") + def command(name, **kwargs): + matchers[name] = Matcher() + return matchers[name] + history.register_history_commands(command, str, SimpleNamespace(text=str)) + monkeypatch.setattr(history, "get_llm_service", lambda: llm_service) + monkeypatch.setattr(history, "_ensure_llm_bindings", lambda: None) + monkeypatch.setattr(history, "stats_tracker", SimpleNamespace( + get_stats=lambda group: SimpleNamespace(user_messages={"2002": 2}) + )) + monkeypatch.setattr(history, "identities", SimpleNamespace( + snapshot=lambda group: SimpleNamespace(name=lambda user: "Target") + )) + entered = asyncio.Event() + release = threading.Event() + loop = asyncio.get_running_loop() + def archive_read(*args): + loop.call_soon_threadsafe(entered.set) + assert release.wait(timeout=10) + return [{"user_id": "2002", "text": "sample"}] + monkeypatch.setattr(history.chat_archive, "read_window", archive_read) + captured = {} + async def generate(**kwargs): + captured.update(kwargs) + return "Generated profile", "openai-main/gpt-test" + monkeypatch.setattr(history, "generate_profile", generate) + monkeypatch.setattr(history, "send_long_group_message", AsyncMock()) + event = SimpleNamespace( + group_id=1001, message_type="group", + get_message=lambda: "/profile [CQ:at,qq=2002]", + ) + task = asyncio.create_task(matchers["profile"].handler(SimpleNamespace(), event)) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + next_draft = service.read() + parameters = next_draft["document"]["providers"][0]["models"][0]["parameters"] + parameters["style_overrides"] = "MODEL-NEW" + next_saved = service.replace(next_draft["document"], next_draft["revision"]) + service.apply(next_saved["revision"], "profile-new") + await worker.tick() + release.set() + await task + assert captured["llm_config"].revision == initial_revision + assert "MODEL-OLD" in captured["system_prompt"] + assert "MODEL-NEW" not in captured["system_prompt"] + assert "CHANNEL-STYLE" not in captured["system_prompt"] + assert llm_service.config.revision > initial_revision + finally: + release.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await llm_service.shutdown() diff --git a/tests/unit/adapters/test_voice.py b/tests/unit/adapters/test_voice.py index 640285ef..221d07e9 100644 --- a/tests/unit/adapters/test_voice.py +++ b/tests/unit/adapters/test_voice.py @@ -56,10 +56,8 @@ async def get_record(self, *, file, out_format): return {"file": str(audio_file)} original_config_path = generation_service.config_path - original_legacy_path = generation_service.legacy_llm_path original_config = generation_service._config original_active_mtime = generation_service._active_mtime - original_legacy_mtime = generation_service._legacy_mtime async def fake_transcribe(model_config, provider, audio_bytes, *, filename, mime_type): assert model_config.model == "whisper-1" @@ -71,7 +69,6 @@ async def fake_transcribe(model_config, provider, audio_bytes, *, filename, mime try: generation_service.config_path = config_path - generation_service.legacy_llm_path = tmp_path / "missing-llm.toml" generation_service.reload() monkeypatch.setattr("quickquip.adapters.nonebot.voice.transcribe_audio", fake_transcribe) @@ -83,7 +80,5 @@ async def fake_transcribe(model_config, provider, audio_bytes, *, filename, mime assert [item.text for item in transcripts] == ["转写结果"] finally: generation_service.config_path = original_config_path - generation_service.legacy_llm_path = original_legacy_path generation_service._config = original_config generation_service._active_mtime = original_active_mtime - generation_service._legacy_mtime = original_legacy_mtime diff --git a/tests/unit/generation/test_config.py b/tests/unit/generation/test_config.py index 59f540d2..72c3e67d 100644 --- a/tests/unit/generation/test_config.py +++ b/tests/unit/generation/test_config.py @@ -48,7 +48,7 @@ def test_load_generation_config_from_dedicated_file(tmp_path: Path, monkeypatch) assert resolved.model_config.model == "doubao-seedream-4-0" -def test_load_generation_config_falls_back_to_legacy_llm_file(tmp_path: Path): +def test_missing_generation_file_does_not_load_legacy_llm_file(tmp_path: Path): legacy_path = tmp_path / "llm.toml" legacy_path.write_text( textwrap.dedent( @@ -72,17 +72,11 @@ def test_load_generation_config_falls_back_to_legacy_llm_file(tmp_path: Path): encoding="utf-8", ) - loaded = load_generation_config( - tmp_path / "generation.toml", - legacy_llm_path=legacy_path, - ) - - assert loaded.load_error is None - assert loaded.source_kind == "llm_legacy" - assert loaded.source_path == legacy_path - assert loaded.image.enabled is True - assert loaded.image.default_model == "minimax-image" - assert loaded.image.resolve_model("minimax-image") is not None + loaded = load_generation_config(tmp_path / "generation.toml") + assert loaded.load_error is not None + assert loaded.source_kind == "generation" + assert loaded.image.enabled is False + assert loaded.image.resolve_model("minimax-image") is None def test_generation_service_prefers_dedicated_config_over_legacy(tmp_path: Path): @@ -129,7 +123,7 @@ def test_generation_service_prefers_dedicated_config_over_legacy(tmp_path: Path) encoding="utf-8", ) - service = GenerationService(generation_path, legacy_llm_path=legacy_path) + service = GenerationService(generation_path) resolved = service.resolve_image_model() assert resolved is not None diff --git a/tests/unit/llm/skills/test_catalog.py b/tests/unit/llm/skills/test_catalog.py index e59a7a17..5bb61f19 100644 --- a/tests/unit/llm/skills/test_catalog.py +++ b/tests/unit/llm/skills/test_catalog.py @@ -425,22 +425,20 @@ def test_resolve_file_in_rejects_symlink_escape(make_skill, tmp_path): # ── read_configured_catalog_dir ────────────────────────────────────── -def test_read_configured_catalog_dir_reads_value(tmp_path): - config = tmp_path / "llm.toml" - config.write_text('[skills]\ncatalog_dir = "/tmp/custom-skills"\n', encoding="utf-8") +def test_read_configured_catalog_dir_uses_published_configuration(tmp_path): + from quickquip.llm.config_service import ConfigService + config = tmp_path / "llm_config.db" + service = ConfigService(config) + service.initialize() + document = service.read()["document"] + document["settings"]["skills"]["catalog_dir"] = "/tmp/custom-skills" + saved = service.replace(document, service.read()["revision"]) + assert read_configured_catalog_dir(config) == "" + service.apply(saved["revision"], "test-publication") + claimed = service.tasks.claim("bot") + service.publish(claimed["id"], claimed["claim_token"]) assert read_configured_catalog_dir(config) == "/tmp/custom-skills" -def test_read_configured_catalog_dir_fallbacks(tmp_path): - # 文件缺失 - assert read_configured_catalog_dir(tmp_path / "missing.toml") == "" - config = tmp_path / "llm.toml" - # 节缺失 / 键缺失 - config.write_text("[runtime]\nenabled = true\n", encoding="utf-8") - assert read_configured_catalog_dir(config) == "" - # 非字符串 - config.write_text("[skills]\ncatalog_dir = 123\n", encoding="utf-8") - assert read_configured_catalog_dir(config) == "" - # TOML 解析失败 - config.write_text("not [valid toml", encoding="utf-8") - assert read_configured_catalog_dir(config) == "" +def test_read_configured_catalog_dir_missing_database_uses_default(tmp_path): + assert read_configured_catalog_dir(tmp_path / "missing.db") == "" diff --git a/tests/unit/llm/test_bot_boot_fencing.py b/tests/unit/llm/test_bot_boot_fencing.py new file mode 100644 index 00000000..b8289a2e --- /dev/null +++ b/tests/unit/llm/test_bot_boot_fencing.py @@ -0,0 +1,102 @@ +"""A superseded boot cannot regress publication, loaded state, or admit new work.""" +from __future__ import annotations + +import asyncio + +import pytest + +from quickquip.llm.config_document import compile_document +from quickquip.llm.config_errors import ConfigError +from quickquip.llm.config_runtime import ConfigRuntimeWorker +from quickquip.llm.service import LLMService + + +@pytest.mark.parametrize("paused_boundary", ["publication", "activation"]) +async def test_resumed_old_boot_is_fenced_after_new_bot_applies_later_revision( + llm_service, monkeypatch, paused_boundary +): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + old = llm_service + service = old.config_service + first_worker = ConfigRuntimeWorker(service, old) + entered, release = asyncio.Event(), asyncio.Event() + old_turn_versions = [] + async def already_started_turn(): + async with old.configuration_lease(): + entered.set() + await release.wait() + old_turn_versions.append(old.config.revision) + turn = asyncio.create_task(already_started_turn()) + await entered.wait() + draft = service.read() + draft["document"]["settings"]["runtime"]["max_prompt_chars"] = 2200 + saved = service.replace(draft["document"], draft["revision"]) + first_operation = service.apply(saved["revision"], "first-apply") + original_publish = service.publish + original_activate = old.activate_configuration + replacement = {} + + def takeover(): + # This synchronous pause hook models another process operating while this + # worker is stopped immediately after its durable or in-memory commit. + restarted = LLMService( + config_path=old.config_path, db_path=old.store.path, + identity_path=old.identity_path, vocab_path=old.vocab_path, + ) + worker = ConfigRuntimeWorker(service, restarted) + replacement.update(runtime=restarted, worker=worker) + with service.store.connect(write=True) as conn: + conn.execute("UPDATE config_operations SET lease_until=0 WHERE id=?", + (first_operation["id"],)) + recovered = service.tasks.claim(worker.boot_id, boot_id=worker.boot_id) + original_publish(recovered["id"], recovered["claim_token"], boot_id=worker.boot_id) + service.tasks.finish(recovered["id"], recovered["claim_token"]) + next_draft = service.read() + next_draft["document"]["settings"]["runtime"]["max_prompt_chars"] = 3300 + next_saved = service.replace(next_draft["document"], next_draft["revision"]) + service.apply(next_saved["revision"], "second-apply") + claimed = service.tasks.claim(worker.boot_id, boot_id=worker.boot_id) + original_publish(claimed["id"], claimed["claim_token"], boot_id=worker.boot_id) + document = service.read(claimed["target_revision"]) + config = compile_document( + document["document"], revision=document["revision"], + config_dir=old.config_path.parent.parent / "config", + ) + restarted.activate_configuration(restarted.prepare_configuration(config)) + worker.report() + service.tasks.finish(claimed["id"], claimed["claim_token"]) + + def publish(*args, **kwargs): + result = original_publish(*args, **kwargs) + if paused_boundary == "publication": + takeover() + return result + + def activate(snapshot): + original_activate(snapshot) + if paused_boundary == "activation": + takeover() + + monkeypatch.setattr(service, "publish", publish) + monkeypatch.setattr(old, "activate_configuration", activate) + await first_worker.tick() + latest = service.status() + assert latest["published_revision"] == 3 + assert latest["bot"]["loaded_revision"] == 3 + assert latest["bot"]["boot_id"] == replacement["worker"].boot_id + latest_epoch = int(replacement["worker"].boot_id.split(":")[0]) + assert latest_epoch > int(first_worker.boot_id.split(":")[0]) + assert old._configuration_fenced + with pytest.raises(ConfigError, match="newer bot boot"): + async with old.configuration_lease(): + pytest.fail("Superseded boot admitted a new task") + with pytest.raises(ConfigError): + _ = old.config + with pytest.raises(ConfigError): + first_worker.report() + assert service.status()["bot"]["loaded_revision"] == 3 + release.set() + await turn + assert old_turn_versions == [1] + await old.shutdown() + await replacement["runtime"].shutdown() diff --git a/tests/unit/llm/test_config_document.py b/tests/unit/llm/test_config_document.py new file mode 100644 index 00000000..c72d237c --- /dev/null +++ b/tests/unit/llm/test_config_document.py @@ -0,0 +1,397 @@ +"""Protect strict writes, effective overrides and credential-free JSON migration.""" + +from copy import deepcopy +import json +from pathlib import Path +import tomllib + +import pytest + +from quickquip.llm.config_document import ( + ConfigError, + compile_document, + effective_parameters, + empty_document, + resolve_model_provider, + validate_document, +) +from quickquip.llm.config_migration import migrate_legacy +from quickquip.llm.config_legacy import load_llm_config +from tests.fixtures.configs import MIN_LLM_CONFIG_TOML + + +def document(): + doc = empty_document() + doc["providers"] = [ + { + "id": "p", + "protocol": "openai", + "base_url": "https://example.test/v1", + "api_key_env": "QQ_TEST_KEY", + "default_model": "org/model", + "parameters": { + "temperature": 0.5, + "stream_enabled": True, + "extra_body": {"a": {"b": 1}}, + "style_profile": "shared", + "style_overrides": "append", + }, + "models": [ + { + "model": "org/model", + "parameters": { + "temperature": 0, + "stream_enabled": False, + "extra_body": {}, + "style_overrides": "", + }, + } + ], + "aliases": [{"alias": "short", "model": "org/model"}], + } + ] + doc["style_profiles"] = {"shared": "style"} + doc["routes"]["daily_summary"] = [{"provider_id": "p", "model": "org/model"}, "@default"] + return validate_document(doc) + + +def test_zero_false_empty_object_and_style_replace_provider_defaults(tmp_path): + config = compile_document(document(), tmp_path) + resolved = resolve_model_provider(config, "p", "short") + assert resolved.temperature == 0 + assert resolved.stream_enabled is False + assert resolved.extra_body == {} + assert resolved.style_overrides == "style" + assert config.providers["p"].extra_body == {"a": {"b": 1}} + assert config.daily_summary.model_cascade == ["p/org/model", "@default"] + # Call parameters take precedence without mutating the snapshot. + called = resolve_model_provider( + config, "p", "org/model", {"temperature": 1, "style_profile": ""} + ) + assert called.temperature == 1 and called.style_overrides == "" + assert resolved.temperature == 0 + + +def test_null_inherits_and_sources_remain_unexpanded(tmp_path, monkeypatch): + doc = document() + doc["providers"][0]["parameters"]["user_agent"] = "${QQ_AGENT}" + doc["providers"][0]["models"][0]["parameters"].update(temperature=None, extra_body=None) + monkeypatch.setenv("QQ_AGENT", "private-sentinel") + config = compile_document(doc, tmp_path) + assert resolve_model_provider(config, "p", "org/model").user_agent == "private-sentinel" + effective = effective_parameters(doc, "p", "org/model") + assert effective["temperature"]["value"] == 0.5 + assert effective["temperature"]["source"] == "provider" + assert effective["user_agent"]["value"] == "${QQ_AGENT}" + assert "private-sentinel" not in json.dumps(effective) + + +@pytest.mark.parametrize( + "key,value", + [ + ("temperature", -0.1), + ("stream_enabled", "false"), + ("max_output_tokens", 0), + ("retry_jitter", 2), + ("agent_replay_loop_tokens", 511), + ("timeout_seconds", 0), + ("base_url", "https://other.test"), + ("extra_body", []), + ("temperature", float("nan")), + ], +) +def test_model_writes_reject_invalid_fields_instead_of_clamping(key, value): + doc = document() + doc["providers"][0]["models"][0]["parameters"][key] = value + with pytest.raises(ConfigError): + validate_document(doc) + + +def test_epoch_relations_are_validated_across_inheritance(): + doc = document() + doc["providers"][0]["parameters"]["epoch_hot_target_tokens"] = 70000 + with pytest.raises(ConfigError, match="纪元"): + validate_document(doc) + + +@pytest.mark.parametrize("location", ["headers", "extra_body", "url", "mcp"]) +def test_literal_credentials_never_persist_or_appear_in_errors(location): + doc = document() + sentinel = "sk-private-sentinel-123" + p = doc["providers"][0] + if location == "headers": + p["headers"] = {"Authorization": "Bearer " + sentinel} + elif location == "extra_body": + p["parameters"]["extra_body"] = {"nested": {"api_key": sentinel}} + elif location == "url": + p["base_url"] = "https://example.test/?token=" + sentinel + else: + doc["settings"]["mcp"]["servers"] = [{"id": "secret", "env": {"MY_TOKEN": sentinel}}] + with pytest.raises(ConfigError) as caught: + validate_document(doc) + assert caught.value.code == "literal_secret" + assert sentinel not in str(caught.value) + json.dumps(caught.value.as_dict()) + + +def test_missing_disabled_route_reference_blocks_save(): + doc = document() + doc["routes"]["monthly_report"] = [{"provider_id": "gone", "model": "x"}] + with pytest.raises(ConfigError) as caught: + validate_document(doc) + assert caught.value.code == "dependency_conflict" + assert caught.value.field_path == "routes.monthly_report[0]" + + +def test_json_roundtrip_preserves_slash_models_and_order(): + doc = document() + doc["providers"][0]["models"].append({"model": "other/a"}) + normalized = validate_document(doc) + assert validate_document(json.loads(json.dumps(normalized))) == normalized + assert [m["model"] for m in normalized["providers"][0]["models"]] == ["org/model", "other/a"] + + +def test_empty_database_config_compiles_with_llm_disabled(tmp_path): + config = compile_document(empty_document(), tmp_path) + assert not config.is_available + assert config.load_error is None + + +def test_legacy_migration_keeps_references_prices_and_persona_shared_semantics( + tmp_path, monkeypatch +): + source = tmp_path / "config" / "llm.toml" + source.parent.mkdir() + source.write_text( + MIN_LLM_CONFIG_TOML + + """ +[pricing.models."openai-main/gpt-test"] +input_per_mtok = 0.0 +output_per_mtok = 0.0 +[pricing.models."response-only"] +input_per_mtok = 2.0 +output_per_mtok = 3.0 +""" + ) + # Frozen input must preserve references even when local environment contains expanded secrets. + original = source.read_text().replace( + "timeout_seconds = 30", + 'headers = { Authorization = "Bearer ${QQ_TEST_KEY}" }\ntimeout_seconds = 30', + ) + source.write_text(original) + directory = source.parent / "personas" + directory.mkdir() + (directory / "_shared.toml").write_text('shared_system_prompt = "shared"\n') + monkeypatch.setenv("QQ_TEST_KEY", "private-sentinel") + frozen = load_llm_config(source, preserve_env=True) + doc, report = migrate_legacy(source) + config = compile_document(doc, source.parent) + assert config.personas["default"].system_prompt == frozen.personas["default"].system_prompt + assert config.personas["default"].system_prompt == "你是测试人格。" + assert doc["providers"][0]["headers"]["Authorization"] == "Bearer ${QQ_TEST_KEY}" + assert "private-sentinel" not in json.dumps(doc) + json.dumps(report) + assert list(config.pricing) == list(frozen.pricing) + assert config.pricing["openai-main/gpt-test"].input_per_mtok == 0 + assert config.pricing["response-only"].cache_read_per_mtok is None + assert source.read_text() == original + assert migrate_legacy(source)[0] == doc + assert (Path(report["backup"]) / "llm.toml").read_text() == original + + +def test_migration_reports_limits_and_delivery_mapping(tmp_path): + source = tmp_path / "llm.toml" + text = MIN_LLM_CONFIG_TOML.replace("tool_max_rounds = 2", "tool_max_rounds = 64") + text = text.replace("search_max_calls_per_round = 3", "search_max_calls_per_round = 64") + text = text.replace( + "enabled = true\n default_provider", "enabled = true\n default_provider" + ) + text = text.replace("[runtime]\n", "[runtime]\nagent_delivery_enabled = true\n", 1) + source.write_text(text) + doc, report = migrate_legacy(source) + assert doc["settings"]["runtime"]["tool_max_rounds"] == 16 + assert doc["settings"]["auto_search"]["search_max_calls_per_round"] == 32 + assert doc["settings"]["runtime"]["agent_delivery_final_enabled"] is True + assert doc["settings"]["tools"]["enabled_mode"] == "append" + assert any(c["kind"] == "mapped_to_delivery_domains" for c in report["changes"]) + + +def test_independent_generation_wins_and_original_multimodal_can_be_extracted(tmp_path): + source = tmp_path / "llm.toml" + source.write_text(MIN_LLM_CONFIG_TOML + "\n[image_generation]\nenabled = false\n") + doc, report = migrate_legacy(source) + asset = tmp_path / "generation.toml" + assert tomllib.loads(asset.read_text())["generation"]["image"]["enabled"] is False + original_asset = "[image]\nenabled = false\n" + asset.write_text(original_asset) + _, report = migrate_legacy(source) + assert asset.read_text() == original_asset + assert any(w["code"] == "independent_generation_authoritative" for w in report["warnings"]) + + +def test_public_legacy_example_migrates_without_secrets_or_lost_prices(tmp_path): + example = Path("config/llm.toml.example") + source = tmp_path / "llm.toml" + source.write_bytes(example.read_bytes()) + raw = tomllib.loads(example.read_text()) + doc, _ = migrate_legacy(source) + assert len(doc["providers"]) == len(raw["providers"]) + assert len(doc["pricing"]) == len(raw.get("pricing", {}).get("models", {})) + assert validate_document(deepcopy(doc)) == doc + + +def test_qualified_and_slash_fallback_prices_keep_distinct_identities(tmp_path): + from quickquip.llm.pricing import match_pricing + + doc = document() + doc["pricing"] = [ + {"provider_id": None, "model": "p/org/model", "input_per_mtok": 7}, + {"provider_id": "p", "model": "org/model", "input_per_mtok": 2}, + ] + config = compile_document(doc, tmp_path) + assert match_pricing("p", "org/model", config.pricing_references).input_per_mtok == 2 + assert match_pricing("other", "p/org/model", config.pricing_references).input_per_mtok == 7 + + +def test_cli_and_web_share_missing_price_warning_and_preview_impacts(tmp_path): + from quickquip.llm.config_service import ConfigService + from quickquip.llm.config_cli import execute, parser + + service = ConfigService(tmp_path / "llm_config.db") + service.initialize() + file = tmp_path / "import.json" + file.write_text(json.dumps(document())) + args = parser().parse_args( + ["--db", str(service.path), "import", str(file), "--expected-revision", "1", "--preview"] + ) + preview = execute(args) + assert preview == service.preview_import(document(), 1) + assert preview["warnings"][0]["code"] == "price_missing" + assert preview["dependencies"] + assert preview["components"]["mcp_reconnect"] is False + assert service.read()["revision"] == 1 + + +def test_url_auth_reference_cannot_hide_literal_credential_parts(): + doc = document() + doc["providers"][0]["base_url"] = "https://example.test/?api_key=sentinel${TOKEN}" + with pytest.raises(ConfigError) as caught: + validate_document(doc) + assert caught.value.code == "literal_secret" + assert "sentinel" not in json.dumps(caught.value.as_dict()) + + +def test_legacy_qualified_prices_also_keep_response_name_fallback(tmp_path): + from quickquip.llm.pricing import match_pricing + + source = tmp_path / "llm.toml" + source.write_text( + MIN_LLM_CONFIG_TOML + + """ +[pricing.models."openai-main/gpt-test"] +input_per_mtok = 2 +output_per_mtok = 3 +""" + ) + old = load_llm_config(source, preserve_env=True) + doc, report = migrate_legacy(source) + compiled = compile_document(doc, tmp_path) + for provider, response in [("openai-main", "gpt-test"), ("other", "openai-main/gpt-test")]: + before = match_pricing(provider, response, old.pricing) + after = match_pricing(provider, response, compiled.pricing_references) + assert before == after + assert report["source_counts"]["pricing"] == 1 + assert len(doc["pricing"]) == 2 + + +def test_interrupted_independent_asset_backup_resumes_and_preserves_all_originals( + tmp_path, monkeypatch +): + import quickquip.llm.config_migration as migration + + source = tmp_path / "llm.toml" + source.write_text(MIN_LLM_CONFIG_TOML) + personas = tmp_path / "personas" + personas.mkdir() + (personas / "a.toml").write_text('id="a"\nsystem_prompt="original-a"\n') + (personas / "b.toml").write_text('id="b"\nsystem_prompt="original-b"\n') + real_write = migration.durable_write + + def interrupted(path, content): + if path.name == "b.toml": + raise OSError("simulated interruption") + real_write(path, content) + + monkeypatch.setattr(migration, "durable_write", interrupted) + with pytest.raises(OSError): + migrate_legacy(source) + assert sorted(p.name for p in personas.iterdir()) == ["a.toml", "b.toml"] + monkeypatch.setattr(migration, "durable_write", real_write) + _, report = migrate_legacy(source) + backup = Path(report["backup"]) / "personas" + assert (backup / "a.toml").read_bytes() == (personas / "a.toml").read_bytes() + assert (backup / "b.toml").read_bytes() == (personas / "b.toml").read_bytes() + assert (backup.parent / "assets-backup-complete.json").exists() + + +def test_interrupted_extraction_does_not_publish_partial_persona(tmp_path, monkeypatch): + import quickquip.llm.config_migration as migration + + source = tmp_path / "llm.toml" + source.write_text(MIN_LLM_CONFIG_TOML) + real_link = migration.os.link + + def interrupted(*args): + raise OSError("simulated publication failure") + + monkeypatch.setattr(migration.os, "link", interrupted) + with pytest.raises(OSError): + migrate_legacy(source) + assert list((tmp_path / "personas").glob("*.toml")) == [] + monkeypatch.setattr(migration.os, "link", real_link) + _, report = migrate_legacy(source) + assert report["assets"][0]["status"] == "ready" + assert ( + tomllib.loads(next((tmp_path / "personas").glob("*.toml")).read_text())["id"] == "default" + ) + + +def test_non_initializing_cli_commands_cannot_suppress_legacy_migration(tmp_path): + from quickquip.llm.config_cli import execute, parser + + legacy = tmp_path / "llm.toml" + legacy.write_text(MIN_LLM_CONFIG_TOML) + db = tmp_path / "llm_config.db" + args = parser().parse_args(["--db", str(db), "status"]) + with pytest.raises(ConfigError) as caught: + execute(args) + assert caught.value.code == "not_initialized" + assert not db.exists() + + +def test_migration_preserves_frozen_style_whitespace_semantics(tmp_path): + source = tmp_path / "llm.toml" + body = MIN_LLM_CONFIG_TOML.replace( + "timeout_seconds = 30", + 'style_profile = " family "\nstyle_overrides = " append "\ntimeout_seconds = 30', + ) + body += '\n[style_profiles]\nfamily = " shared "\n' + source.write_text(body) + old = load_llm_config(source, preserve_env=True) + doc, _ = migrate_legacy(source) + compiled = compile_document(doc, tmp_path) + assert compiled.providers["openai-main"].style_overrides == "shared\nappend" + assert ( + compiled.providers["openai-main"].style_overrides + == old.providers["openai-main"].style_overrides + ) + + +def test_existing_business_provider_style_survives_effective_model_resolution(): + from quickquip.llm.config import LLMConfig, ProviderConfig + + provider = ProviderConfig( + "p", "openai", "https://example.test", "KEY", "m", ["m"], style_overrides="existing-style" + ) + config = LLMConfig(providers={"p": provider}) + assert resolve_model_provider(config, "p", "m").style_overrides == "existing-style" + provider.model_parameters = {"m": {"style_overrides": ""}} + assert resolve_model_provider(config, "p", "m").style_overrides == "" diff --git a/tests/unit/llm/test_config_env_expand.py b/tests/unit/llm/test_config_env_expand.py index d2edbfb6..ad512f5c 100644 --- a/tests/unit/llm/test_config_env_expand.py +++ b/tests/unit/llm/test_config_env_expand.py @@ -4,7 +4,7 @@ import textwrap from pathlib import Path -import quickquip.llm.service as llm_runtime_module +import quickquip.llm.config_legacy as llm_runtime_module CONFIG_WITH_ENV_VARS = textwrap.dedent( diff --git a/tests/unit/llm/test_config_fields_metadata.py b/tests/unit/llm/test_config_fields_metadata.py new file mode 100644 index 00000000..2992bf4c --- /dev/null +++ b/tests/unit/llm/test_config_fields_metadata.py @@ -0,0 +1,82 @@ +"""契约测试:metadata 的类型标注必须与实际写入校验一致。 + +防止 ``field_metadata`` 把 ``list[str]`` / ``dict[str, Any]`` 等泛型误判为 string +后,前端按错误类型渲染控件、写回时被 ``check_value`` 拒绝。 +""" + +from __future__ import annotations + +from typing import Any, get_type_hints + +import pytest + +from quickquip.llm.config_fields import PARAMETER_TYPES, SETTINGS, check_value, metadata + +_meta = metadata() + +_SAMPLE_BY_TYPE = { + "array": ["tool_search"], + "object": {"thinking": {"type": "enabled"}}, + "string": "sample", + "integer": 1, + "number": 1.5, + "boolean": True, +} + + +def _check_settings_field(section: str, key: str, sample: Any) -> None: + annotation = get_type_hints(SETTINGS[section])[key] + check_value(sample, annotation, f"settings.{section}.{key}", f"{section}.{key}") + + +def test_metadata_generic_types_are_not_strings() -> None: + assert _meta["settings"]["tools"]["enabled"]["type"] == "array" + assert _meta["settings"]["tools"]["always_loaded"]["type"] == "array" + assert _meta["parameters"]["extra_body"]["type"] == "object" + + +@pytest.mark.parametrize( + ("section", "key"), + [ + ("tools", "enabled"), + ("tools", "always_loaded"), + ("triggers", "default_prefix"), + ("runtime", "history_limit"), + ("runtime", "memory_enabled"), + ], +) +def test_settings_metadata_type_matches_write_validation(section: str, key: str) -> None: + field = _meta["settings"][section][key] + sample = _SAMPLE_BY_TYPE[field["type"]] + _check_settings_field(section, key, sample) + + +def test_parameters_metadata_type_matches_write_validation() -> None: + for name, field in _meta["parameters"].items(): + if field["type"] not in _SAMPLE_BY_TYPE: + continue + annotation = PARAMETER_TYPES[name] + sample = field["enum"][0] if field.get("enum") else _SAMPLE_BY_TYPE[field["type"]] + if isinstance(sample, (int, float)) and not isinstance(sample, bool): + if isinstance(field.get("minimum"), (int, float)) and sample < field["minimum"]: + sample = field["minimum"] + if isinstance(field.get("maximum"), (int, float)) and sample > field["maximum"]: + sample = field["maximum"] + check_value( + sample, annotation, + f"providers[0].parameters.{name}", name, inherit=True, + ) + + +def test_nullable_union_unwraps_to_member_type() -> None: + # Optional[X] / X | None 必须解包为成员类型 + provider = _meta["settings"]["runtime"]["default_provider"] + assert provider["nullable"] is True + assert provider["type"] == "string" + + +def test_parameters_scalar_annotations_unchanged() -> None: + assert _meta["parameters"]["temperature"]["type"] == "number" + assert _meta["parameters"]["max_output_tokens"]["type"] == "integer" + assert _meta["parameters"]["stream_enabled"]["type"] == "boolean" + assert _meta["parameters"]["reasoning_effort"]["enum"] diff --git a/tests/unit/llm/test_config_runtime.py b/tests/unit/llm/test_config_runtime.py new file mode 100644 index 00000000..42f34e84 --- /dev/null +++ b/tests/unit/llm/test_config_runtime.py @@ -0,0 +1,403 @@ +"""Published configuration, fenced recovery and task resource ownership.""" +from __future__ import annotations + +import asyncio +from dataclasses import replace +from types import SimpleNamespace +import pytest + +from quickquip.llm.config import PricingRates +from quickquip.llm.config_document import compile_document +from quickquip.llm.config_runtime import ConfigRuntimeWorker, initialize_for_bot +from quickquip.llm.provider import LLMRequest, LLMResponse +from quickquip.llm.service import LLMService +from quickquip.llm.usage import _record_usage + + +def enqueue(runtime, change, request_id="apply-test"): + service = runtime.config_service + draft = service.read() + change(draft["document"]) + saved = service.replace(draft["document"], draft["revision"]) + return service.apply(saved["revision"], request_id) + + +async def test_apply_pins_target_and_old_task_prices(llm_service, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + runtime = llm_service + runtime.config.pricing = {"gpt-test": PricingRates(input_per_mtok=2, output_per_mtok=3)} + old_revision = runtime.config.revision + entered = asyncio.Event() + release = asyncio.Event() + observed = [] + rows = [] + monkeypatch.setattr("quickquip.llm.usage_store.usage_store.record", rows.append) + + async def long_task(): + async with runtime.configuration_lease(): + entered.set() + await release.wait() + observed.append((runtime.config.revision, runtime.config.runtime.max_prompt_chars)) + await _record_usage( + SimpleNamespace(config=runtime.config.providers["openai-main"]), + LLMRequest(model="gpt-test", system_prompt="", messages=[], + temperature=0, max_output_tokens=1), + LLMResponse(text="done", model="gpt-test", input_tokens=1_000_000, + output_tokens=1_000_000), + started=0, stream_used=False, state="ok", + ) + task = asyncio.create_task(long_task()) + await entered.wait() + operation = enqueue( + runtime, lambda doc: doc["settings"]["runtime"].update(max_prompt_chars=2000) + ) + draft = runtime.config_service.read() + draft["document"]["settings"]["runtime"]["max_prompt_chars"] = 3000 + runtime.config_service.replace(draft["document"], draft["revision"]) + await ConfigRuntimeWorker(runtime.config_service, runtime).tick() + assert runtime.config.runtime.max_prompt_chars == 2000 + assert runtime.config.revision == operation["target_revision"] + assert runtime.config_service.tasks.get(operation["id"])["status"] == "succeeded" + release.set() + await task + assert observed == [(old_revision, 1000)] + assert rows[0]["cost_usd"] == 5 + status = runtime.config_service.status() + assert status["bot"]["loaded_revision"] == operation["target_revision"] + assert status["draft_revision"] > status["published_revision"] + await runtime.shutdown() + + +async def test_mcp_change_releases_old_generation_after_last_lease(llm_service, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + runtime = llm_service + old_manager = runtime.mcp_manager + closed = [] + async def close(): + closed.append(old_manager) + monkeypatch.setattr(old_manager, "aclose", close) + entered, release = asyncio.Event(), asyncio.Event() + retained = [] + async def old_task(): + async with runtime.configuration_lease(): + entered.set() + await release.wait() + retained.append(runtime.mcp_manager) + task = asyncio.create_task(old_task()) + await entered.wait() + operation = enqueue(runtime, lambda doc: doc["settings"]["mcp"].update(enabled=True)) + await ConfigRuntimeWorker(runtime.config_service, runtime).tick() + assert runtime.config_service.tasks.get(operation["id"])["status"] == "succeeded" + assert runtime.mcp_manager is not old_manager + assert closed == [] + release.set() + await task + await asyncio.gather(*runtime._resource_cleanup_tasks) + assert retained == [old_manager] + assert closed == [old_manager] + manager = runtime.mcp_manager + operation = enqueue( + runtime, lambda doc: doc["settings"]["runtime"].update(max_prompt_chars=2100), + request_id="unrelated-change", + ) + await ConfigRuntimeWorker(runtime.config_service, runtime).tick() + assert runtime.mcp_manager is manager + await runtime.shutdown() + + +async def test_failed_schedules_keep_published_and_loaded_config(llm_service, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + runtime = llm_service + original = runtime.config.revision + operation = enqueue( + runtime, lambda doc: doc["settings"]["runtime"].update(max_prompt_chars=2100) + ) + def fail_schedule(old, new): + raise RuntimeError("expanded-secret-sentinel") + await ConfigRuntimeWorker(runtime.config_service, runtime, schedules=fail_schedule).tick() + result = runtime.config_service.tasks.get(operation["id"]) + assert result["status"] == "failed" + assert result["error"]["code"] == "configuration_failed" + assert "expanded-secret-sentinel" not in str(result) + assert runtime.config.revision == original + assert runtime.config_service.read("published")["revision"] == original + await runtime.shutdown() + + +async def test_boot_loads_published_and_recovers_publication(llm_service, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + runtime = llm_service + operation = enqueue( + runtime, lambda doc: doc["settings"]["runtime"].update(max_prompt_chars=2100) + ) + claimed = runtime.config_service.tasks.claim("crashed-bot") + runtime.config_service.publish(claimed["id"], claimed["claim_token"]) + with runtime.config_service.store.connect(write=True) as conn: + conn.execute("UPDATE config_operations SET lease_until=0 WHERE id=?", (operation["id"],)) + draft = runtime.config_service.read() + draft["document"]["settings"]["runtime"]["max_prompt_chars"] = 3100 + runtime.config_service.replace(draft["document"], draft["revision"]) + restarted = LLMService( + config_path=runtime.config_path, db_path=runtime.store.path, + identity_path=runtime.identity_path, vocab_path=runtime.vocab_path, + ) + assert restarted.config.runtime.max_prompt_chars == 2100 + await ConfigRuntimeWorker(runtime.config_service, restarted).tick() + assert runtime.config_service.tasks.get(operation["id"])["status"] == "succeeded" + assert runtime.config_service.status()["bot"]["loaded_revision"] == operation["target_revision"] + await restarted.shutdown() + await runtime.shutdown() + + +async def test_async_billing_keeps_snapshot_after_parent_lease_exits(llm_service, monkeypatch): + from quickquip.llm.usage import _configured_pricing + gate = asyncio.Event() + async def billing(): + await gate.wait() + return _configured_pricing()["gpt-test"].input_per_mtok + llm_service.config.pricing = {"gpt-test": PricingRates(input_per_mtok=2, output_per_mtok=0)} + async with llm_service.configuration_lease(): + task = asyncio.create_task(billing()) + llm_service.config = replace(llm_service.config, pricing={ + "gpt-test": PricingRates(input_per_mtok=20, output_per_mtok=0) + }) + gate.set() + assert await task == 2 + + +async def test_model_override_is_used_in_actual_quick_judge_request(llm_service, monkeypatch): + captured = [] + document = llm_service.config_service.read()["document"] + document["providers"][0]["models"][0]["parameters"].update( + timeout_seconds=91, extra_body={"sentinel": True}, reasoning_effort="high" + ) + llm_service.config = compile_document( + document, config_dir=llm_service.config_path.parent.parent / "config" + ) + class Client: + async def complete(self, request): + return LLMResponse(text='{"trigger":true}', model=request.model) + def build(provider): + captured.append(provider) + return Client() + monkeypatch.setattr("quickquip.llm.service.build_provider_client", build) + await llm_service.quick_judge("question") + assert captured[0].timeout_seconds == 91 + assert captured[0].extra_body == {"sentinel": True} + assert captured[0].reasoning_effort == "high" + + +async def test_skill_directory_change_keeps_old_task_bindings(llm_service, tmp_path, monkeypatch): + from quickquip.llm.tools import ToolExecutionContext + from tests.unit.llm.skills.conftest import write_skill + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + old_dir, new_dir = tmp_path / "old-skills", tmp_path / "new-skills" + write_skill(old_dir, "demo", body="old instructions", files={"references/a.md": "OLD-ASSET"}) + write_skill(new_dir, "demo", body="new instructions", files={"references/a.md": "NEW-ASSET"}) + write_skill(new_dir, "new-only", body="new extra skill") + runtime = llm_service + runtime.config = replace(runtime.config, skills=replace( + runtime.config.skills, catalog_dir=str(old_dir) + )) + context = ToolExecutionContext( + group_id=101, user_id=202, sender_name="test", provider_id="openai-main", + model="gpt-test", chat_type="group", + ) + entered, release = asyncio.Event(), asyncio.Event() + results = [] + async def old_task(): + async with runtime.configuration_lease(): + runtime.prepare_skill_catalog_for_turn(provider=None, model="gpt-test") + runtime._tool_activate_skill({"name": "demo"}, context) + entered.set() + await release.wait() + results.append(runtime._tool_read_skill_resource( + {"skill": "demo", "path": "references/a.md"}, context + )) + specs = runtime.tool_registry.get_specs(["activate_skill"]) + results.append(specs[0].input_schema["properties"]["name"]["enum"]) + task = asyncio.create_task(old_task()) + await entered.wait() + manager = runtime.mcp_manager + enqueue(runtime, lambda doc: doc["settings"]["skills"].update(catalog_dir=str(new_dir))) + await ConfigRuntimeWorker(runtime.config_service, runtime).tick() + async with runtime.configuration_lease(): + runtime.prepare_skill_catalog_for_turn(provider=None, model="gpt-test") + runtime._tool_activate_skill({"name": "demo"}, context) + new_result = runtime._tool_read_skill_resource( + {"skill": "demo", "path": "references/a.md"}, context + ) + assert "NEW-ASSET" in new_result + release.set() + await task + assert "OLD-ASSET" in results[0] + assert "NEW-ASSET" not in results[0] + assert results[1] == ["demo"] + assert runtime.mcp_manager is manager + await runtime.shutdown() + + +def test_read_only_bot_waits_for_web_asset_initialization(tmp_path, monkeypatch): + from quickquip.llm.config_service import ConfigService + service = ConfigService(tmp_path / "llm_config.db") + def readonly(_legacy): + raise PermissionError("readonly assets") + monkeypatch.setattr(service, "initialize", readonly) + def initialize_elsewhere(_interval): + ConfigService(service.path).initialize() + snapshot = initialize_for_bot(service, sleep=initialize_elsewhere) + assert snapshot["revision"] == ConfigService(service.path).read("published")["revision"] + + +def test_read_only_bot_timeout_reports_explicit_migration_requirement(tmp_path, monkeypatch): + from quickquip.llm.config_errors import ConfigError + from quickquip.llm.config_service import ConfigService + service = ConfigService(tmp_path / "llm_config.db") + def readonly(_legacy): + raise PermissionError("readonly assets") + monkeypatch.setattr(service, "initialize", readonly) + with pytest.raises(ConfigError) as error: + initialize_for_bot(service, wait_seconds=0) + assert error.value.code == "migration_required" + + +async def test_shutdown_drains_task_leases_before_closing_mcp(llm_service, monkeypatch): + runtime = llm_service + entered, release = asyncio.Event(), asyncio.Event() + closed = [] + async def close(): + closed.append(True) + monkeypatch.setattr(runtime.mcp_manager, "aclose", close) + async def turn(): + async with runtime.configuration_lease(): + entered.set() + await release.wait() + assert closed == [] + task = asyncio.create_task(turn()) + await entered.wait() + shutdown = asyncio.create_task(runtime.shutdown()) + await asyncio.sleep(0) + assert not shutdown.done() + assert closed == [] + release.set() + await task + await shutdown + assert closed == [True] + + +async def test_manual_runtime_reload_preserves_old_mcp_leases(llm_service, monkeypatch): + from quickquip.llm.mcp import MCPClientManager + runtime = llm_service + old = runtime.mcp_manager + synced, closed = [], [] + async def sync(manager, _config, **kwargs): + assert manager is not old + synced.append(manager) + async def close(): + closed.append(old) + monkeypatch.setattr(MCPClientManager, "sync", sync) + monkeypatch.setattr(old, "aclose", close) + entered, release = asyncio.Event(), asyncio.Event() + async def turn(): + async with runtime.configuration_lease(): + entered.set() + await release.wait() + assert runtime.mcp_manager is old + assert closed == [] + task = asyncio.create_task(turn()) + await entered.wait() + await runtime.reload_runtime() + assert runtime.mcp_manager is not old + assert synced == [runtime.mcp_manager] + assert closed == [] + release.set() + await task + await asyncio.gather(*runtime._resource_cleanup_tasks) + assert closed == [old] + await runtime.shutdown() + + +@pytest.mark.parametrize("selection", ["default", "report", "judge", "image", "nonvision"]) +async def test_apply_rejects_unavailable_active_selection(llm_service, monkeypatch, selection): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + runtime = llm_service + original = runtime.config.revision + def change(document): + if selection == "default": + document["providers"][0]["models"][0]["enabled"] = False + return + document["settings"]["runtime"]["enabled"] = False + if selection == "report": + document["settings"]["daily_summary"]["enabled"] = True + document["routes"]["daily_summary"] = [ + {"provider_id": "openai-main", "model": "gpt-alt"} + ] + elif selection == "judge": + document["settings"]["quick_judge"].update(provider_id="openai-main", model="gpt-alt") + else: + document["settings"]["image_preprocessing"].update( + enabled=True, provider_id="openai-main", model="gpt-alt" + ) + if selection == "nonvision": + document["providers"][0]["models"][1]["vision"] = False + else: + document["providers"][0]["models"][1]["enabled"] = False + operation = enqueue(runtime, change) + await ConfigRuntimeWorker(runtime.config_service, runtime).tick() + result = runtime.config_service.tasks.get(operation["id"]) + assert result["status"] == "failed" + assert result["error"]["code"] == ( + "vision_required" if selection == "nonvision" else "disabled_model" + ) + assert result["error"]["field_path"] + assert runtime.config.revision == original + assert runtime.config_service.read("published")["revision"] == original + await runtime.shutdown() + + +@pytest.mark.parametrize("feature", ["weekly_report", "quick_judge", "image_preprocessing"]) +async def test_independent_feature_requires_selected_credentials(llm_service, monkeypatch, feature): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + def change(document): + document["settings"]["runtime"]["enabled"] = False + if feature == "weekly_report": + document["settings"][feature]["enabled"] = True + else: + document["settings"][feature].update(provider_id="openai-main", model="gpt-test") + if feature == "image_preprocessing": + document["settings"][feature]["enabled"] = True + operation = enqueue(llm_service, change) + await ConfigRuntimeWorker(llm_service.config_service, llm_service).tick() + result = llm_service.config_service.tasks.get(operation["id"]) + assert result["status"] == "failed" + assert result["error"]["code"] == "missing_environment" + assert result["error"]["details"]["variables"] == ["OPENAI_API_KEY"] + await llm_service.shutdown() + + +async def test_selected_auth_header_requires_environment(llm_service, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + monkeypatch.delenv("TEST_SELECTED_HEADER_KEY", raising=False) + operation = enqueue(llm_service, lambda document: document["providers"][0]["headers"].update( + Authorization="Bearer ${TEST_SELECTED_HEADER_KEY}" + )) + await ConfigRuntimeWorker(llm_service.config_service, llm_service).tick() + result = llm_service.config_service.tasks.get(operation["id"]) + assert result["status"] == "failed" + assert result["error"]["code"] == "missing_environment" + assert result["error"]["details"]["variables"] == ["TEST_SELECTED_HEADER_KEY"] + await llm_service.shutdown() + + +async def test_unused_provider_credentials_do_not_block_application(llm_service, monkeypatch): + from copy import deepcopy + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + monkeypatch.delenv("UNUSED_PROVIDER_KEY", raising=False) + def change(document): + unused = deepcopy(document["providers"][0]) + unused.update(id="unused", api_key_env="UNUSED_PROVIDER_KEY") + document["providers"].append(unused) + operation = enqueue(llm_service, change) + await ConfigRuntimeWorker(llm_service.config_service, llm_service).tick() + assert llm_service.config_service.tasks.get(operation["id"])["status"] == "succeeded" + await llm_service.shutdown() diff --git a/tests/unit/llm/test_config_service.py b/tests/unit/llm/test_config_service.py new file mode 100644 index 00000000..9add9359 --- /dev/null +++ b/tests/unit/llm/test_config_service.py @@ -0,0 +1,373 @@ +"""Configuration transaction and cross-process control contract regressions.""" + +from __future__ import annotations + +import multiprocessing +import sqlite3 +import time + +import pytest + +from quickquip.llm.config_document import ConfigError, empty_document +from quickquip.llm.config_service import ConfigService + + +def initialize_worker(path, queue): + try: + queue.put(ConfigService(path).initialize()["revision"]) + except Exception as exc: + queue.put(type(exc).__name__) + + +@pytest.fixture +def service(tmp_path): + result = ConfigService(tmp_path / "llm_config.db") + result.initialize() + return result + + +def change(service, text): + draft = service.read() + draft["document"]["settings"]["triggers"]["default_prefix"] = text + return service.replace(draft["document"], draft["revision"]) + + +def test_two_processes_initialize_one_database(tmp_path): + context = multiprocessing.get_context("spawn") + queue = context.Queue() + workers = [ + context.Process(target=initialize_worker, args=(str(tmp_path / "config.db"), queue)) + for _ in range(2) + ] + for worker in workers: + worker.start() + for worker in workers: + worker.join(15) + assert worker.exitcode == 0 + assert sorted(queue.get(timeout=1) for _ in workers) == [1, 1] + assert len(ConfigService(tmp_path / "config.db").history()["snapshots"]) == 1 + + +def test_schema_rejection_and_no_legacy_fallback(service, tmp_path): + with sqlite3.connect(service.path) as conn: + conn.execute("UPDATE config_meta SET schema_version=999") + with pytest.raises(ConfigError, match="Unsupported") as exc: + service.initialize(tmp_path / "legacy.toml") + assert exc.value.code == "unsupported_schema" + + +def test_existing_empty_database_is_rejected_not_reinitialized(tmp_path): + path = tmp_path / "broken.db" + path.touch() + with pytest.raises(ConfigError) as exc: + ConfigService(path).initialize() + assert exc.value.code == "unsupported_schema" + + +def test_draft_revision_cas_and_published_is_immutable(service): + original = service.read() + updated = change(service, "changed") + assert updated["revision"] == 2 + assert service.read("published") == original + with pytest.raises(ConfigError) as exc: + service.replace(original["document"], 1) + assert exc.value.code == "revision_conflict" + assert ConfigService(service.path).read("published") == original + assert service.replace(updated["document"], 2)["revision"] == 2 + + +def test_apply_target_frozen_while_editing_and_actual_status(service): + change(service, "target") + submitted = service.apply(2, "apply-one") + change(service, "later draft") + assert service.tasks.get(submitted["id"])["target_revision"] == 2 + assert service.read(2)["document"]["settings"]["triggers"]["default_prefix"] == "target" + assert service.status()["published_revision"] == 1 + boot = service.register_bot_boot("boot1", 1) + claim = service.tasks.claim(boot, boot_id=boot) + service.publish(claim["id"], claim["claim_token"]) + assert service.tasks.get(claim["id"])["status"] == "running" + service.report_bot(boot, 2, {"mcp": "preparing"}, "1.16.2") + service.tasks.finish(claim["id"], claim["claim_token"]) + status = service.status() + assert (status["draft_revision"], status["published_revision"]) == (3, 2) + assert status["bot"]["loaded_revision"] == 2 + assert not status["bot"]["stale"] + + +def test_apply_idempotency_conflict_and_cancel(service): + first = service.apply(1, "same-request") + assert service.apply(1, "same-request")["id"] == first["id"] + with pytest.raises(ConfigError) as exc: + service.apply(1, "other-request") + assert exc.value.code == "operation_conflict" + service.tasks.cancel(first["id"]) + assert service.tasks.get(first["id"])["status"] == "cancelled" + second = service.apply(1, "next-request") + claim = service.tasks.claim("bot") + with pytest.raises(ConfigError): + service.tasks.cancel(second["id"]) + service.tasks.finish(claim["id"], claim["claim_token"], status="failed", error={"code": "test"}) + + +def test_expired_lease_is_reclaimed_and_old_owner_fenced(service): + service.apply(1, "lease") + first = service.tasks.claim("old") + with service.store.connect(write=True) as conn: + conn.execute("UPDATE config_operations SET lease_until=?", (time.time() - 1,)) + second = service.tasks.claim("new") + assert second["id"] == first["id"] + assert second["claim_token"] != first["claim_token"] + for action in (service.tasks.heartbeat, service.tasks.finish, service.publish): + with pytest.raises(ConfigError) as exc: + action(first["id"], first["claim_token"]) + assert exc.value.code == "stale_claim" + service.tasks.finish(second["id"], second["claim_token"]) + + +def test_publish_callback_failure_preserves_pointer(service): + change(service, "new") + service.apply(2, "callback") + claim = service.tasks.claim("bot") + + def reject(): + raise ConfigError("dependency_conflict", "group refers to old model") + + with pytest.raises(ConfigError): + service.publish(claim["id"], claim["claim_token"], before_publish=reject) + assert service.status()["published_revision"] == 1 + + +def test_backup_is_consistent_and_restore_clears_leases(service, tmp_path): + change(service, "backup-value") + operation = service.apply(2, "before-backup") + service.tasks.claim("bot") + boot = service.register_bot_boot("bot", 1) + service.report_bot(boot, 1) + backup = service.backup(tmp_path / "copy.db") + assert ConfigService(backup).read()["revision"] == 2 + change(service, "after-backup") + with pytest.raises(ConfigError): + service.restore_database(backup) + result = service.restore_database(backup, stopped=True) + assert result["bot"] is None + assert service.read()["document"]["settings"]["triggers"]["default_prefix"] == "backup-value" + assert service.tasks.get(operation["id"])["status"] == "failed" + + +def test_structured_parameters_and_report_tables(service): + with service.store.connect() as conn: + fields = {row["name"] for row in conn.execute("PRAGMA table_info(model_parameters)")} + assert {"temperature", "extra_body", "reasoning_effort", "stream_enabled"} <= fields + report = conn.execute("SELECT length_hint FROM weekly_report_settings").fetchone() + assert report[0] == empty_document()["settings"]["weekly_report"]["length_hint"] + + +def test_snapshot_sql_update_rejected(service): + with service.store.connect(write=True) as conn: + with pytest.raises(sqlite3.IntegrityError): + conn.execute("UPDATE config_snapshots SET document='{}' WHERE revision=1") + + +def test_interrupted_initialization_does_not_publish_partial_database(tmp_path, monkeypatch): + import quickquip.llm.config_store as store_module + + path = tmp_path / "config.db" + + def interrupt(conn, document): + raise RuntimeError("simulated interruption") + + monkeypatch.setattr(store_module, "project_document", interrupt) + with pytest.raises(RuntimeError): + ConfigService(path).initialize() + assert not path.exists() + assert not list(tmp_path.glob("*.initializing*")) + + +def configured_provider(service): + draft = service.read() + draft["document"]["providers"] = [ + { + "id": "p", + "protocol": "openai", + "base_url": "https://example.com/v1", + "api_key_env": "MODEL_KEY", + "default_model": "m", + "models": [{"model": "m"}, {"model": "next"}], + "aliases": [{"alias": "short", "model": "m"}], + } + ] + service.replace(draft["document"], draft["revision"]) + return service.read() + + +def test_interrupted_probe_is_failed_without_replay(service): + configured_provider(service) + submitted = service.probe("draft", "p", "m", "once") + claim = service.tasks.claim("old") + assert claim["id"] == submitted["id"] + with service.store.connect(write=True) as conn: + conn.execute("UPDATE config_operations SET lease_until=?", (time.time() - 1,)) + assert service.tasks.claim("new") is None + result = service.tasks.get(submitted["id"]) + assert result["status"] == "failed" + assert result["error"]["code"] == "probe_result_unconfirmed" + assert service.status()["published_revision"] == 1 + + +def test_relational_projection_failure_rolls_back_all_draft_changes(service, monkeypatch): + import quickquip.llm.config_service as service_module + + configured_provider(service) + before = service.read() + + def fail(conn, document): + conn.execute("DELETE FROM model_aliases") + raise RuntimeError("simulated persistence failure") + + monkeypatch.setattr(service_module, "project_document", fail) + with pytest.raises(RuntimeError): + change(service, "cannot commit") + assert service.read() == before + with service.store.connect() as conn: + assert conn.execute("SELECT alias FROM model_aliases").fetchone()[0] == "short" + + +def test_group_overrides_validate_published_only_and_block_deletion(service, tmp_path): + from quickquip.llm.store import LLMStore + + store = LLMStore(tmp_path / "history.db") + configured_provider(service) + with pytest.raises(ConfigError) as exc: + service.update_group_override(store, "10001", provider_id="p", model="m") + assert exc.value.code == "dependency_conflict" + submitted = service.apply(2, "publish") + claim = service.tasks.claim("bot") + assert claim["id"] == submitted["id"] + service.publish(claim["id"], claim["claim_token"]) + service.tasks.finish(claim["id"], claim["claim_token"]) + service.update_group_override(store, "10001", provider_id="p", model="short") + assert store.get_group_settings("10001").model == "short" + draft = service.read()["document"] + draft["providers"][0]["models"][0]["enabled"] = False + with pytest.raises(ConfigError): + service.validate_group_overrides(draft, store) + assert store.get_group_settings("10001").provider_id == "p" + + +def test_same_request_id_cannot_retarget_apply(service): + service.apply(1, "idempotent") + change(service, "new draft") + with pytest.raises(ConfigError) as exc: + service.apply(2, "idempotent") + assert exc.value.code == "operation_conflict" + + +def test_missing_restore_source_and_same_file_restore_are_rejected(service, tmp_path): + missing = tmp_path / "missing.db" + for path in (missing, service.path): + with pytest.raises(ConfigError): + service.restore_database(path, stopped=True) + assert not missing.exists() + + +@pytest.mark.parametrize( + "mutation, object_name", + [ + ("ALTER TABLE runtime_settings DROP COLUMN enabled", "runtime_settings"), + ("ALTER TABLE config_operations DROP COLUMN claim_token", "config_operations"), + ("DROP TRIGGER snapshots_immutable_update", "snapshots_immutable_update"), + ( + "DROP TRIGGER snapshots_immutable_delete; " + "CREATE TRIGGER snapshots_immutable_delete BEFORE DELETE ON config_snapshots " + "BEGIN SELECT 1; END", + "snapshots_immutable_delete", + ), + ( + "DROP INDEX one_pending_apply; CREATE INDEX one_pending_apply " + "ON config_operations(kind) " + "WHERE kind='apply' AND status IN ('queued','running')", + "one_pending_apply", + ), + ( + "DROP INDEX one_pending_apply; CREATE UNIQUE INDEX one_pending_apply " + "ON config_operations(kind) WHERE kind='apply' AND status='queued'", + "one_pending_apply", + ), + ], +) +def test_incomplete_schema_rejected_before_initialization_or_restore( + service, + tmp_path, + mutation, + object_name, +): + source = service.backup(tmp_path / "tampered.db") + with sqlite3.connect(source) as conn: + conn.executescript(mutation) + before = service.read() + candidate = ConfigService(source) + for operation in ( + lambda: candidate.initialize(), + lambda: candidate.apply(1, "must-not-enqueue"), + lambda: service.restore_database(source, stopped=True), + ): + with pytest.raises(ConfigError) as error: + operation() + assert error.value.code == "unsupported_schema" + assert object_name in {*error.value.details["changed"], *error.value.details["missing"]} + assert service.read() == before + assert not list(tmp_path.glob("llm_config.db.*.backup")) + + +def test_initialized_database_and_backup_directory_entries_are_synced(tmp_path, monkeypatch): + from quickquip.llm import config_durability + + synced = [] + + def observe(directory): + directory = directory.resolve() + database = directory / "llm_config.db" + snapshot = directory / "snapshot.db" + # Capture publication state, including durability of a newly created backup directory. + synced.append( + (directory, database.is_file(), snapshot.is_file(), (directory / "backups").is_dir()) + ) + for file in (database, snapshot): + if file.is_file(): + ConfigService(file).store.verify() + + monkeypatch.setattr(config_durability, "sync_directory", observe) + service = ConfigService(tmp_path / "llm_config.db") + service.initialize() + destination = service.backup(tmp_path / "backups" / "snapshot.db") + assert (tmp_path.resolve(), True, False, False) in synced + assert (tmp_path.resolve(), True, False, True) in synced + assert ((tmp_path / "backups").resolve(), False, True, False) in synced + # Reopen after the backup target connection was closed and verify every schema object. + ConfigService(destination).store.verify() + + +def test_model_only_group_override_uses_automatic_enabled_default(): + from quickquip.llm.config_document import validate_document + + config = empty_document() + config["providers"] = [ + { + "id": "p", + "protocol": "openai", + "base_url": "https://example.test", + "api_key_env": "KEY", + "default_model": "org/model", + "models": [{"model": "org/model"}], + "aliases": [{"alias": "short", "model": "org/model"}], + } + ] + config = validate_document(config) + assert config["settings"]["runtime"]["default_provider"] is None + ConfigService.validate_group_reference(config, None, "org/model") + ConfigService.validate_group_reference(config, None, "short") + config["providers"][0]["models"][0]["enabled"] = False + with pytest.raises(ConfigError) as caught: + ConfigService.validate_group_reference(config, None, "org/model") + assert caught.value.code == "dependency_conflict" diff --git a/tests/unit/llm/test_health.py b/tests/unit/llm/test_health.py index 8f6cc766..3359ab85 100644 --- a/tests/unit/llm/test_health.py +++ b/tests/unit/llm/test_health.py @@ -43,6 +43,19 @@ async def test_health_verbose_skips_probe_when_key_missing(llm_service, monkeypa assert "provider_reachable" not in items["provider"].details +async def test_health_warns_and_skips_paid_probe_for_disabled_model(llm_service, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + llm_service.config.providers["openai-main"].model_enabled["gpt-test"] = False + async def unexpected_probe(*args): + raise AssertionError("Disabled model must not send a paid probe") + monkeypatch.setattr("quickquip.llm.health._probe_provider", unexpected_probe) + report = await llm_service.build_health_report(10001, probe_provider=True) + provider = next(item for item in report.items if item.name == "provider") + assert provider.status == "warn" + assert provider.details["model_enabled"] is False + assert "provider_reachable" not in provider.details + + async def test_health_tool_returns_formatted_report(llm_service, monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "test-key") context = ToolExecutionContext( @@ -67,79 +80,38 @@ async def test_health_tool_returns_formatted_report(llm_service, monkeypatch): async def test_health_reports_bound_image_preprocessor(llm_service, monkeypatch): - from tests.fixtures.configs import MIN_LLM_CONFIG_TOML - - class _StubClient: - pass - - monkeypatch.setattr( - "quickquip.llm.service.build_provider_client", lambda provider: _StubClient() - ) - llm_service.config_path.write_text( - MIN_LLM_CONFIG_TOML - + """ - -[image_preprocessing] -enabled = true -provider_id = "openai-main" -model = "gpt-test" -""", - encoding="utf-8", + from quickquip.llm.config import ImagePreprocessingConfig + llm_service.config.image_preprocessing = ImagePreprocessingConfig( + enabled=True, provider_id="openai-main", model="gpt-test" ) - llm_service.reload_config() - + llm_service.rebuild_image_preprocessor() report = await llm_service.build_health_report(10001) items = {item.name: item for item in report.items} - assert items["image_preprocessing"].status == "ok" assert items["image_preprocessing"].details["runtime_bound"] is True async def test_health_warns_when_image_preprocessor_provider_missing(llm_service): - from tests.fixtures.configs import MIN_LLM_CONFIG_TOML - - llm_service.config_path.write_text( - MIN_LLM_CONFIG_TOML - + """ - -[image_preprocessing] -enabled = true -provider_id = "missing-provider" -""", - encoding="utf-8", + from quickquip.llm.config import ImagePreprocessingConfig + llm_service.config.image_preprocessing = ImagePreprocessingConfig( + enabled=True, provider_id="missing-provider" ) - llm_service.reload_config() - + llm_service.rebuild_image_preprocessor() report = await llm_service.build_health_report(10001) items = {item.name: item for item in report.items} - assert items["image_preprocessing"].status == "warn" assert items["image_preprocessing"].details["provider_declared"] is False async def test_health_rejects_non_vision_preprocessor_model(llm_service): - from tests.fixtures.configs import MIN_LLM_CONFIG_TOML - - config = MIN_LLM_CONFIG_TOML.replace( - 'models = ["gpt-test", "gpt-alt"]', - 'models = ["gpt-test", "gpt-alt"]\nnon_vision_models = ["gpt-test"]', - ) - llm_service.config_path.write_text( - config - + """ - -[image_preprocessing] -enabled = true -provider_id = "openai-main" -model = "gpt-test" -""", - encoding="utf-8", + from quickquip.llm.config import ImagePreprocessingConfig + llm_service.config.providers["openai-main"].non_vision_models = ["gpt-test"] + llm_service.config.image_preprocessing = ImagePreprocessingConfig( + enabled=True, provider_id="openai-main", model="gpt-test" ) - llm_service.reload_config() - + llm_service.rebuild_image_preprocessor() report = await llm_service.build_health_report(10001) items = {item.name: item for item in report.items} - assert items["image_preprocessing"].status == "warn" assert items["image_preprocessing"].details["model_declared_non_vision"] is True assert items["image_preprocessing"].details["runtime_bound"] is False diff --git a/tests/unit/llm/test_mcp_dual_era.py b/tests/unit/llm/test_mcp_dual_era.py index 776d1dff..165986e5 100644 --- a/tests/unit/llm/test_mcp_dual_era.py +++ b/tests/unit/llm/test_mcp_dual_era.py @@ -7,7 +7,7 @@ """ from __future__ import annotations -from quickquip.llm.config import _read_mcp_servers +from quickquip.llm.config_legacy import _read_mcp_servers from quickquip.llm.mcp.client import _extract_modern_server_info from quickquip.llm.mcp.types import ( MCPError, diff --git a/tests/unit/llm/test_probe_single_dispatch.py b/tests/unit/llm/test_probe_single_dispatch.py new file mode 100644 index 00000000..387f0997 --- /dev/null +++ b/tests/unit/llm/test_probe_single_dispatch.py @@ -0,0 +1,90 @@ +"""A probe never replays a possibly billed request through another dispatch path.""" +from __future__ import annotations + +import json +from unittest.mock import AsyncMock + +import httpx +import pytest + +from quickquip.llm.config import ProviderConfig +from quickquip.llm.config_runtime import ConfigRuntimeWorker +from quickquip.llm.provider import LLMProviderError, LLMRequest, build_provider_client +from quickquip.llm.provider_health import probe_provider + + +@pytest.fixture +def transport(monkeypatch): + original_client = httpx.AsyncClient + calls = [] + monkeypatch.setattr("quickquip.llm.provider.base.begin_http_trace", AsyncMock()) + monkeypatch.setattr("quickquip.llm.provider.base.finish_http_trace", AsyncMock()) + monkeypatch.setattr("quickquip.llm.provider.base._schedule_usage_record", lambda *args: None) + + def install(handler): + def capture(request): + calls.append((str(request.url), json.loads(request.content))) + return handler(request) + mock = httpx.MockTransport(capture) + monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original_client( + transport=mock, **kwargs + )) + return calls, install + + +def provider(): + return ProviderConfig( + id="test", protocol="openai", base_url="https://primary.test/v1", + api_key_env="TEST_PROBE_KEY", default_model="model", models=["model"], + fallback_urls=["https://secondary.test/v1"], retry_max_attempts=5, + ) + + +async def test_single_dispatch_blocks_retry_policy_and_endpoint_fallback(transport, monkeypatch): + monkeypatch.setenv("TEST_PROBE_KEY", "synthetic") + calls, install = transport + install(lambda request: httpx.Response(503, text="retryable service failure", request=request)) + # Keep the configured retries and fallback URL to exercise the transport policy itself. + client = build_provider_client(provider(), single_dispatch=True) + with pytest.raises(LLMProviderError): + await client.complete(LLMRequest( + model="model", system_prompt="", messages=[], temperature=0, max_output_tokens=1 + )) + assert len(calls) == 1 + assert calls[0][0].startswith("https://primary.test/") + + +async def test_plain_probe_sends_one_nonstream_request(transport, monkeypatch): + monkeypatch.setenv("TEST_PROBE_KEY", "synthetic") + calls, install = transport + install(lambda request: httpx.Response(503, text="retryable service failure", request=request)) + configuration = provider() + configuration.stream_enabled = True + result = await probe_provider(configuration) + assert result.status == "error" + assert len(calls) == 1 + assert calls[0][1].get("stream", False) is False + + +async def test_stream_diagnostic_does_not_replay_after_response_parse_failure( + transport, llm_service, monkeypatch +): + monkeypatch.setenv("OPENAI_API_KEY", "synthetic") + calls, install = transport + install(lambda request: httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + content=b'data: {"choices":[{"delta":3}]}\n\ndata: [DONE]\n\n', request=request, + )) + service = llm_service.config_service + draft = service.read() + draft["document"]["providers"][0]["fallback_urls"] = ["https://secondary.test/v1"] + service.replace(draft["document"], draft["revision"]) + operation = service.probe( + "draft", "openai-main", "gpt-test", "stream-probe", + payload={"user_prompt": "test", "stream": True}, + ) + await ConfigRuntimeWorker(service, llm_service).tick() + assert service.tasks.get(operation["id"])["status"] == "failed" + assert len(calls) == 1 + assert calls[0][1]["stream"] is True + assert service.read("published")["revision"] != operation["target_revision"] diff --git a/tests/unit/llm/test_single_shot_entries.py b/tests/unit/llm/test_single_shot_entries.py index 0cd8db83..213f3b15 100644 --- a/tests/unit/llm/test_single_shot_entries.py +++ b/tests/unit/llm/test_single_shot_entries.py @@ -95,8 +95,8 @@ async def test_defectify_load_error_short_circuits(llm_service, monkeypatch): async def test_defectify_missing_provider(llm_service): - # 绕过 set_chat_model 的存在性校验,直接写入一个未知 provider override - llm_service._update_chat_settings(1001, "group", provider_id="missing-provider") + # Simulate an invalid historical row outside the validated write service. + llm_service.store.update_group_settings(1001, provider_id="missing-provider") result = await llm_service.generate_defectify_reply( **_CHAT, prompt="测试内容" @@ -221,7 +221,7 @@ async def test_turmfluch_load_error_short_circuits(llm_service, monkeypatch): async def test_turmfluch_missing_provider(llm_service): - llm_service._update_chat_settings(1001, "group", provider_id="missing-provider") + llm_service.store.update_group_settings(1001, provider_id="missing-provider") result = await llm_service.generate_turmfluch_reply(**_CHAT, prompt="今天好倒霉") assert set(result) == _EARLY_KEYS diff --git a/tests/unit/llm/test_tools_enabled_mode.py b/tests/unit/llm/test_tools_enabled_mode.py index 3eca2112..8341e7d8 100644 --- a/tests/unit/llm/test_tools_enabled_mode.py +++ b/tests/unit/llm/test_tools_enabled_mode.py @@ -2,7 +2,7 @@ import logging -from quickquip.llm.config import ProviderConfig, ToolsConfig, load_llm_config +from quickquip.llm.config import LLMConfig, ProviderConfig, ToolsConfig, load_llm_config from quickquip.llm.service_parts.constants import DEFAULT_ENABLED_TOOLS from quickquip.llm.service_parts.tools import ToolMixin from quickquip.llm.tool_registry import ToolRegistry @@ -22,9 +22,7 @@ def __init__( mcp_names: set[str], providers: dict[str, ProviderConfig] | None = None, ) -> None: - # 与真实 LLMConfig 对齐:providers 恒存在(默认空 dict),生产代码 - # 直接访问 self.config.providers,无需 getattr 兜底。 - self.config = type("Cfg", (), {"tools": tools, "providers": providers or {}})() + self.config = LLMConfig(tools=tools, providers=providers or {}) self._mcp_tool_names = mcp_names self.tool_registry = ToolRegistry() for name in [*_BUILTIN_TOOLS, *sorted(mcp_names)]: diff --git a/tests/unit/llm/test_usage_metering.py b/tests/unit/llm/test_usage_metering.py index a729be13..68aa896e 100644 --- a/tests/unit/llm/test_usage_metering.py +++ b/tests/unit/llm/test_usage_metering.py @@ -12,16 +12,22 @@ def _config() -> ProviderConfig: return ProviderConfig( - id="fake", protocol="claude", base_url="https://x/v1", - api_key_env="K", default_model="m", models=["m"], + id="fake", + protocol="claude", + base_url="https://x/v1", + api_key_env="K", + default_model="m", + models=["m"], ) def _req() -> LLMRequest: return LLMRequest( - model="m", system_prompt="s", + model="m", + system_prompt="s", messages=[LLMConversationMessage(role="user", content="hi", image_urls=[])], - temperature=0.2, max_output_tokens=64, + temperature=0.2, + max_output_tokens=64, ) @@ -29,8 +35,14 @@ async def test_complete_ok_records_usage(monkeypatch): calls = [] async def spy( - client, request, response, started, stream_used, state, - error_msg="", finished_at=None, + client, + request, + response, + started, + stream_used, + state, + error_msg="", + finished_at=None, ): calls.append((state, response is not None)) @@ -54,8 +66,14 @@ async def test_complete_does_not_await_usage_record(monkeypatch): calls = [] async def spy( - client, request, response, started, stream_used, state, - error_msg="", finished_at=None, + client, + request, + response, + started, + stream_used, + state, + error_msg="", + finished_at=None, ): calls.append(state) entered.set() @@ -81,8 +99,14 @@ async def test_complete_error_records_state(monkeypatch): calls = [] async def spy( - client, request, response, started, stream_used, state, - error_msg="", finished_at=None, + client, + request, + response, + started, + stream_used, + state, + error_msg="", + finished_at=None, ): calls.append((state, response)) @@ -105,8 +129,14 @@ async def test_complete_cancelled_propagates(monkeypatch): calls = [] async def spy( - client, request, response, started, stream_used, state, - error_msg="", finished_at=None, + client, + request, + response, + started, + stream_used, + state, + error_msg="", + finished_at=None, ): calls.append((state, response)) @@ -142,6 +172,7 @@ async def _raise(*a, **kw): await drain_usage_tasks() from quickquip.llm import usage_store as usage_store_module + with usage_store_module.usage_store.connect() as conn: row = conn.execute("SELECT error_message FROM llm_usage_events").fetchone() assert row["error_message"].startswith("ConnectError:") @@ -153,7 +184,11 @@ async def _raise(*a, **kw): async def test_record_usage_carries_context_meters_and_resets(monkeypatch, tmp_path): """同一请求的四种上下文计量一起落库,退出作用域后均复位。""" from quickquip.llm.usage import ( - _record_usage, envelope_meter, epoch_meter, media_meter, patch_meter, + _record_usage, + envelope_meter, + epoch_meter, + media_meter, + patch_meter, ) from quickquip.llm.usage_store import LLMUsageStore from plugins.llm_config import ProviderConfig @@ -164,8 +199,12 @@ async def test_record_usage_carries_context_meters_and_resets(monkeypatch, tmp_p class FakeClient: config = ProviderConfig( - id="p", protocol="claude", base_url="https://x/v1", - api_key_env="K", default_model="m", models=["m"], + id="p", + protocol="claude", + base_url="https://x/v1", + api_key_env="K", + default_model="m", + models=["m"], ) class FakeReq: @@ -182,7 +221,9 @@ class FakeReq: "FROM llm_usage_events ORDER BY id" ).fetchall() assert [tuple(r) for r in rows] == [ - (456, 4200, 2, 360), (456, 4200, 2, 360), (None, None, None, None), + (456, 4200, 2, 360), + (456, 4200, 2, 360), + (None, None, None, None), ] @@ -198,16 +239,24 @@ async def test_record_usage_writes_db_row(monkeypatch, tmp_path): class FakeClient: config = ProviderConfig( - id="p", protocol="claude", base_url="https://x/v1", - api_key_env="K", default_model="m", models=["m"], + id="p", + protocol="claude", + base_url="https://x/v1", + api_key_env="K", + default_model="m", + models=["m"], ) class FakeReq: model = "m" response = LLMResponse( - text="ok", model="m", input_tokens=100, output_tokens=50, - cache_creation_tokens=80, cache_read_tokens=200, + text="ok", + model="m", + input_tokens=100, + output_tokens=50, + cache_creation_tokens=80, + cache_read_tokens=200, ) await _record_usage(FakeClient(), FakeReq(), response, 0.0, True, "ok") with fake_store.connect() as conn: @@ -238,8 +287,12 @@ async def test_record_usage_uses_provider_level_pricing(monkeypatch, tmp_path): class FakeClient: config = ProviderConfig( - id="p1", protocol="openai", base_url="https://x/v1", - api_key_env="K", default_model="gpt-test", models=["gpt-test"], + id="p1", + protocol="openai", + base_url="https://x/v1", + api_key_env="K", + default_model="gpt-test", + models=["gpt-test"], ) class FakeReq: @@ -269,22 +322,28 @@ async def test_record_usage_input_semantics_matches_protocol(monkeypatch, tmp_pa async def _record(protocol: str) -> str | None: class FakeClient: config = ProviderConfig( - id="p", protocol=protocol, base_url="https://x/v1", - api_key_env="K", default_model="m", models=["m"], + id="p", + protocol=protocol, + base_url="https://x/v1", + api_key_env="K", + default_model="m", + models=["m"], ) class FakeReq: model = "m" response = LLMResponse( - text="ok", model="m", input_tokens=100, output_tokens=50, + text="ok", + model="m", + input_tokens=100, + output_tokens=50, cache_read_tokens=200, ) await _record_usage(FakeClient(), FakeReq(), response, 0.0, True, "ok") with fake_store.connect() as conn: return conn.execute( - "SELECT input_token_semantics FROM llm_usage_events" - " WHERE protocol = ?", (protocol,) + "SELECT input_token_semantics FROM llm_usage_events WHERE protocol = ?", (protocol,) ).fetchone()[0] assert await _record("claude") == "exclusive" @@ -306,22 +365,28 @@ async def test_record_usage_persists_finish_reason(monkeypatch, tmp_path): class FakeClient: config = ProviderConfig( - id="p", protocol="claude", base_url="https://x/v1", - api_key_env="K", default_model="m", models=["m"], + id="p", + protocol="claude", + base_url="https://x/v1", + api_key_env="K", + default_model="m", + models=["m"], ) class FakeReq: model = "m" ok_response = LLMResponse( - text="ok", model="m", input_tokens=10, output_tokens=5, finish_reason="end_turn", + text="ok", + model="m", + input_tokens=10, + output_tokens=5, + finish_reason="end_turn", ) await _record_usage(FakeClient(), FakeReq(), ok_response, 0.0, True, "ok") await _record_usage(FakeClient(), FakeReq(), None, 0.0, True, "error", "boom") with fake_store.connect() as conn: - rows = conn.execute( - "SELECT finish_reason FROM llm_usage_events ORDER BY id" - ).fetchall() + rows = conn.execute("SELECT finish_reason FROM llm_usage_events ORDER BY id").fetchall() assert [r["finish_reason"] for r in rows] == ["end_turn", None] @@ -341,8 +406,12 @@ async def test_summary_usage_records_response_outcome_without_losing_cost(monkey class FakeClient: config = ProviderConfig( - id="p", protocol="openai", base_url="https://x/v1", - api_key_env="K", default_model="m", models=["m"], + id="p", + protocol="openai", + base_url="https://x/v1", + api_key_env="K", + default_model="m", + models=["m"], ) class FakeReq: @@ -353,7 +422,10 @@ class FakeReq: text="完整", model="m", input_tokens=100, output_tokens=50, finish_reason="stop" ), LLMResponse( - text="残稿", model="m", input_tokens=100, output_tokens=50, + text="残稿", + model="m", + input_tokens=100, + output_tokens=50, finish_reason="MAX_TOKENS", ), LLMResponse(text=" ", model="m", input_tokens=100, output_tokens=50, finish_reason="stop"), @@ -378,3 +450,34 @@ class FakeReq: assert rows[0]["cost_usd"] > 0 assert rows[1]["cost_usd"] == rows[0]["cost_usd"] assert rows[2]["cost_usd"] == rows[0]["cost_usd"] + + +async def test_compiled_tuple_price_identity_and_snapshot_revision_persist(monkeypatch, tmp_path): + from quickquip.llm.config import LLMConfig, PricingRates, ProviderConfig + from quickquip.llm.provider import LLMResponse + from quickquip.llm.usage import _record_usage, configuration_pricing + from quickquip.llm.usage_store import LLMUsageStore + from types import SimpleNamespace + + store = LLMUsageStore(tmp_path / "usage.db") + monkeypatch.setattr("quickquip.llm.usage_store.usage_store", store) + old = LLMConfig( + revision=7, + pricing_references={ + ("p", "org/model"): PricingRates(input_per_mtok=2), + (None, "org/model"): PricingRates(input_per_mtok=100), + }, + ) + client = SimpleNamespace( + config=ProviderConfig("p", "openai", "https://x", "KEY", "org/model", []) + ) + response = LLMResponse(text="ok", model="org/model", input_tokens=100, output_tokens=0) + with configuration_pricing(old): + await _record_usage(client, SimpleNamespace(model="org/model"), response, 0, False, "ok") + with store.connect() as connection: + row = connection.execute( + "SELECT pricing_model,config_revision,cost_usd FROM llm_usage_events" + ).fetchone() + assert row["pricing_model"] == "p/org/model" + assert row["config_revision"] == 7 + assert row["cost_usd"] == 100 * 2 / 1_000_000 diff --git a/tests/unit/scripts/test_deploy_v4.py b/tests/unit/scripts/test_deploy_v4.py index 1ca46227..35aa6284 100644 --- a/tests/unit/scripts/test_deploy_v4.py +++ b/tests/unit/scripts/test_deploy_v4.py @@ -1,4 +1,5 @@ """Isolated deployment transactions; no SSH or real Docker daemon access.""" + from __future__ import annotations import io @@ -44,7 +45,9 @@ def deployment(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() docker = bin_dir / "docker" - docker.write_text(f"#!{sys.executable}\n" + ''' + docker.write_text( + f"#!{sys.executable}\n" + + """ import json, os, pathlib, sys args = sys.argv[1:] root = pathlib.Path(os.environ["TEST_ROOT"]) @@ -79,6 +82,8 @@ def deployment(tmp_path): )) elif "build" in args and os.environ.get("FAIL_BUILD") == "1": sys.exit(1) + elif "run" in args and os.environ.get("FAIL_CONFIG_INIT") == "1": + sys.exit(1) elif "up" in args: if os.environ.get("FAIL_UP") == "all" or ( os.environ.get("FAIL_UP") == "new" and release == os.environ["TEST_NEW"] @@ -90,7 +95,8 @@ def deployment(tmp_path): print("Bot 123456 connected") elif args[0] == "inspect": print("true") -''') +""" + ) docker.chmod(0o700) env = dict( os.environ, @@ -105,7 +111,10 @@ def run_deploy(deployment, action="deploy", target="", **overrides): root, inbox, env = deployment return subprocess.run( ["bash", str(inbox / "remote-deploy-v4.sh"), str(root), NEW, "4", action, target], - env=env | overrides, text=True, capture_output=True, timeout=20, + env=env | overrides, + text=True, + capture_output=True, + timeout=20, ) @@ -173,8 +182,15 @@ def test_bash_rejects_invalid_modes_before_side_effects(args): @pytest.mark.parametrize( "legacy", [ - "-DryRun", "-Status", "-Rollback", "-Migrate", "-SkipHealth", - "-HostAlias", "-RemoteDir", "-KeepReleases", "-LocalCheck", + "-DryRun", + "-Status", + "-Rollback", + "-Migrate", + "-SkipHealth", + "-HostAlias", + "-RemoteDir", + "-KeepReleases", + "-LocalCheck", ], ) def test_bash_rejects_legacy_single_dash_aliases(legacy): @@ -283,9 +299,7 @@ def _write_fake_docker_for_baseline( "llbot": {"container_name": "llbot"}, "quickquip": { "container_name": "quickquip", - "volumes": [ - {"type": "bind", "source": source, "target": "/app/skills"} - ], + "volumes": [{"type": "bind", "source": source, "target": "/app/skills"}], }, "web-admin": {"container_name": "web-admin"}, } @@ -526,3 +540,67 @@ def test_rollback_rejects_unknown_mount_layout(deployment): assert not any( "up" in call and str(target / "prod/docker-compose.yml") in call for call in calls ) + + +def _add_config_initializer(deployment): + root, inbox, _ = deployment + marker = inbox / "tree/src/quickquip/llm/config_cli.py" + marker.parent.mkdir(parents=True) + marker.write_text("# isolated configuration initializer marker\n") + old = root / "releases" / OLD / "config" + old.mkdir() + (old / "llm.toml").write_text("[runtime]\nenabled = false\n") + candidate = inbox / "tree/config" + candidate.mkdir() + return old, candidate + + +def test_config_migration_selects_server_assets_before_initializer(deployment): + root, _, _ = deployment + old, candidate = _add_config_initializer(deployment) + (candidate / "generation.toml").write_text("[image]\nenabled = false\n") + (candidate / "personas").mkdir() + (candidate / "personas/local.toml").write_text('id="local"\n') + result = run_deploy(deployment) + assert result.returncode == 0, result.stdout + result.stderr + config = root / "releases" / NEW / "config" + assert (config / "llm.toml").read_bytes() == (old / "llm.toml").read_bytes() + assert not (config / "generation.toml").exists() + assert (config / ".llm-upgrade-generation.toml").exists() + assert not (config / "personas/local.toml").exists() + calls = [json.loads(line) for line in (root / "calls").read_text().splitlines()] + build = next(i for i, call in enumerate(calls) if "build" in call) + initialize = next( + i for i, call in enumerate(calls) if "config_cli" in " ".join(call) and "init" in call + ) + activate = next(i for i, call in enumerate(calls) if "up" in call) + assert build < initialize < activate + + +def test_config_initialization_failure_preserves_current_release(deployment): + root, _, _ = deployment + _add_config_initializer(deployment) + result = run_deploy(deployment, FAIL_CONFIG_INIT="1") + assert result.returncode != 0 + assert (root / "current").readlink() == Path("releases") / OLD + assert not (root / "data/llm_config.db").exists() + assert not any("up" in json.loads(line) for line in (root / "calls").read_text().splitlines()) + + +def test_first_migration_breaks_candidate_generation_hardlink(deployment): + root, _, _ = deployment + old, candidate = _add_config_initializer(deployment) + body = "[image]\nenabled = true\n" + (old / "generation.toml").write_text(body) + (candidate / "generation.toml").write_text(body) + (candidate / "generation.toml").chmod(0o644) + (old / "generation.toml").chmod(0o644) + # Force matching timestamps so the release driver chooses rsync's hardlink path. + source_stat = (old / "generation.toml").stat() + os.utime(candidate / "generation.toml", ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns)) + result = run_deploy(deployment) + assert result.returncode == 0, result.stdout + result.stderr + current = root / "releases" / NEW / "config/generation.toml" + assert current.read_text() == body + assert (old / "generation.toml").read_text() == body + assert current.stat().st_ino != (old / "generation.toml").stat().st_ino diff --git a/tests/unit/web/conftest.py b/tests/unit/web/conftest.py new file mode 100644 index 00000000..96e93120 --- /dev/null +++ b/tests/unit/web/conftest.py @@ -0,0 +1,26 @@ +"""Web regressions always use isolated LLM configuration databases.""" + +import importlib + +import pytest + +from quickquip.llm.config_service import ConfigService + + +@pytest.fixture(autouse=True) +def config_service(monkeypatch, tmp_path): + service = ConfigService(tmp_path / "llm_config.db") + service.initialize() + import quickquip.llm.config_service as service_module + + monkeypatch.setattr(service_module, "get_config_service", lambda: service) + for name in ( + "llm_configuration", + "llm_effort", + "diagnostics", + "group_settings", + "mcp_dashboard", + ): + module = importlib.import_module(f"quickquip.app.web.routes.{name}") + monkeypatch.setattr(module, "get_config_service", lambda: service) + return service diff --git a/tests/unit/web/test_config_routes.py b/tests/unit/web/test_config_routes.py index 5bb3d6fe..8d64b21d 100644 --- a/tests/unit/web/test_config_routes.py +++ b/tests/unit/web/test_config_routes.py @@ -46,7 +46,7 @@ def test_sensitive_words_config_key_is_not_writable(monkeypatch, tmp_path): with pytest.raises(HTTPException) as exc: config.put_config( "sensitive_words", - config.ConfigBody(content="[block]\nwords = [\"secret\"]\n"), + config.ConfigBody(content='[block]\nwords = ["secret"]\n'), _mock_request(), ) @@ -58,7 +58,8 @@ def test_put_awakening_config_queues_reload(monkeypatch, tmp_path): base = _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] monkeypatch.setattr( - config.action_queue, "enqueue", + config.action_queue, + "enqueue", lambda action_type: captured.append(action_type) or {"id": "a1"}, ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) @@ -78,7 +79,8 @@ def test_put_chat_rules_config_queues_rules_reload(monkeypatch, tmp_path): _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] monkeypatch.setattr( - config.action_queue, "enqueue", + config.action_queue, + "enqueue", lambda action_type: captured.append(action_type) or {"id": "r1"}, ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) @@ -93,32 +95,27 @@ def test_put_chat_rules_config_queues_rules_reload(monkeypatch, tmp_path): assert captured == ["rules_reload"] -def test_put_llm_config_does_not_queue_reload(monkeypatch, tmp_path): - """llm 改动不自动 reload——reload_runtime 含探活会静默扣费(opt-in)。""" - _patch_config_dir(monkeypatch, tmp_path) - captured: list[str] = [] - monkeypatch.setattr( - config.action_queue, "enqueue", - lambda action_type: captured.append(action_type) or {"id": "x1"}, - ) - monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) - - result = config.put_config( - "llm", - config.ConfigBody(content="[runtime]\nenabled = true\n"), - _mock_request(), - ) - - assert result["effect"] == "manual_reload" - assert captured == [] # 不入队,避免静默探活扣费 - - -@pytest.mark.parametrize("key", ["games", "generation", "niuniu_text", "niuniu_text_safe"]) +def test_legacy_llm_editor_returns_migration_notice(monkeypatch, tmp_path): + base = _patch_config_dir(monkeypatch, tmp_path) + for call in ( + lambda: config.get_config("llm"), + lambda: config.put_config("llm", config.ConfigBody(content=""), _mock_request()), + ): + with pytest.raises(HTTPException) as exc: + call() + assert exc.value.status_code == 410 + assert exc.value.detail["code"] == "llm_configuration_migrated" + assert not (base / "llm.toml").exists() + assert "llm" not in {entry["key"] for entry in config.list_configs()["configs"]} + + +@pytest.mark.parametrize("key", ["generation", "games", "niuniu_text", "niuniu_text_safe"]) def test_put_restart_needed_configs_do_not_queue_reload(monkeypatch, tmp_path, key): _patch_config_dir(monkeypatch, tmp_path) captured: list[str] = [] monkeypatch.setattr( - config.action_queue, "enqueue", + config.action_queue, + "enqueue", lambda action_type: captured.append(action_type) or {"id": "g1"}, ) monkeypatch.setattr(config.audit_logger, "log", lambda *args, **kwargs: None) diff --git a/tests/unit/web/test_diagnostics_routes.py b/tests/unit/web/test_diagnostics_routes.py index 1b33e237..06437f6d 100644 --- a/tests/unit/web/test_diagnostics_routes.py +++ b/tests/unit/web/test_diagnostics_routes.py @@ -1,57 +1,39 @@ -from __future__ import annotations +"""Model diagnostics are immutable bot operations and never run in Web.""" -from types import SimpleNamespace -from unittest.mock import AsyncMock +from __future__ import annotations import pytest -fastapi = pytest.importorskip("fastapi") -HTTPException = fastapi.HTTPException - -from quickquip.app.web.routes import diagnostics # noqa: E402 -from quickquip.llm.provider_health import ProviderHealth # noqa: E402 -from quickquip.llm.provider_health import format_probe_results # noqa: E402 - - -async def test_probe_providers_returns_results_and_text(monkeypatch): - """端点应并发探活并返回结构化 results + 后端统一格式化的 text。""" - monkeypatch.setattr( - diagnostics, "load_llm_config", lambda path: SimpleNamespace(load_error=None, providers={}) - ) - - fake_results = [ - ProviderHealth("p1", "m1", "ok", latency_ms=100.0), - ProviderHealth("p2", "m2", "error", latency_ms=5000.0, error="timeout"), +from quickquip.app.web.routes import diagnostics +from quickquip.app.web.routes.llm_configuration import ProbeBody +from quickquip.llm.config_document import ConfigError + + +def test_probe_queues_explicit_snapshot(config_service): + draft = config_service.read() + draft["document"]["providers"] = [ + { + "id": "p", + "protocol": "openai", + "base_url": "https://example.com/v1", + "api_key_env": "API_KEY", + "default_model": "m", + "models": [{"model": "m"}], + } ] - - async def _fake_probe(cfg, **kwargs): - return fake_results - - monkeypatch.setattr(diagnostics, "probe_all_providers", _fake_probe) - - resp = await diagnostics.probe_providers() - - assert len(resp["results"]) == 2 - assert resp["results"][0]["provider_id"] == "p1" - assert resp["results"][0]["status"] == "ok" - assert resp["results"][1]["status"] == "error" - # 独立期望:合计行由 ok/error 计数直接决定,不用生产格式化函数自证 - assert "合计:1/2 正常" in resp["text"] - assert resp["text"] == format_probe_results(fake_results) - - -async def test_probe_providers_raises_on_config_error(monkeypatch): - """config 加载失败时应抛 400,不触发探活(不白花钱)。""" - monkeypatch.setattr( - diagnostics, - "load_llm_config", - lambda path: SimpleNamespace(load_error="TOML 语法错误"), + config_service.replace(draft["document"], 1) + result = diagnostics.probe_providers( + ProbeBody(source="draft", provider_id="p", model="m", request_id="probe") ) - - probe = AsyncMock() - monkeypatch.setattr(diagnostics, "probe_all_providers", probe) - with pytest.raises(HTTPException) as exc: - await diagnostics.probe_providers() - - assert exc.value.status_code == 400 - probe.assert_not_awaited() + assert result["status"] == "queued" + assert result["kind"] == "probe" + assert result["target_revision"] == 2 + assert result["may_charge"] and not result["automatic_retry"] + assert config_service.status()["published_revision"] == 1 + + +def test_probe_unknown_target_rejected(config_service): + with pytest.raises(ConfigError) as exc: + diagnostics.probe_providers(ProbeBody(provider_id="missing", model="m", request_id="probe")) + assert exc.value.code in ("invalid_reference", "dependency_conflict") + assert config_service.tasks.claim("bot") is None diff --git a/tests/unit/web/test_group_settings_delivery_routes.py b/tests/unit/web/test_group_settings_delivery_routes.py index e3d1a9f5..959fcb39 100644 --- a/tests/unit/web/test_group_settings_delivery_routes.py +++ b/tests/unit/web/test_group_settings_delivery_routes.py @@ -1,4 +1,5 @@ """group-settings 路由的交付两域字段:写入路由、默认投影与列表形状。""" + from __future__ import annotations import sqlite3 @@ -19,9 +20,14 @@ def test_options_defaults_carry_both_delivery_domains(monkeypatch, tmp_path): import types fake_runtime = types.SimpleNamespace( - enabled=True, memory_enabled=True, auto_memory_enabled=False, - agent_delivery_intermediate_enabled=True, agent_delivery_final_enabled=False, - default_provider="p1", default_persona=None, history_limit=10, + enabled=True, + memory_enabled=True, + auto_memory_enabled=False, + agent_delivery_intermediate_enabled=True, + agent_delivery_final_enabled=False, + default_provider="p1", + default_persona=None, + history_limit=10, ) fake_cfg = types.SimpleNamespace( runtime=fake_runtime, @@ -30,8 +36,7 @@ def test_options_defaults_carry_both_delivery_domains(monkeypatch, tmp_path): triggers=types.SimpleNamespace(default_prefix="/", allow_prefix=True, allow_at=True), load_error=None, ) - monkeypatch.setattr(routes, "load_llm_config", lambda path: fake_cfg) - monkeypatch.setattr(routes, "_LLM_TOML", tmp_path / "llm.toml") + monkeypatch.setattr(routes, "compile_document", lambda document, **kwargs: fake_cfg) payload = routes.get_options() assert payload["defaults"]["agent_delivery_intermediate_enabled"] is True @@ -41,11 +46,11 @@ def test_options_defaults_carry_both_delivery_domains(monkeypatch, tmp_path): def test_options_defaults_empty_when_llm_toml_load_fails(monkeypatch, tmp_path): """读 llm.toml 抛异常时兜底空 defaults + 非空 load_error(不向上抛)。""" - def _boom(path): + + def _boom(document, **kwargs): raise RuntimeError("boom") - monkeypatch.setattr(routes, "load_llm_config", _boom) - monkeypatch.setattr(routes, "_LLM_TOML", tmp_path / "llm.toml") + monkeypatch.setattr(routes, "compile_document", _boom) payload = routes.get_options() assert payload["providers"] == [] @@ -117,9 +122,11 @@ def test_list_group_settings_projects_both_delivery_domains(monkeypatch, tmp_pat conn.close() import quickquip.app.message_pipeline as message_pipeline + monkeypatch.setattr(routes, "_DB", db_path) - monkeypatch.setattr(message_pipeline, "stats_tracker", - type("T", (), {"to_dict": staticmethod(lambda: {})})()) + monkeypatch.setattr( + message_pipeline, "stats_tracker", type("T", (), {"to_dict": staticmethod(lambda: {})})() + ) payload = routes.list_group_settings() entry = next(g for g in payload["groups"] if g["group_id"] == "10001") diff --git a/tests/unit/web/test_group_settings_effort_routes.py b/tests/unit/web/test_group_settings_effort_routes.py index c5d49bde..c6aff67f 100644 --- a/tests/unit/web/test_group_settings_effort_routes.py +++ b/tests/unit/web/test_group_settings_effort_routes.py @@ -1,4 +1,5 @@ """group-settings 路由的思考档位字段:options 投影、PUT 校验与列表投影。""" + from __future__ import annotations import sqlite3 @@ -18,25 +19,35 @@ def test_options_providers_carry_reasoning_effort(monkeypatch, tmp_path): fake_providers = { "p1": types.SimpleNamespace( - id="p1", default_model="m1", models=["m1"], reasoning_effort="high", + id="p1", + default_model="m1", + models=["m1"], + reasoning_effort="high", ), "p2": types.SimpleNamespace( - id="p2", default_model="m2", models=["m2"], reasoning_effort="", + id="p2", + default_model="m2", + models=["m2"], + reasoning_effort="", ), } fake_cfg = types.SimpleNamespace( runtime=types.SimpleNamespace( - enabled=True, memory_enabled=True, auto_memory_enabled=False, - agent_delivery_intermediate_enabled=True, agent_delivery_final_enabled=False, - default_provider="p1", default_persona=None, history_limit=10, + enabled=True, + memory_enabled=True, + auto_memory_enabled=False, + agent_delivery_intermediate_enabled=True, + agent_delivery_final_enabled=False, + default_provider="p1", + default_persona=None, + history_limit=10, ), providers=fake_providers, personas={}, triggers=types.SimpleNamespace(default_prefix="/", allow_prefix=True, allow_at=True), load_error=None, ) - monkeypatch.setattr(routes, "load_llm_config", lambda path: fake_cfg) - monkeypatch.setattr(routes, "_LLM_TOML", tmp_path / "llm.toml") + monkeypatch.setattr(routes, "compile_document", lambda document, **kwargs: fake_cfg) payload = routes.get_options() efforts = {p["id"]: p["reasoning_effort"] for p in payload["providers"]} @@ -55,19 +66,22 @@ def update_group_settings(self, group_id, **fields): monkeypatch.setattr(routes, "_store", lambda: FakeStore()) monkeypatch.setattr(routes, "_DB", tmp_path / "llm.db") monkeypatch.setattr( - routes.audit_logger, "log", + routes.audit_logger, + "log", lambda request, **kwargs: audits.append(kwargs), ) body = routes.GroupSettingsBody(reasoning_effort="high") assert routes.put_group_settings("10001", body, object()) == {"ok": True} assert calls == [("10001", {"reasoning_effort": "high"})] - assert audits == [{ - "action": "update", - "target_type": "group_setting", - "target_id": "10001", - "summary_after": {"fields": ["reasoning_effort"]}, - }] + assert audits == [ + { + "action": "update", + "target_type": "group_setting", + "target_id": "10001", + "summary_after": {"fields": ["reasoning_effort"]}, + } + ] # 显式 null 与未发送的区分:null 进 payload(清覆盖),缺省不进 payload clear_body = routes.GroupSettingsBody(reasoning_effort=None) @@ -121,9 +135,11 @@ def test_list_group_settings_projects_reasoning_effort(monkeypatch, tmp_path): conn.close() import quickquip.app.message_pipeline as message_pipeline + monkeypatch.setattr(routes, "_DB", db_path) - monkeypatch.setattr(message_pipeline, "stats_tracker", - type("T", (), {"to_dict": staticmethod(lambda: {})})()) + monkeypatch.setattr( + message_pipeline, "stats_tracker", type("T", (), {"to_dict": staticmethod(lambda: {})})() + ) payload = routes.list_group_settings() entries = {g["group_id"]: g for g in payload["groups"]} diff --git a/tests/unit/web/test_llm_configuration_routes.py b/tests/unit/web/test_llm_configuration_routes.py new file mode 100644 index 00000000..049f5ffd --- /dev/null +++ b/tests/unit/web/test_llm_configuration_routes.py @@ -0,0 +1,189 @@ +"""Authenticated configuration HTTP contract and sensitive-data boundaries.""" + +from __future__ import annotations + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from quickquip.app.web import auth +from quickquip.app.web.routes import llm_configuration as routes +from quickquip.app.web.session_store import WebAdminSessionStore + +BASE = "/ops/api/llm-configuration" + + +@pytest.fixture +def client(monkeypatch, tmp_path, config_service): + monkeypatch.setattr(auth, "get_web_admin_password", lambda: "test-admin-password") + sessions = WebAdminSessionStore(tmp_path / "sessions.db") + monkeypatch.setattr(auth, "_session_store", lambda: sessions) + monkeypatch.setattr(routes.audit_logger, "log", lambda *a, **k: None) + app = FastAPI() + app.include_router(auth.router, prefix="/ops/api") + app.include_router(routes.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + with TestClient(app, headers={"origin": "http://testserver"}) as result: + yield result + + +def login(client): + assert ( + client.post("/ops/api/auth/login", json={"password": "test-admin-password"}).status_code + == 200 + ) + + +def provider(): + return { + "id": "p", + "protocol": "openai", + "base_url": "https://example.com/v1", + "api_key_env": "MODEL_KEY", + "default_model": "owner/model", + "models": [{"model": "owner/model", "parameters": {"temperature": 0}}], + } + + +def test_authentication_same_origin_and_flat_error(client): + response = client.get(BASE) + assert response.status_code == 401 + assert response.json()["code"] == "unauthorized" + assert set(response.json()) == {"code", "message", "field_path", "details"} + login(client) + response = client.put(BASE, headers={"origin": "https://attacker.example"}, json={}) + assert response.status_code == 403 + + +def test_import_preview_save_apply_and_conflict_flow(client, config_service): + login(client) + initial = client.get(BASE).json() + document = initial["document"] + document["providers"] = [provider()] + body = {"document": document, "expected_draft_revision": initial["revision"]} + preview = client.post(f"{BASE}/import/preview", json=body) + assert preview.status_code == 200 + assert preview.json()["valid"] and preview.json()["warnings"] + assert config_service.read()["revision"] == 1 + saved = client.post(f"{BASE}/import", json=body) + assert saved.status_code == 200 + assert saved.json()["revision"] == 2 + assert config_service.read("published")["revision"] == 1 + conflict = client.put(BASE, json=body) + assert conflict.status_code == 409 + assert conflict.json()["code"] == "revision_conflict" + operation = client.post( + f"{BASE}/apply", json={"expected_draft_revision": 2, "request_id": "apply"} + ) + assert operation.status_code == 202 + assert operation.json()["status"] == "queued" + assert client.get(f"{BASE}/operations/{operation.json()['id']}").json()["status"] == "queued" + assert client.get(f"{BASE}/operations").json()["operations"][0]["id"] == operation.json()["id"] + assert "claim_token" not in operation.json() + assert config_service.status()["published_revision"] == 1 + assert client.post(f"{BASE}/operations/{operation.json()['id']}/cancel").status_code == 200 + + +def test_errors_and_exports_never_contain_credentials(client, config_service, monkeypatch): + login(client) + monkeypatch.setenv("MODEL_KEY", "secret-value-never-export") + document = config_service.read()["document"] + document["providers"] = [provider()] + document["providers"][0]["headers"] = {"Authorization": "Bearer literal-secret-value"} + invalid = client.put(BASE, json={"expected_draft_revision": 1, "document": document}) + assert invalid.status_code == 422 + assert invalid.json()["code"] == "literal_secret" + assert "literal-secret-value" not in invalid.text + document["providers"][0]["headers"] = {"Authorization": "Bearer ${MODEL_KEY}"} + assert ( + client.put(BASE, json={"expected_draft_revision": 1, "document": document}).status_code + == 200 + ) + export = client.get(f"{BASE}/export") + assert "${MODEL_KEY}" in export.text + assert "secret-value-never-export" not in export.text + invalid_body = client.post( + f"{BASE}/apply", json={"request_id": "x", "expected_draft_revision": "sensitive-input"} + ) + assert invalid_body.status_code == 422 + assert "sensitive-input" not in invalid_body.text + + +def test_body_limit_without_trusted_content_length(client, config_service): + login(client) + oversized = json.dumps( + {"document": {"large": "x" * routes.MAX_BODY_BYTES}, "expected_draft_revision": 1} + ).encode() + response = client.put( + BASE, + content=iter([oversized[:500000], oversized[500000:]]), + headers={"content-type": "application/json"}, + ) + assert response.status_code == 413 + assert response.json()["code"] == "body_too_large" + assert config_service.read()["revision"] == 1 + + +def test_bot_environment_observation_and_effective_parameters(client, config_service): + login(client) + document = config_service.read()["document"] + document["providers"] = [provider()] + config_service.replace(document, 1) + unavailable = client.get(f"{BASE}/environment").json() + assert not unavailable["available"] + assert unavailable["variables"] == [{"name": "MODEL_KEY", "configured": None}] + boot = config_service.register_bot_boot("boot", 1) + config_service.report_bot(boot, 1, {"environment": [{"name": "MODEL_KEY", "configured": True}]}) + assert client.get(f"{BASE}/environment").json()["variables"][0]["configured"] is True + effective = client.get(f"{BASE}/providers/p/effective", params={"model": "owner/model"}) + assert effective.status_code == 200 + assert effective.json()["parameters"]["temperature"]["value"] == 0 + assert effective.json()["parameters"]["temperature"]["source"] == "model" + + +def test_public_openapi_matches_runtime_registry(): + from pathlib import Path + from quickquip.app.web.llm_configuration_schema import configuration_openapi + + artifact = Path(__file__).resolve().parents[3] / "docs/dev/llm-configuration.openapi.json" + expected = json.loads(artifact.read_text(encoding="utf-8")) + generated = json.loads(json.dumps(configuration_openapi())) + assert generated == expected + assert generated["components"]["schemas"]["ConfigDocument"]["properties"]["providers"] + for path, verbs in generated["paths"].items(): + for method, operation in verbs.items(): + if method in ("get", "post", "put", "patch", "delete"): + assert operation["security"] == [{"AdminSession": []}] + assert operation["responses"]["409"]["content"]["application/json"]["schema"][ + "$ref" + ] + + +def test_parameter_patch_keeps_omitted_values_and_replaces_extra_body(client, config_service): + login(client) + document = config_service.read()["document"] + entry = provider() + entry["parameters"] = { + "temperature": 0.5, + "stream_enabled": True, + "extra_body": {"old": {"inner": 1}}, + } + document["providers"] = [entry] + document["pricing"] = [{"provider_id": None, "model": "owner/model", "input_per_mtok": 0}] + config_service.replace(document, 1) + response = client.patch( + f"{BASE}/providers/p", + json={ + "expected_draft_revision": 2, + "fields": {"parameters": {"temperature": None, "extra_body": {}}}, + }, + ) + assert response.status_code == 200 + parameters = response.json()["document"]["providers"][0]["parameters"] + assert parameters["stream_enabled"] is True + assert parameters["temperature"] is None + assert parameters["extra_body"] == {} + detail = client.get(f"{BASE}/providers/p").json() + assert detail["pricing"][0]["provider_id"] is None + assert detail["effective_parameters"]["owner/model"]["stream_enabled"]["source"] == "provider" diff --git a/tests/unit/web/test_llm_effort_routes.py b/tests/unit/web/test_llm_effort_routes.py index 365215e6..65d4a7e6 100644 --- a/tests/unit/web/test_llm_effort_routes.py +++ b/tests/unit/web/test_llm_effort_routes.py @@ -1,245 +1,71 @@ -"""llm-effort 路由:llm.toml provider 级思考档位的结构化读取与行级手术。""" +"""Provider thinking defaults use revision checks and draft-only persistence.""" + from __future__ import annotations import pytest - -fastapi = pytest.importorskip("fastapi") -HTTPException = fastapi.HTTPException - -from pydantic import ValidationError # noqa: E402 - -from quickquip.app.web.routes import llm_effort as routes # noqa: E402 - -_SAMPLE_TOML = """\ -[runtime] -enabled = true -default_provider = "alpha" - -[[providers]] -# 主 provider 注释 -id = "alpha" # 行尾注释 -protocol = "openai" -base_url = "https://a.example/v1" -api_key_env = "ALPHA_KEY" -default_model = "alpha-1" -models = ["alpha-1"] - -[[providers]] -id = 'beta' -protocol = "openai" -base_url = "https://b.example/v1" -api_key_env = "BETA_KEY" -default_model = "beta-1" -models = ["beta-1"] -reasoning_effort = "low" # 既有档位注释 - -[pricing.models."alpha-1"] -input_per_mtok = 0.14 -""" - - -def _patch_audit_noop(monkeypatch): +from pydantic import ValidationError + +from quickquip.app.web.routes import llm_effort as routes +from quickquip.llm.config_document import ConfigError + + +@pytest.fixture +def configured(config_service, monkeypatch): + draft = config_service.read() + draft["document"]["providers"] = [ + { + "id": "p", + "protocol": "openai", + "base_url": "https://example.com/v1", + "api_key_env": "API_KEY", + "default_model": "m", + "models": [{"model": "m"}], + } + ] + config_service.replace(draft["document"], draft["revision"]) monkeypatch.setattr(routes.audit_logger, "log", lambda *a, **k: None) + return config_service -def _write_sample(monkeypatch, tmp_path, content: str = _SAMPLE_TOML): - path = tmp_path / "config" / "llm.toml" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - monkeypatch.setattr(routes, "_LLM_TOML", path) - _patch_audit_noop(monkeypatch) - return path - - -def _put(provider_id: str, effort: str): - return routes.put_llm_effort(provider_id, routes.EffortBody(effort=effort), object()) - - -def test_get_llm_effort_projects_providers(monkeypatch, tmp_path): - """GET 解析 llm.toml:各 provider 投影 id/default_model/reasoning_effort,缺省为 ""。""" - _write_sample(monkeypatch, tmp_path) - - payload = routes.get_llm_effort() - assert payload == { - "providers": [ - {"id": "alpha", "default_model": "alpha-1", "reasoning_effort": ""}, - {"id": "beta", "default_model": "beta-1", "reasoning_effort": "low"}, - ] - } - - -def test_get_llm_effort_missing_file_404(monkeypatch, tmp_path): - monkeypatch.setattr(routes, "_LLM_TOML", tmp_path / "config" / "llm.toml") - - with pytest.raises(HTTPException) as exc: - routes.get_llm_effort() - assert exc.value.status_code == 404 - - -def test_put_inserts_effort_after_id_line(monkeypatch, tmp_path): - """插入路径:无既有档位行时在 id 行后插入,注释与其余块原样保留。""" - path = _write_sample(monkeypatch, tmp_path) - - result = _put("alpha", "high") - assert result == {"ok": True, "effect": "manual_reload"} - - content = path.read_text(encoding="utf-8") - lines = content.split("\n") - id_line = lines.index('id = "alpha" # 行尾注释') - assert lines[id_line + 1] == 'reasoning_effort = "high"' - # 注释与其余块原样保留 - assert "# 主 provider 注释" in content - assert 'reasoning_effort = "low" # 既有档位注释' in content - assert "[pricing.models.\"alpha-1\"]" in content - efforts = {p["id"]: p["reasoning_effort"] for p in routes.get_llm_effort()["providers"]} - assert efforts == {"alpha": "high", "beta": "low"} - - -def test_put_replaces_existing_effort_line(monkeypatch, tmp_path): - """替换路径:既有档位行原位置改写,行尾注释保留。""" - path = _write_sample(monkeypatch, tmp_path) - - result = _put("beta", "max") - assert result["ok"] is True +def test_read_draft_effort_and_revision(configured): + result = routes.get_llm_effort() + assert result["revision"] == 2 + assert result["providers"] == [{"id": "p", "default_model": "m", "reasoning_effort": None}] - content = path.read_text(encoding="utf-8") - assert 'reasoning_effort = "max" # 既有档位注释' in content - assert 'reasoning_effort = "low"' not in content - assert content.count("reasoning_effort") == 1 - efforts = {p["id"]: p["reasoning_effort"] for p in routes.get_llm_effort()["providers"]} - assert efforts == {"alpha": "", "beta": "max"} - -def test_put_deletes_effort_line_on_empty(monkeypatch, tmp_path): - """删除路径:空串删除既有档位行(回模型默认档)。""" - path = _write_sample(monkeypatch, tmp_path) - - result = _put("beta", "") - assert result["ok"] is True - - content = path.read_text(encoding="utf-8") - assert "reasoning_effort" not in content - assert "id = 'beta'" in content - efforts = {p["id"]: p["reasoning_effort"] for p in routes.get_llm_effort()["providers"]} - assert efforts == {"alpha": "", "beta": ""} - - -def test_put_rejects_invalid_effort(monkeypatch, tmp_path): - """非法档位在请求模型层拒绝(HTTP 层即 422),文件不动。""" - path = _write_sample(monkeypatch, tmp_path) - original = path.read_text(encoding="utf-8") - - for bogus in ("turbo", "HIGH", " "): - with pytest.raises(ValidationError): - routes.EffortBody(effort=bogus) - # effort 必传:空 body 不再有隐性删除语义 - with pytest.raises(ValidationError): - routes.EffortBody() - assert path.read_text(encoding="utf-8") == original - - -def test_put_writes_audit_with_before_after(monkeypatch, tmp_path): - """成功 PUT 记审计:action/target 与 provider 档位 before/after。""" - _write_sample(monkeypatch, tmp_path) - records: list[dict] = [] - monkeypatch.setattr( - routes.audit_logger, "log", - lambda request, **kwargs: records.append(kwargs), +def test_save_effort_changes_draft_only(configured): + result = routes.put_llm_effort( + "p", routes.EffortBody(effort="high", expected_draft_revision=2), object() ) - - _put("beta", "max") - - assert len(records) == 1 - record = records[0] - assert record["action"] == "update" - assert record["target_type"] == "config" - assert record["target_id"] == "llm" - assert record["summary_before"] == {"provider": "beta", "reasoning_effort": "low"} - assert record["summary_after"] == {"provider": "beta", "reasoning_effort": "max"} - - -def test_put_failure_paths_write_no_audit(monkeypatch, tmp_path): - """失败路径(404 provider 不存在 / 400 多行字符串 fail-closed)不落审计。""" - records: list[dict] = [] - - def _spy(): - # _write_sample 内置 noop 审计,每次重写样本后需重新装 spy - monkeypatch.setattr( - routes.audit_logger, "log", - lambda request, **kwargs: records.append(kwargs), + assert result["effect"] == "saved_draft" + assert configured.read()["document"]["providers"][0]["parameters"]["reasoning_effort"] == "high" + assert configured.read("published")["revision"] == 1 + with pytest.raises(ConfigError) as exc: + routes.put_llm_effort( + "p", routes.EffortBody(effort="low", expected_draft_revision=2), object() ) + assert exc.value.code == "revision_conflict" - _write_sample(monkeypatch, tmp_path) - _spy() - with pytest.raises(HTTPException): - _put("gamma", "high") - content = _SAMPLE_TOML.replace( - 'models = ["alpha-1"]', - 'models = ["alpha-1"]\nstyle_overrides = """\n多行风格段\n"""', - 1, +def test_null_inherits_and_empty_is_explicit(configured): + result = routes.put_llm_effort( + "p", routes.EffortBody(effort="", expected_draft_revision=2), object() ) - _write_sample(monkeypatch, tmp_path, content) - _spy() - with pytest.raises(HTTPException): - _put("alpha", "high") - - assert records == [] - - -def test_put_unknown_provider_404(monkeypatch, tmp_path): - """目标 provider 不存在:404 且文件不动。""" - path = _write_sample(monkeypatch, tmp_path) - original = path.read_text(encoding="utf-8") - - with pytest.raises(HTTPException) as exc: - _put("gamma", "high") - assert exc.value.status_code == 404 - assert path.read_text(encoding="utf-8") == original - - -def test_put_multiline_string_block_400(monkeypatch, tmp_path): - """目标块含多行字符串:fail-closed 400,文件不动。""" - content = _SAMPLE_TOML.replace( - 'models = ["alpha-1"]', - 'models = ["alpha-1"]\nstyle_overrides = """\n多行风格段\n"""', - 1, + assert result["document"]["providers"][0]["parameters"]["reasoning_effort"] == "" + result = routes.put_llm_effort( + "p", routes.EffortBody(effort=None, expected_draft_revision=3), object() ) - path = _write_sample(monkeypatch, tmp_path, content) - original = path.read_text(encoding="utf-8") + assert result["document"]["providers"][0]["parameters"]["reasoning_effort"] is None - with pytest.raises(HTTPException) as exc: - _put("alpha", "high") - assert exc.value.status_code == 400 - assert "多行字符串" in exc.value.detail - assert path.read_text(encoding="utf-8") == original - -def test_verify_edit_rejects_semantic_drift(): - """手术后语义校验:目标档位与意图不符或其余 provider 档位漂移 → 500。""" - before = [ - {"id": "alpha", "default_model": "a", "reasoning_effort": ""}, - {"id": "beta", "default_model": "b", "reasoning_effort": "low"}, - ] - # 目标档位与意图不符 - drifted_target = [dict(before[0], reasoning_effort="max"), before[1]] - with pytest.raises(HTTPException) as exc: - routes._verify_edit(before, drifted_target, "alpha", "high") - assert exc.value.status_code == 500 - assert "目标 provider" in exc.value.detail - # 其余 provider 档位漂移(目标先符合意图,确保命中其他 provider 分支) - drifted_other = [ - dict(before[0], reasoning_effort="high"), - dict(before[1], reasoning_effort="max"), - ] - with pytest.raises(HTTPException) as exc: - routes._verify_edit(before, drifted_other, "alpha", "high") - assert exc.value.status_code == 500 - assert "其他 provider" in exc.value.detail - # provider 序列变化 - with pytest.raises(HTTPException) as exc: - routes._verify_edit(before, before[:1], "alpha", "high") - assert exc.value.status_code == 500 - # 正常结果通过 - ok_after = [dict(before[0], reasoning_effort="high"), before[1]] - routes._verify_edit(before, ok_after, "alpha", "high") +def test_validation_and_unknown_provider_do_not_save(configured): + with pytest.raises(ValidationError): + routes.EffortBody(effort="turbo", expected_draft_revision=2) + with pytest.raises(ValidationError): + routes.EffortBody(effort="high") + with pytest.raises(ConfigError) as exc: + routes.put_llm_effort( + "missing", routes.EffortBody(effort="high", expected_draft_revision=2), object() + ) + assert exc.value.code == "not_found" + assert configured.read()["revision"] == 2 diff --git a/tests/unit/web/test_mcp_dashboard_routes.py b/tests/unit/web/test_mcp_dashboard_routes.py index 8c16db62..8a2b026a 100644 --- a/tests/unit/web/test_mcp_dashboard_routes.py +++ b/tests/unit/web/test_mcp_dashboard_routes.py @@ -107,39 +107,16 @@ def test_dashboard_status_file_branch_defaults_missing_era_fields(tmp_path, monk assert server["negotiated_protocol_version"] == "" -def test_dashboard_runtime_branch_exposes_era_tag(tmp_path, monkeypatch): - from types import SimpleNamespace - - from quickquip.llm.mcp.types import MCPServerStatus - +def test_dashboard_never_uses_web_local_runtime(tmp_path, monkeypatch, config_service): import quickquip.app.message_pipeline as message_pipeline monkeypatch.setattr(mcp_dashboard, "_STATUS_PATH", tmp_path / "missing.json") - - status = MCPServerStatus( - id="runtime-server", - transport="http", - enabled=True, - connected=True, - tool_count=3, - negotiation="modern", - era="modern", - ) - - manager = SimpleNamespace( - get_statuses=lambda: [status], - bindings={}, - ) - monkeypatch.setattr(message_pipeline, "_ensure_llm_bindings", lambda: None) monkeypatch.setattr( - message_pipeline, "get_llm_service", lambda: SimpleNamespace(mcp_manager=manager) + message_pipeline, + "get_llm_service", + lambda: (_ for _ in ()).throw(AssertionError("Web runtime consulted")), ) - - server = mcp_dashboard.get_mcp_dashboard()["servers"][0] - - assert server["id"] == "runtime-server" - assert server["runtime_available"] is True - assert server["era_tag"] == "/modern" + assert mcp_dashboard.get_mcp_dashboard() == {"servers": []} def test_dashboard_config_only_branch_era_tag_empty(tmp_path, monkeypatch): @@ -159,10 +136,10 @@ def test_dashboard_config_only_branch_era_tag_empty(tmp_path, monkeypatch): id="cfg-server", transport="stdio", enabled=True, negotiation="modern" ) - def _fake_load(_path): + def _fake_load(document, **kwargs): return SimpleNamespace(load_error=None, mcp=SimpleNamespace(servers=[server_entry])) - monkeypatch.setattr(mcp_dashboard, "load_llm_config", _fake_load) + monkeypatch.setattr(mcp_dashboard, "compile_document", _fake_load) server = mcp_dashboard.get_mcp_dashboard()["servers"][0] diff --git a/tests/unit/web/test_skills_routes.py b/tests/unit/web/test_skills_routes.py index ef73ec0e..812c9d45 100644 --- a/tests/unit/web/test_skills_routes.py +++ b/tests/unit/web/test_skills_routes.py @@ -934,27 +934,18 @@ def test_github_inspect_invalid_url_422(catalog_dir, stash_dir): # ── 生效目录解析 ──────────────────────────────────────────────────── -def test_effective_catalog_dir_reads_llm_toml(monkeypatch, tmp_path): +def test_effective_catalog_dir_reads_published_database(config_service, tmp_path): custom = tmp_path / "custom-skills" - config = tmp_path / "llm.toml" - config.write_text(f'[skills]\ncatalog_dir = "{custom}"\n', encoding="utf-8") - monkeypatch.setattr(skills, "_LLM_CONFIG_PATH", config) - + draft = config_service.read() + draft["document"]["settings"]["skills"]["catalog_dir"] = str(custom) + config_service.replace(draft["document"], 1) + assert _effective_catalog_dir() == SKILLS_DIR + submitted = config_service.apply(2, "skills") + claimed = config_service.tasks.claim("bot") + assert claimed["id"] == submitted["id"] + config_service.publish(claimed["id"], claimed["claim_token"]) assert _effective_catalog_dir() == custom -def test_effective_catalog_dir_fallbacks(monkeypatch, tmp_path): - # 文件缺失 - monkeypatch.setattr(skills, "_LLM_CONFIG_PATH", tmp_path / "missing.toml") - assert _effective_catalog_dir() == SKILLS_DIR - # 键缺失 - config = tmp_path / "llm.toml" - config.write_text("[runtime]\nenabled = true\n", encoding="utf-8") - monkeypatch.setattr(skills, "_LLM_CONFIG_PATH", config) - assert _effective_catalog_dir() == SKILLS_DIR - # 非字符串 - config.write_text("[skills]\ncatalog_dir = 123\n", encoding="utf-8") - assert _effective_catalog_dir() == SKILLS_DIR - # TOML 解析失败 - config.write_text("not [valid toml", encoding="utf-8") +def test_effective_catalog_dir_defaults(config_service): assert _effective_catalog_dir() == SKILLS_DIR From 23d033549368c704bd952747a0fd2541bae6347d Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:39:30 +0800 Subject: [PATCH 03/48] feat(web-admin): add structured LLM config editor Add provider/model editors, parameter inheritance, routing, pricing, behavior settings and separate draft, publish and bot status views. Preserve edits across saves and conflicts, bind imports to previews and recover pending operations across sessions. --- frontend/package.json | 2 +- frontend/src/api/groupSettings.ts | 4 +- frontend/src/api/llmConfiguration.ts | 35 + frontend/src/api/llmEffort.ts | 23 - .../diagnostics/DiagRuntimeOpsCard.vue | 2 +- .../components/llm-config/CascadeEditor.vue | 203 ++++++ .../src/components/llm-config/EpochPanel.vue | 129 ++++ .../components/llm-config/ExchangePanel.vue | 322 +++++++++ .../components/llm-config/KeyValueEditor.vue | 116 ++++ .../src/components/llm-config/McpPanel.vue | 217 +++++++ .../components/llm-config/ParamsEditor.vue | 288 +++++++++ .../components/llm-config/PricingPanel.vue | 231 +++++++ .../components/llm-config/ProviderPicker.vue | 60 ++ .../components/llm-config/ProvidersPanel.vue | 612 ++++++++++++++++++ .../components/llm-config/PublishPanel.vue | 584 +++++++++++++++++ .../components/llm-config/ReportsPanel.vue | 37 ++ .../src/components/llm-config/RoutesPanel.vue | 144 +++++ .../components/llm-config/SettingField.vue | 200 ++++++ .../components/llm-config/SettingsForm.vue | 98 +++ .../components/llm-config/SettingsGroup.vue | 41 ++ .../llm-config/StringListEditor.vue | 107 +++ .../src/components/llm-config/StylesPanel.vue | 179 +++++ frontend/src/components/ui/UiIcon.vue | 10 +- frontend/src/components/ui/UiStatCard.vue | 3 +- .../src/composables/useGroupOverrideDraft.ts | 7 +- frontend/src/composables/useLlmConfigStore.ts | 337 ++++++++++ frontend/src/config/nav.ts | 2 + frontend/src/lib/llmConfig.ts | 444 +++++++++++++ frontend/src/views/AuditView.vue | 2 +- frontend/src/views/ConfigView.vue | 114 +--- frontend/src/views/ConversationsView.vue | 4 +- frontend/src/views/GroupSettingsView.vue | 18 +- frontend/src/views/GroupsView.vue | 8 +- frontend/src/views/LlmConfigView.vue | 447 +++++++++++++ frontend/src/views/LlmUsageView.vue | 20 +- frontend/src/views/LogsTraceView.vue | 2 +- frontend/src/views/McpDashboardView.vue | 6 +- frontend/src/views/NiuNiuView.vue | 2 +- frontend/src/views/PersonasView.vue | 2 +- frontend/src/views/QuotesView.vue | 2 +- frontend/src/views/ScheduledMessagesView.vue | 2 +- frontend/src/views/SummaryView.vue | 8 +- frontend/src/views/TiebaView.vue | 4 +- frontend/src/views/WordcloudView.vue | 2 +- frontend/tests/editor-races.test.cjs | 38 +- frontend/tests/llm-config.test.cjs | 443 +++++++++++++ 46 files changed, 5347 insertions(+), 214 deletions(-) create mode 100644 frontend/src/api/llmConfiguration.ts delete mode 100644 frontend/src/api/llmEffort.ts create mode 100644 frontend/src/components/llm-config/CascadeEditor.vue create mode 100644 frontend/src/components/llm-config/EpochPanel.vue create mode 100644 frontend/src/components/llm-config/ExchangePanel.vue create mode 100644 frontend/src/components/llm-config/KeyValueEditor.vue create mode 100644 frontend/src/components/llm-config/McpPanel.vue create mode 100644 frontend/src/components/llm-config/ParamsEditor.vue create mode 100644 frontend/src/components/llm-config/PricingPanel.vue create mode 100644 frontend/src/components/llm-config/ProviderPicker.vue create mode 100644 frontend/src/components/llm-config/ProvidersPanel.vue create mode 100644 frontend/src/components/llm-config/PublishPanel.vue create mode 100644 frontend/src/components/llm-config/ReportsPanel.vue create mode 100644 frontend/src/components/llm-config/RoutesPanel.vue create mode 100644 frontend/src/components/llm-config/SettingField.vue create mode 100644 frontend/src/components/llm-config/SettingsForm.vue create mode 100644 frontend/src/components/llm-config/SettingsGroup.vue create mode 100644 frontend/src/components/llm-config/StringListEditor.vue create mode 100644 frontend/src/components/llm-config/StylesPanel.vue create mode 100644 frontend/src/composables/useLlmConfigStore.ts create mode 100644 frontend/src/lib/llmConfig.ts create mode 100644 frontend/src/views/LlmConfigView.vue create mode 100644 frontend/tests/llm-config.test.cjs diff --git a/frontend/package.json b/frontend/package.json index 6a9affdc..7d073899 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "type-check": "vue-tsc --noEmit", + "type-check": "vue-tsc --noEmit -p tsconfig.app.json", "test": "node --test tests/*.test.cjs" }, "dependencies": { diff --git a/frontend/src/api/groupSettings.ts b/frontend/src/api/groupSettings.ts index 2b012d2d..1b255c8c 100644 --- a/frontend/src/api/groupSettings.ts +++ b/frontend/src/api/groupSettings.ts @@ -61,7 +61,7 @@ export interface GroupSettingsProviderOption { id: string default_model: string | null models: string[] - /** provider 配置档(llm.toml reasoning_effort);空串/缺省 = 模型默认档 */ + /** provider 配置档(LLM 配置 reasoning_effort);空串/缺省 = 模型默认档 */ reasoning_effort?: string } @@ -73,7 +73,7 @@ export interface GroupSettingsPersonaOption { /** * GET /api/group-settings/options 的 defaults(全局默认配置投影)。 - * llm.toml 加载失败时后端返回空对象,故全部字段可选。 + * LLM 配置 加载失败时后端返回空对象,故全部字段可选。 */ export interface GroupSettingsDefaults { enabled?: boolean diff --git a/frontend/src/api/llmConfiguration.ts b/frontend/src/api/llmConfiguration.ts new file mode 100644 index 00000000..73ddafa4 --- /dev/null +++ b/frontend/src/api/llmConfiguration.ts @@ -0,0 +1,35 @@ +import { request } from './index' +import type { JsonObject, JsonValue, FieldMetadata } from '../lib/llmConfig' +const ROOT = '/api/llm-configuration' +const post = (path: string, body: unknown) => request(ROOT + path, { method: 'POST', body: JSON.stringify(body) }) +export interface ConfigEnvelope { revision: number; document: JsonObject } +export interface ConfigChange { field_path: string; before: JsonValue; after: JsonValue } +export interface ConfigStatus { + schema_version: number; draft_revision: number; published_revision: number + bot: { boot_id: string; loaded_revision: number; heartbeat_at: number | string; observed_at: number | string; stale: boolean; components: JsonObject; application_version?: string } | null +} +export interface ConfigOperation { + id: string; kind: string; target_revision: number; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; stage: string; created_at: number | string; result: JsonValue; error: JsonValue +} +export interface ConfigValidation { valid: boolean; warnings: JsonValue[]; dependencies: JsonValue[] } +export interface ImportPreview extends ConfigValidation { document: JsonObject; changes: ConfigChange[]; expected_draft_revision: number } +export interface ConfigMetadata { fields?: Record; settings?: Record>; protocols?: string[]; route_names?: string[]; parameters?: Record; [key: string]: unknown } +export const fetchLlmConfiguration = (source = 'draft') => request(`${ROOT}?source=${encodeURIComponent(source)}`) +export const fetchLlmConfigMetadata = () => request(`${ROOT}/metadata`) +export const fetchLlmConfigStatus = () => request(`${ROOT}/status`) +export const saveLlmConfiguration = (document: JsonObject, revision: number) => request(ROOT, { method: 'PUT', body: JSON.stringify({ document, expected_draft_revision: revision }) }) +export const validateLlmConfiguration = (document: JsonObject) => post('/validate', { document }) +export const previewLlmConfigImport = (document: JsonObject, revision: number) => post('/import/preview', { document, expected_draft_revision: revision }) +export const importLlmConfiguration = (document: JsonObject, revision: number) => post('/import', { document, expected_draft_revision: revision }) +export const exportLlmConfiguration = (source: string) => request(`${ROOT}/export?source=${encodeURIComponent(source)}`) +export const diffLlmConfiguration = () => request<{ changes: ConfigChange[]; components: JsonValue[] }>(`${ROOT}/diff?source=draft&target=published`) +export const applyLlmConfiguration = (revision: number, requestId: string) => post('/apply', { expected_draft_revision: revision, request_id: requestId }) +export const fetchLlmConfigOperation = (id: string) => request(`${ROOT}/operations/${encodeURIComponent(id)}`) +export const cancelLlmConfigOperation = (id: string) => post(`/operations/${encodeURIComponent(id)}/cancel`, {}) +export const fetchLlmConfigHistory = () => request<{ snapshots: { revision: number; created_at: string | number }[] }>(`${ROOT}/history`) +export const restoreLlmConfigSnapshot = (revision: number, expected: number) => post(`/history/${revision}/restore`, { expected_draft_revision: expected }) +export const fetchLlmEffectiveParameters = (providerId: string, model: string) => request<{ parameters: Record }>(`${ROOT}/providers/${encodeURIComponent(providerId)}/effective?model=${encodeURIComponent(model)}`) +export const fetchLlmConfigEnvironment = (source = 'draft') => request<{ variables: { name: string; configured: boolean | null }[]; available: boolean; stale: boolean }>(`${ROOT}/environment?source=${encodeURIComponent(source)}`) +export const probeLlmConfiguration = (source: string, providerId: string, model: string, requestId: string) => post('/probe', { source, provider_id: providerId, model, request_id: requestId }) + +export const fetchLlmConfigOperations = () => request<{ operations: ConfigOperation[] }>(`${ROOT}/operations?limit=20`) diff --git a/frontend/src/api/llmEffort.ts b/frontend/src/api/llmEffort.ts deleted file mode 100644 index 2f46cdc1..00000000 --- a/frontend/src/api/llmEffort.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { request } from './index' - -/** GET /api/llm-effort 的 provider 投影:reasoning_effort 空串 = 模型默认档(不发送思考参数) */ -export interface LlmEffortProvider { - id: string - default_model: string - reasoning_effort: string -} - -export async function fetchLlmEffort() { - return request<{ providers: LlmEffortProvider[] }>('/api/llm-effort') -} - -/** effort 传空串即删除该 provider 的档位行(回模型默认档) */ -export async function saveLlmEffort(providerId: string, effort: string) { - return request<{ ok: boolean; effect?: string }>( - `/api/llm-effort/${encodeURIComponent(providerId)}`, - { - method: 'PUT', - body: JSON.stringify({ effort }), - }, - ) -} diff --git a/frontend/src/components/diagnostics/DiagRuntimeOpsCard.vue b/frontend/src/components/diagnostics/DiagRuntimeOpsCard.vue index 2a2a25a5..85ec8532 100644 --- a/frontend/src/components/diagnostics/DiagRuntimeOpsCard.vue +++ b/frontend/src/components/diagnostics/DiagRuntimeOpsCard.vue @@ -197,7 +197,7 @@ async function runRuntimeAction(action: RuntimeActionKind) { else if (action === 'personas') data = await reloadPersonas() else data = await reloadRules() if (data?.load_error || data?.error) { - runtimeError.value = data.load_error || data.error + runtimeError.value = data.load_error || data.error || null toast('操作完成但存在错误', 'error') } else { toast(data?.queued ? '操作已入队' : '操作已完成') diff --git a/frontend/src/components/llm-config/CascadeEditor.vue b/frontend/src/components/llm-config/CascadeEditor.vue new file mode 100644 index 00000000..a591b438 --- /dev/null +++ b/frontend/src/components/llm-config/CascadeEditor.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/frontend/src/components/llm-config/EpochPanel.vue b/frontend/src/components/llm-config/EpochPanel.vue new file mode 100644 index 00000000..140c57af --- /dev/null +++ b/frontend/src/components/llm-config/EpochPanel.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/components/llm-config/ExchangePanel.vue b/frontend/src/components/llm-config/ExchangePanel.vue new file mode 100644 index 00000000..259da039 --- /dev/null +++ b/frontend/src/components/llm-config/ExchangePanel.vue @@ -0,0 +1,322 @@ +