feat(api): 支持 x-opencode-session 会话头与 Astrion User-Agent
- OpenCode Go/Zen 自 2026-09-05 起要求每个对话携带稳定的 x-opencode-session 头,用于会话亲和路由与 prompt 缓存优化 - 个人空间「模型与思考」新增「外部会话标识」开关(默认关闭,opt-in) - 开启后按对话惰性生成随机 ID(uuid4 hex)并持久化到对话 metadata,深压缩后重置 - 覆盖主对话/传统与多智能体子智能体/三个审核智能体(一次性 ID)/标题生成(复用主对话 ID) - 仅对 opencode.ai 域名下发,不向其他 provider 泄露对话标识 - 所有对外模型请求统一携带 User-Agent: Astrion/1.0(新增 config/version.py)
This commit is contained in:
parent
9a17b38153
commit
c80bc4fbb4
8
config/version.py
Normal file
8
config/version.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""应用版本号。
|
||||||
|
|
||||||
|
用于对外 HTTP 请求的 User-Agent 标识(如 ``Astrion/1.0``)。
|
||||||
|
OpenCode 等供应商要求客户端工具以「产品名/版本号」格式自标识,
|
||||||
|
禁止使用宽泛 UA(如 python-httpx/x.y)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
APP_VERSION = "1.0"
|
||||||
@ -102,6 +102,10 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
# 初始化组件
|
# 初始化组件
|
||||||
self.api_client = APIClient(thinking_mode=self.thinking_mode)
|
self.api_client = APIClient(thinking_mode=self.thinking_mode)
|
||||||
self.api_client.project_path = project_path
|
self.api_client.project_path = project_path
|
||||||
|
# 外部会话标识(x-opencode-session):请求时按当前对话惰性解析,
|
||||||
|
# 仅个人空间开关开启且端点为 opencode.ai 时附加;对话 ID 在请求时
|
||||||
|
# 从 context_manager 读取(构造时对话尚未创建/加载)。
|
||||||
|
self.api_client.extra_headers_resolver = self._resolve_external_session_headers
|
||||||
self.model_key = get_default_model_key()
|
self.model_key = get_default_model_key()
|
||||||
self.model_profile = get_model_profile(self.model_key)
|
self.model_profile = get_model_profile(self.model_key)
|
||||||
self.apply_model_profile(self.model_profile)
|
self.apply_model_profile(self.model_profile)
|
||||||
@ -245,6 +249,30 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
"save": self.save_conversation_command
|
"save": self.save_conversation_command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _resolve_external_session_headers(self, base_url: Optional[str]) -> Dict[str, str]:
|
||||||
|
"""APIClient extra_headers_resolver:为当前对话解析 x-opencode-session 头。
|
||||||
|
|
||||||
|
请求时惰性执行:个人空间开关开启且端点为 opencode.ai 时,返回该对话
|
||||||
|
的稳定 session ID(首次请求生成并存入对话 metadata,深压缩后重置)。
|
||||||
|
任何异常返回空 dict,不影响主请求。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.external_session import resolve_conversation_headers
|
||||||
|
|
||||||
|
cm = getattr(self, "context_manager", None)
|
||||||
|
conversation_id = getattr(cm, "current_conversation_id", None) if cm else None
|
||||||
|
if not conversation_id:
|
||||||
|
return {}
|
||||||
|
manager = cm._get_conversation_manager_for_id(conversation_id)
|
||||||
|
return resolve_conversation_headers(
|
||||||
|
base_url,
|
||||||
|
conversation_id,
|
||||||
|
manager=manager,
|
||||||
|
base_dir=getattr(self, "data_dir", None),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
def _apply_container_session(self, session: Optional["ContainerHandle"]):
|
def _apply_container_session(self, session: Optional["ContainerHandle"]):
|
||||||
self.container_session = session
|
self.container_session = session
|
||||||
if session and session.mode == "docker":
|
if session and session.mode == "docker":
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import LOGS_DIR
|
from config import LOGS_DIR
|
||||||
|
from modules.external_session import build_user_agent, resolve_ephemeral_headers
|
||||||
from modules.review_agent_config import resolve_review_agent_config
|
from modules.review_agent_config import resolve_review_agent_config
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
|
|
||||||
@ -104,7 +105,9 @@ class ApprovalAgent:
|
|||||||
_flush_trace(out)
|
_flush_trace(out)
|
||||||
return out
|
return out
|
||||||
endpoint = f"{url.rstrip('/')}/chat/completions"
|
endpoint = f"{url.rstrip('/')}/chat/completions"
|
||||||
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
# 以「产品名/版本号」自标识;审核调用无对话连续性,每次审核生成一次性 session ID
|
||||||
|
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": build_user_agent()}
|
||||||
|
headers.update(resolve_ephemeral_headers(url))
|
||||||
max_rounds = int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)
|
max_rounds = int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)
|
||||||
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||||
extra_params = dict(self.cfg.get("extra_params") or {})
|
extra_params = dict(self.cfg.get("extra_params") or {})
|
||||||
|
|||||||
119
modules/external_session.py
Normal file
119
modules/external_session.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""外部会话标识(x-opencode-session)管理。
|
||||||
|
|
||||||
|
OpenCode Go/Zen 自 2026-09-05 起要求发往其端点的请求携带
|
||||||
|
``x-opencode-session`` 请求头:同一对话使用稳定 ID,供其做会话亲和
|
||||||
|
路由与 prompt 缓存优化(https://opencode.ai/docs/go/)。
|
||||||
|
|
||||||
|
设计约定:
|
||||||
|
- 开关:个人空间「模型与思考」-> ``external_session_header``,默认关闭(opt-in)。
|
||||||
|
- ID 取值:随机 uuid4 hex,不复用 conversation_id(避免向 vendor 暴露本地标识)。
|
||||||
|
- 生命周期:对话首次请求时惰性生成并存入对话 metadata;深压缩完成后重置
|
||||||
|
(压缩重写上下文后,旧 session 的缓存亲和已失去意义,语义上等同新 session)。
|
||||||
|
- 下发范围:仅对 opencode.ai 域名下发,不向其他 provider 泄露对话标识。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from config.version import APP_VERSION
|
||||||
|
|
||||||
|
OPENCODE_HOST_MARKER = "opencode.ai"
|
||||||
|
SESSION_METADATA_KEY = "external_session_id"
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_agent() -> str:
|
||||||
|
"""对外请求的 User-Agent(OpenCode 要求工具自标识,禁止宽泛 UA)。"""
|
||||||
|
return f"Astrion/{APP_VERSION}"
|
||||||
|
|
||||||
|
|
||||||
|
def new_external_session_id() -> str:
|
||||||
|
"""生成新的随机外部会话 ID。"""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def is_opencode_endpoint(base_url: Optional[str]) -> bool:
|
||||||
|
"""判定 base_url 是否指向 opencode.ai(Go 与 Zen 同域名)。"""
|
||||||
|
return bool(base_url) and OPENCODE_HOST_MARKER in str(base_url).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def external_session_header_enabled(base_dir=None) -> bool:
|
||||||
|
"""读取个人空间开关 external_session_header(默认关闭)。
|
||||||
|
|
||||||
|
无工作区上下文的调用方(如审核智能体)使用全局 DATA_DIR;
|
||||||
|
有工作区上下文的调用方可传入 workspace.data_dir。
|
||||||
|
任何异常均按关闭处理,不影响请求主链路。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.personalization_manager import load_personalization_config
|
||||||
|
|
||||||
|
if base_dir is None:
|
||||||
|
from config import DATA_DIR
|
||||||
|
|
||||||
|
base_dir = DATA_DIR
|
||||||
|
config = load_personalization_config(base_dir)
|
||||||
|
return bool(config.get("external_session_header"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_conversation_session_id(conversation_id: str, manager) -> Optional[str]:
|
||||||
|
"""读取对话 metadata 中的 external_session_id;缺失则生成并写回(惰性创建)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: 对话 ID
|
||||||
|
manager: ConversationManager 实例(需具备 load_conversation /
|
||||||
|
update_conversation_metadata 能力)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
session ID 字符串;读取或写入失败时返回 None(不影响请求主链路)。
|
||||||
|
"""
|
||||||
|
if not conversation_id or manager is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = manager.load_conversation(conversation_id) or {}
|
||||||
|
metadata = data.get("metadata") or {}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
existing = metadata.get(SESSION_METADATA_KEY)
|
||||||
|
if isinstance(existing, str) and existing.strip():
|
||||||
|
return existing.strip()
|
||||||
|
new_id = new_external_session_id()
|
||||||
|
try:
|
||||||
|
manager.update_conversation_metadata(conversation_id, {SESSION_METADATA_KEY: new_id})
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return new_id
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_conversation_headers(
|
||||||
|
base_url: Optional[str],
|
||||||
|
conversation_id: Optional[str],
|
||||||
|
manager=None,
|
||||||
|
base_dir=None,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""主对话/标题生成用:开关 + 域名判定后返回该对话的稳定 session 头。
|
||||||
|
|
||||||
|
不满足条件时返回空 dict。
|
||||||
|
"""
|
||||||
|
if not conversation_id:
|
||||||
|
return {}
|
||||||
|
if not external_session_header_enabled(base_dir):
|
||||||
|
return {}
|
||||||
|
if not is_opencode_endpoint(base_url):
|
||||||
|
return {}
|
||||||
|
sid = get_or_create_conversation_session_id(conversation_id, manager)
|
||||||
|
return {"x-opencode-session": sid} if sid else {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ephemeral_headers(base_url: Optional[str], base_dir=None) -> Dict[str, str]:
|
||||||
|
"""无对话连续性的调用(审核智能体等)用:每次生成一次性 session 头。
|
||||||
|
|
||||||
|
不满足条件时返回空 dict。
|
||||||
|
"""
|
||||||
|
if not external_session_header_enabled(base_dir):
|
||||||
|
return {}
|
||||||
|
if not is_opencode_endpoint(base_url):
|
||||||
|
return {}
|
||||||
|
return {"x-opencode-session": new_external_session_id()}
|
||||||
@ -23,6 +23,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import PROMPTS_DIR, LOGS_DIR
|
from config import PROMPTS_DIR, LOGS_DIR
|
||||||
|
from modules.external_session import build_user_agent, resolve_ephemeral_headers
|
||||||
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
|
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
|
||||||
from modules.review_agent_config import resolve_review_agent_config
|
from modules.review_agent_config import resolve_review_agent_config
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
@ -188,7 +189,9 @@ class GoalReviewAgent:
|
|||||||
_flush_trace(out)
|
_flush_trace(out)
|
||||||
return out
|
return out
|
||||||
endpoint = f"{url.rstrip('/')}/chat/completions"
|
endpoint = f"{url.rstrip('/')}/chat/completions"
|
||||||
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
# 以「产品名/版本号」自标识;审核调用无对话连续性,每次审核生成一次性 session ID
|
||||||
|
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": build_user_agent()}
|
||||||
|
headers.update(resolve_ephemeral_headers(url))
|
||||||
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||||
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||||
extra_params = dict(self.cfg.get("extra_params") or {})
|
extra_params = dict(self.cfg.get("extra_params") or {})
|
||||||
|
|||||||
@ -83,6 +83,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
|||||||
"default_work_mode": "plan", # 默认运行模式:plan / ask / execute
|
"default_work_mode": "plan", # 默认运行模式:plan / ask / execute
|
||||||
"auto_generate_title": True,
|
"auto_generate_title": True,
|
||||||
"title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型)
|
"title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型)
|
||||||
|
"external_session_header": False, # 向 opencode.ai 端点发送 x-opencode-session 头(默认关闭,opt-in)
|
||||||
"recent_conversations_prompt_enabled": False,
|
"recent_conversations_prompt_enabled": False,
|
||||||
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||||
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数:None-无上限 / >=5-该值
|
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数:None-无上限 / >=5-该值
|
||||||
@ -280,6 +281,9 @@ def sanitize_personalization_payload(
|
|||||||
)
|
)
|
||||||
base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"]))
|
base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"]))
|
||||||
base["title_model"] = str(data.get("title_model", base.get("title_model", "")) or "").strip()
|
base["title_model"] = str(data.get("title_model", base.get("title_model", "")) or "").strip()
|
||||||
|
base["external_session_header"] = bool(
|
||||||
|
data.get("external_session_header", base.get("external_session_header", False))
|
||||||
|
)
|
||||||
base["recent_conversations_prompt_enabled"] = bool(
|
base["recent_conversations_prompt_enabled"] = bool(
|
||||||
data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False))
|
data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False))
|
||||||
)
|
)
|
||||||
|
|||||||
@ -119,6 +119,9 @@ class SubAgentTask:
|
|||||||
self.max_turns = _max_turns_int if _max_turns_int > 0 else None
|
self.max_turns = _max_turns_int if _max_turns_int > 0 else None
|
||||||
self.current_context_tokens: int = 0
|
self.current_context_tokens: int = 0
|
||||||
self._compress_round: int = 0
|
self._compress_round: int = 0
|
||||||
|
# 外部会话标识(x-opencode-session):从已有对话文件恢复(重启/恢复场景),
|
||||||
|
# 无则首次请求时惰性生成;深度压缩后重置为新 ID。
|
||||||
|
self._external_session_id: Optional[str] = self._load_external_session_id()
|
||||||
|
|
||||||
self.messages: List[Dict[str, Any]] = [
|
self.messages: List[Dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
@ -764,6 +767,8 @@ class SubAgentTask:
|
|||||||
client.model_key = chosen_key
|
client.model_key = chosen_key
|
||||||
client.project_path = str(self.manager.project_path)
|
client.project_path = str(self.manager.project_path)
|
||||||
client.apply_profile(model_map[chosen_key])
|
client.apply_profile(model_map[chosen_key])
|
||||||
|
# 外部会话标识(x-opencode-session):随子对话生命周期稳定,压缩后重置
|
||||||
|
client.extra_headers_resolver = self._resolve_external_session_headers
|
||||||
return client, chosen_key
|
return client, chosen_key
|
||||||
|
|
||||||
async def _call_model(
|
async def _call_model(
|
||||||
@ -1044,6 +1049,43 @@ class SubAgentTask:
|
|||||||
# 兜底用内存中的
|
# 兜底用内存中的
|
||||||
return self.system_prompt
|
return self.system_prompt
|
||||||
|
|
||||||
|
def _load_external_session_id(self) -> Optional[str]:
|
||||||
|
"""从子对话文件恢复外部会话标识(重启/恢复场景);不存在返回 None。"""
|
||||||
|
try:
|
||||||
|
if self.conversation_file.exists():
|
||||||
|
data = json.loads(self.conversation_file.read_text(encoding="utf-8"))
|
||||||
|
value = data.get("external_session_id")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_external_session_headers(self, base_url: Optional[str]) -> Dict[str, str]:
|
||||||
|
"""APIClient extra_headers_resolver:为本子智能体对话解析 x-opencode-session 头。
|
||||||
|
|
||||||
|
个人空间开关开启且端点为 opencode.ai 时返回稳定 session ID;首次请求
|
||||||
|
惰性生成,随 _persist_conversation 落盘,深度压缩后重置。
|
||||||
|
任何异常返回空 dict,不影响主请求。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.external_session import (
|
||||||
|
external_session_header_enabled,
|
||||||
|
is_opencode_endpoint,
|
||||||
|
new_external_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_dir = getattr(self.manager, "data_dir", None)
|
||||||
|
if not external_session_header_enabled(base_dir):
|
||||||
|
return {}
|
||||||
|
if not is_opencode_endpoint(base_url):
|
||||||
|
return {}
|
||||||
|
if not self._external_session_id:
|
||||||
|
self._external_session_id = new_external_session_id()
|
||||||
|
return {"x-opencode-session": self._external_session_id}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
def _deep_compress_messages(self) -> bool:
|
def _deep_compress_messages(self) -> bool:
|
||||||
"""深度压缩:把旧消息总结成一条 system 消息,重建 system prompt。
|
"""深度压缩:把旧消息总结成一条 system 消息,重建 system prompt。
|
||||||
|
|
||||||
@ -1174,6 +1216,15 @@ class SubAgentTask:
|
|||||||
self.current_context_tokens = 0
|
self.current_context_tokens = 0
|
||||||
self.stats["current_context_tokens"] = 0
|
self.stats["current_context_tokens"] = 0
|
||||||
|
|
||||||
|
# 压缩重写上下文后旧 session 的缓存亲和已失效,重置外部会话标识
|
||||||
|
# (与主对话深压缩后重置 external_session_id 的语义一致)
|
||||||
|
try:
|
||||||
|
from modules.external_session import new_external_session_id
|
||||||
|
|
||||||
|
self._external_session_id = new_external_session_id()
|
||||||
|
except Exception:
|
||||||
|
self._external_session_id = None
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[SubAgentTask] task={self.task_id} 深度压缩完成: "
|
f"[SubAgentTask] task={self.task_id} 深度压缩完成: "
|
||||||
f"压缩 {len(old_messages)} 条消息,保留 {len(recent_messages)} 条,"
|
f"压缩 {len(old_messages)} 条消息,保留 {len(recent_messages)} 条,"
|
||||||
@ -1253,6 +1304,7 @@ class SubAgentTask:
|
|||||||
"summary": partial_summary,
|
"summary": partial_summary,
|
||||||
"messages": self.messages,
|
"messages": self.messages,
|
||||||
"stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)},
|
"stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)},
|
||||||
|
"external_session_id": self._external_session_id,
|
||||||
}
|
}
|
||||||
self.conversation_file.parent.mkdir(parents=True, exist_ok=True)
|
self.conversation_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conversation_json = json.dumps(conversation_data, ensure_ascii=False)
|
conversation_json = json.dumps(conversation_data, ensure_ascii=False)
|
||||||
@ -1280,6 +1332,9 @@ class SubAgentTask:
|
|||||||
if self.conversation_file.exists():
|
if self.conversation_file.exists():
|
||||||
data = json.loads(self.conversation_file.read_text(encoding="utf-8"))
|
data = json.loads(self.conversation_file.read_text(encoding="utf-8"))
|
||||||
self.messages = data.get("messages", [])
|
self.messages = data.get("messages", [])
|
||||||
|
restored_session = data.get("external_session_id")
|
||||||
|
if isinstance(restored_session, str) and restored_session.strip():
|
||||||
|
self._external_session_id = restored_session.strip()
|
||||||
ma_debug(
|
ma_debug(
|
||||||
"sub_agent_soft_stop_recovered",
|
"sub_agent_soft_stop_recovered",
|
||||||
task_id=self.task_id,
|
task_id=self.task_id,
|
||||||
@ -1372,6 +1427,7 @@ class SubAgentTask:
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
"messages": self.messages,
|
"messages": self.messages,
|
||||||
"stats": output_data["stats"],
|
"stats": output_data["stats"],
|
||||||
|
"external_session_id": self._external_session_id,
|
||||||
}
|
}
|
||||||
stats_data = {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}
|
stats_data = {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import PROMPTS_DIR, LOGS_DIR
|
from config import PROMPTS_DIR, LOGS_DIR
|
||||||
|
from modules.external_session import build_user_agent, resolve_ephemeral_headers
|
||||||
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
|
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
|
||||||
from modules.review_agent_config import resolve_review_agent_config
|
from modules.review_agent_config import resolve_review_agent_config
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
@ -191,7 +192,9 @@ class WorkflowReviewAgent:
|
|||||||
_flush_trace(out)
|
_flush_trace(out)
|
||||||
return out
|
return out
|
||||||
endpoint = f"{url.rstrip('/')}/chat/completions"
|
endpoint = f"{url.rstrip('/')}/chat/completions"
|
||||||
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
# 以「产品名/版本号」自标识;审核调用无对话连续性,每次审核生成一次性 session ID
|
||||||
|
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": build_user_agent()}
|
||||||
|
headers.update(resolve_ephemeral_headers(url))
|
||||||
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||||
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||||
extra_params = dict(self.cfg.get("extra_params") or {})
|
extra_params = dict(self.cfg.get("extra_params") or {})
|
||||||
|
|||||||
@ -481,12 +481,27 @@ def _title_debug_log(message: str, **extra: Any) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _generate_title_async(user_message: str) -> Optional[str]:
|
async def _generate_title_async(user_message: str, conversation_id: Optional[str] = None, web_terminal=None) -> Optional[str]:
|
||||||
"""使用快速模型生成对话标题。"""
|
"""使用快速模型生成对话标题。"""
|
||||||
if not user_message:
|
if not user_message:
|
||||||
_title_debug_log("skip_empty_user_message")
|
_title_debug_log("skip_empty_user_message")
|
||||||
return None
|
return None
|
||||||
client = APIClient(thinking_mode=False, web_mode=True)
|
client = APIClient(thinking_mode=False, web_mode=True)
|
||||||
|
# 标题生成是主对话链路的附属调用:复用主对话的外部会话标识(x-opencode-session)
|
||||||
|
if conversation_id and web_terminal is not None:
|
||||||
|
def _title_extra_headers_resolver(base_url, _cid=conversation_id, _term=web_terminal):
|
||||||
|
try:
|
||||||
|
from modules.external_session import resolve_conversation_headers
|
||||||
|
|
||||||
|
cm = getattr(_term, "context_manager", None)
|
||||||
|
manager = cm._get_conversation_manager_for_id(_cid) if cm else None
|
||||||
|
return resolve_conversation_headers(
|
||||||
|
base_url, _cid, manager=manager, base_dir=getattr(_term, "data_dir", None)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.extra_headers_resolver = _title_extra_headers_resolver
|
||||||
try:
|
try:
|
||||||
default_model = get_default_model_key()
|
default_model = get_default_model_key()
|
||||||
client.model_key = default_model
|
client.model_key = default_model
|
||||||
@ -531,7 +546,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
|
|||||||
return
|
return
|
||||||
|
|
||||||
async def _runner():
|
async def _runner():
|
||||||
title = await _generate_title_async(user_message)
|
title = await _generate_title_async(user_message, conversation_id=conversation_id, web_terminal=web_terminal)
|
||||||
if not title:
|
if not title:
|
||||||
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -35,6 +35,8 @@ async def _generate_title_async(
|
|||||||
title_prompt_path,
|
title_prompt_path,
|
||||||
debug_logger,
|
debug_logger,
|
||||||
model_profile: Optional[Dict[str, Any]] = None,
|
model_profile: Optional[Dict[str, Any]] = None,
|
||||||
|
conversation_id: Optional[str] = None,
|
||||||
|
web_terminal=None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""使用子智能体模型生成对话标题。
|
"""使用子智能体模型生成对话标题。
|
||||||
|
|
||||||
@ -48,6 +50,21 @@ async def _generate_title_async(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
client = APIClient(thinking_mode=False, web_mode=True)
|
client = APIClient(thinking_mode=False, web_mode=True)
|
||||||
|
# 标题生成是主对话链路的附属调用:复用主对话的外部会话标识(x-opencode-session)
|
||||||
|
if conversation_id and web_terminal is not None:
|
||||||
|
def _title_extra_headers_resolver(base_url, _cid=conversation_id, _term=web_terminal):
|
||||||
|
try:
|
||||||
|
from modules.external_session import resolve_conversation_headers
|
||||||
|
|
||||||
|
cm = getattr(_term, "context_manager", None)
|
||||||
|
manager = cm._get_conversation_manager_for_id(_cid) if cm else None
|
||||||
|
return resolve_conversation_headers(
|
||||||
|
base_url, _cid, manager=manager, base_dir=getattr(_term, "data_dir", None)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.extra_headers_resolver = _title_extra_headers_resolver
|
||||||
if not model_profile:
|
if not model_profile:
|
||||||
_title_debug_log("title_model_profile_missing")
|
_title_debug_log("title_model_profile_missing")
|
||||||
return None
|
return None
|
||||||
@ -125,7 +142,14 @@ def generate_conversation_title_background(
|
|||||||
if model_profile is None:
|
if model_profile is None:
|
||||||
_title_debug_log("title_model_profile_unavailable", title_model=title_model, conversation_id=conversation_id)
|
_title_debug_log("title_model_profile_unavailable", title_model=title_model, conversation_id=conversation_id)
|
||||||
return
|
return
|
||||||
title = await _generate_title_async(user_message, title_prompt_path, debug_logger, model_profile=model_profile)
|
title = await _generate_title_async(
|
||||||
|
user_message,
|
||||||
|
title_prompt_path,
|
||||||
|
debug_logger,
|
||||||
|
model_profile=model_profile,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
web_terminal=web_terminal,
|
||||||
|
)
|
||||||
if not title:
|
if not title:
|
||||||
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from functools import wraps
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from modules.external_session import new_external_session_id
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
|
|
||||||
def _load_summary_prompt(web_terminal) -> str:
|
def _load_summary_prompt(web_terminal) -> str:
|
||||||
@ -650,6 +651,9 @@ async def run_deep_compression(
|
|||||||
"compression_error": summary_fail_reason,
|
"compression_error": summary_fail_reason,
|
||||||
"compression_resume_payload": None,
|
"compression_resume_payload": None,
|
||||||
"is_ultra_long_conversation": False,
|
"is_ultra_long_conversation": False,
|
||||||
|
# 压缩重写上下文后旧 session 的缓存亲和已失效,重置外部会话标识
|
||||||
|
# (与 frozen prompt 一样随压缩周期重建;开关关闭时该值不会被发送)
|
||||||
|
"external_session_id": new_external_session_id(),
|
||||||
}
|
}
|
||||||
for frozen_key in REBUILD_FROZEN_KEYS:
|
for frozen_key in REBUILD_FROZEN_KEYS:
|
||||||
meta_updates[frozen_key] = None
|
meta_updates[frozen_key] = None
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject } from 'vue';
|
import { inject } from 'vue';
|
||||||
|
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ModelTab' });
|
defineOptions({ name: 'ModelTab' });
|
||||||
|
|
||||||
@ -201,5 +202,21 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 外部会话标识:opt-in,开启后向 opencode.ai 端点发送每对话稳定的 session 头 -->
|
||||||
|
<label class="settings-toggle-row"
|
||||||
|
><span class="settings-row-copy"
|
||||||
|
><span class="settings-row-title">{{ $t('personalization.externalSessionHeaderTitle') }}</span
|
||||||
|
><span class="settings-row-desc"
|
||||||
|
>{{ $t('personalization.externalSessionHeaderDesc') }}</span
|
||||||
|
></span
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.external_session_header"
|
||||||
|
@change="
|
||||||
|
personalization.updateField({
|
||||||
|
key: 'external_session_header',
|
||||||
|
value: $event.target.checked
|
||||||
|
})
|
||||||
|
" /><FancyCheck :checked="form.external_session_header" /></label>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -332,6 +332,8 @@ export default {
|
|||||||
reviewDefaultModelDesc: 'Uses the model library default_model',
|
reviewDefaultModelDesc: 'Uses the model library default_model',
|
||||||
titleModelTitle: 'Title generation model',
|
titleModelTitle: 'Title generation model',
|
||||||
titleModelDesc: 'Choose the AI model used to generate conversation titles, shared with sub-agents',
|
titleModelDesc: 'Choose the AI model used to generate conversation titles, shared with sub-agents',
|
||||||
|
externalSessionHeaderTitle: 'External session header',
|
||||||
|
externalSessionHeaderDesc: 'Send a stable per-conversation x-opencode-session header to OpenCode endpoints for session routing and cache optimization; disabled means no header is sent',
|
||||||
titleDefaultModelDesc: 'Uses the sub-agent model library default',
|
titleDefaultModelDesc: 'Uses the sub-agent model library default',
|
||||||
reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking',
|
reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking',
|
||||||
timeoutTitle: 'Review request timeout',
|
timeoutTitle: 'Review request timeout',
|
||||||
|
|||||||
@ -333,6 +333,8 @@ export default {
|
|||||||
reviewDefaultModelDesc: '使用模型库的 default_model',
|
reviewDefaultModelDesc: '使用模型库的 default_model',
|
||||||
titleModelTitle: '标题生成模型',
|
titleModelTitle: '标题生成模型',
|
||||||
titleModelDesc: '选择用于生成对话标题的 AI 模型,与子智能体共用模型库',
|
titleModelDesc: '选择用于生成对话标题的 AI 模型,与子智能体共用模型库',
|
||||||
|
externalSessionHeaderTitle: '外部会话标识',
|
||||||
|
externalSessionHeaderDesc: '向 OpenCode 端点发送每个对话稳定的 x-opencode-session 请求头,用于会话路由与缓存优化;关闭则不发送',
|
||||||
titleDefaultModelDesc: '使用子智能体模型库的默认模型',
|
titleDefaultModelDesc: '使用子智能体模型库的默认模型',
|
||||||
reviewThinkingDesc: '模型不支持思考时自动回落快速模式',
|
reviewThinkingDesc: '模型不支持思考时自动回落快速模式',
|
||||||
timeoutTitle: '审核请求超时',
|
timeoutTitle: '审核请求超时',
|
||||||
|
|||||||
@ -136,6 +136,7 @@ interface PersonalForm {
|
|||||||
versioning_backup_mode: VersioningBackupMode;
|
versioning_backup_mode: VersioningBackupMode;
|
||||||
versioning_restore_mode: 'overwrite';
|
versioning_restore_mode: 'overwrite';
|
||||||
default_model: string | null;
|
default_model: string | null;
|
||||||
|
external_session_header: boolean;
|
||||||
image_compression: string;
|
image_compression: string;
|
||||||
auto_shallow_compress_enabled: boolean;
|
auto_shallow_compress_enabled: boolean;
|
||||||
auto_deep_compress_enabled: boolean;
|
auto_deep_compress_enabled: boolean;
|
||||||
@ -343,6 +344,7 @@ const defaultForm = (): PersonalForm => ({
|
|||||||
versioning_backup_mode: 'shallow',
|
versioning_backup_mode: 'shallow',
|
||||||
versioning_restore_mode: 'overwrite',
|
versioning_restore_mode: 'overwrite',
|
||||||
default_model: null,
|
default_model: null,
|
||||||
|
external_session_header: false,
|
||||||
image_compression: 'original',
|
image_compression: 'original',
|
||||||
auto_shallow_compress_enabled: false,
|
auto_shallow_compress_enabled: false,
|
||||||
auto_deep_compress_enabled: true,
|
auto_deep_compress_enabled: true,
|
||||||
@ -604,6 +606,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
versioning_backup_mode: data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
|
versioning_backup_mode: data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
|
||||||
versioning_restore_mode: 'overwrite',
|
versioning_restore_mode: 'overwrite',
|
||||||
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
|
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
|
||||||
|
external_session_header: !!data.external_session_header,
|
||||||
image_compression:
|
image_compression:
|
||||||
typeof data.image_compression === 'string' ? data.image_compression : 'original',
|
typeof data.image_compression === 'string' ? data.image_compression : 'original',
|
||||||
auto_shallow_compress_enabled: !!data.auto_shallow_compress_enabled,
|
auto_shallow_compress_enabled: !!data.auto_shallow_compress_enabled,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Optional, AsyncGenerator, Any
|
from typing import List, Dict, Optional, AsyncGenerator, Any, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -72,6 +72,10 @@ class APIClientBaseMixin:
|
|||||||
self.supports_reasoning_effort = False # 当前模型是否支持推理强度,由 apply_profile 注入
|
self.supports_reasoning_effort = False # 当前模型是否支持推理强度,由 apply_profile 注入
|
||||||
# 最近一次API错误详情
|
# 最近一次API错误详情
|
||||||
self.last_error_info: Optional[Dict[str, Any]] = None
|
self.last_error_info: Optional[Dict[str, Any]] = None
|
||||||
|
# 附加请求头解析器:每次发请求时以 base_url 为参数回调,返回需合并的
|
||||||
|
# 额外请求头(如 x-opencode-session)。由宿主终端/子智能体按业务注入,
|
||||||
|
# 默认 None 不附加任何额外头。
|
||||||
|
self.extra_headers_resolver: Optional[Callable[[Optional[str]], Dict[str, str]]] = None
|
||||||
# 请求体落盘目录(跟随 LOGS_DIR,默认 ~/.astrion/<mode>/logs/api_requests)
|
# 请求体落盘目录(跟随 LOGS_DIR,默认 ~/.astrion/<mode>/logs/api_requests)
|
||||||
self.request_dump_dir = Path(LOGS_DIR) / "api_requests"
|
self.request_dump_dir = Path(LOGS_DIR) / "api_requests"
|
||||||
self.debug_log_path = Path(LOGS_DIR) / "api_debug.log"
|
self.debug_log_path = Path(LOGS_DIR) / "api_debug.log"
|
||||||
|
|||||||
@ -64,7 +64,7 @@ class APIClientChatMixin:
|
|||||||
# 决定是否使用思考模式(思考模式下每次请求都用思考配置)
|
# 决定是否使用思考模式(思考模式下每次请求都用思考配置)
|
||||||
current_thinking_mode = self.get_current_thinking_mode()
|
current_thinking_mode = self.get_current_thinking_mode()
|
||||||
api_config = self._select_api_config(current_thinking_mode)
|
api_config = self._select_api_config(current_thinking_mode)
|
||||||
headers = self._build_headers(api_config["api_key"])
|
headers = self._build_headers(api_config["api_key"], base_url=api_config.get("base_url"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
override_max = self.thinking_max_tokens if current_thinking_mode else self.fast_max_tokens
|
override_max = self.thinking_max_tokens if current_thinking_mode else self.fast_max_tokens
|
||||||
|
|||||||
@ -32,6 +32,8 @@ except ImportError:
|
|||||||
|
|
||||||
from utils.log_rotation import append_line, prune_dir
|
from utils.log_rotation import append_line, prune_dir
|
||||||
|
|
||||||
|
from modules.external_session import build_user_agent
|
||||||
|
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
|
|
||||||
from utils.api_client.utils import _api_dump_enabled
|
from utils.api_client.utils import _api_dump_enabled
|
||||||
@ -105,11 +107,24 @@ class APIClientProfileMixin:
|
|||||||
"""获取当前应该使用的思考模式:思考模式下每次请求都用思考配置。"""
|
"""获取当前应该使用的思考模式:思考模式下每次请求都用思考配置。"""
|
||||||
return bool(self.thinking_mode)
|
return bool(self.thinking_mode)
|
||||||
|
|
||||||
def _build_headers(self, api_key: str) -> Dict[str, str]:
|
def _build_headers(self, api_key: str, base_url: Optional[str] = None) -> Dict[str, str]:
|
||||||
return {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
|
# 以「产品名/版本号」自标识(OpenCode 等供应商要求,禁止宽泛 UA)
|
||||||
|
"User-Agent": build_user_agent(),
|
||||||
}
|
}
|
||||||
|
# 合并附加请求头(如 x-opencode-session),由调用方注入的 resolver 按
|
||||||
|
# base_url 动态解析;解析器异常不影响主请求。
|
||||||
|
resolver = getattr(self, "extra_headers_resolver", None)
|
||||||
|
if resolver is not None:
|
||||||
|
try:
|
||||||
|
extra = resolver(base_url) or {}
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
headers.update({str(k): str(v) for k, v in extra.items()})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return headers
|
||||||
|
|
||||||
def _select_api_config(self, use_thinking: bool) -> Dict[str, str]:
|
def _select_api_config(self, use_thinking: bool) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user