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:
JOJO 2026-09-07 18:50:29 +08:00
parent 9a17b38153
commit c80bc4fbb4
18 changed files with 321 additions and 11 deletions

8
config/version.py Normal file
View File

@ -0,0 +1,8 @@
"""应用版本号。
用于对外 HTTP 请求的 User-Agent 标识 ``Astrion/1.0``
OpenCode 等供应商要求客户端工具以产品名/版本号格式自标识
禁止使用宽泛 UA python-httpx/x.y
"""
APP_VERSION = "1.0"

View File

@ -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":

View File

@ -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
View 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-AgentOpenCode 要求工具自标识,禁止宽泛 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.aiGo 与 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()}

View File

@ -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 {})

View File

@ -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))
) )

View File

@ -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)}

View File

@ -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 {})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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',

View File

@ -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: '审核请求超时',

View File

@ -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,

View File

@ -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"

View File

@ -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

View File

@ -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]:
""" """