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.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_profile = get_model_profile(self.model_key)
|
||||
self.apply_model_profile(self.model_profile)
|
||||
@ -245,6 +249,30 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
"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"]):
|
||||
self.container_session = session
|
||||
if session and session.mode == "docker":
|
||||
|
||||
@ -9,6 +9,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
import httpx
|
||||
|
||||
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.i18n import tr
|
||||
|
||||
@ -104,7 +105,9 @@ class ApprovalAgent:
|
||||
_flush_trace(out)
|
||||
return out
|
||||
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)
|
||||
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
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
|
||||
|
||||
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.review_agent_config import resolve_review_agent_config
|
||||
from modules.i18n import tr
|
||||
@ -188,7 +189,9 @@ class GoalReviewAgent:
|
||||
_flush_trace(out)
|
||||
return out
|
||||
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)
|
||||
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||
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
|
||||
"auto_generate_title": True,
|
||||
"title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型)
|
||||
"external_session_header": False, # 向 opencode.ai 端点发送 x-opencode-session 头(默认关闭,opt-in)
|
||||
"recent_conversations_prompt_enabled": False,
|
||||
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
"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["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(
|
||||
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.current_context_tokens: 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]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@ -764,6 +767,8 @@ class SubAgentTask:
|
||||
client.model_key = chosen_key
|
||||
client.project_path = str(self.manager.project_path)
|
||||
client.apply_profile(model_map[chosen_key])
|
||||
# 外部会话标识(x-opencode-session):随子对话生命周期稳定,压缩后重置
|
||||
client.extra_headers_resolver = self._resolve_external_session_headers
|
||||
return client, chosen_key
|
||||
|
||||
async def _call_model(
|
||||
@ -1044,6 +1049,43 @@ class SubAgentTask:
|
||||
# 兜底用内存中的
|
||||
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:
|
||||
"""深度压缩:把旧消息总结成一条 system 消息,重建 system prompt。
|
||||
|
||||
@ -1174,6 +1216,15 @@ class SubAgentTask:
|
||||
self.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(
|
||||
f"[SubAgentTask] task={self.task_id} 深度压缩完成: "
|
||||
f"压缩 {len(old_messages)} 条消息,保留 {len(recent_messages)} 条,"
|
||||
@ -1253,6 +1304,7 @@ class SubAgentTask:
|
||||
"summary": partial_summary,
|
||||
"messages": self.messages,
|
||||
"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)
|
||||
conversation_json = json.dumps(conversation_data, ensure_ascii=False)
|
||||
@ -1280,6 +1332,9 @@ class SubAgentTask:
|
||||
if self.conversation_file.exists():
|
||||
data = json.loads(self.conversation_file.read_text(encoding="utf-8"))
|
||||
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(
|
||||
"sub_agent_soft_stop_recovered",
|
||||
task_id=self.task_id,
|
||||
@ -1372,6 +1427,7 @@ class SubAgentTask:
|
||||
"summary": summary,
|
||||
"messages": self.messages,
|
||||
"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)}
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
import httpx
|
||||
|
||||
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.review_agent_config import resolve_review_agent_config
|
||||
from modules.i18n import tr
|
||||
@ -191,7 +192,9 @@ class WorkflowReviewAgent:
|
||||
_flush_trace(out)
|
||||
return out
|
||||
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)
|
||||
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||
extra_params = dict(self.cfg.get("extra_params") or {})
|
||||
|
||||
@ -481,12 +481,27 @@ def _title_debug_log(message: str, **extra: Any) -> None:
|
||||
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:
|
||||
_title_debug_log("skip_empty_user_message")
|
||||
return None
|
||||
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:
|
||||
default_model = get_default_model_key()
|
||||
client.model_key = default_model
|
||||
@ -531,7 +546,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
|
||||
return
|
||||
|
||||
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:
|
||||
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
||||
return
|
||||
|
||||
@ -35,6 +35,8 @@ async def _generate_title_async(
|
||||
title_prompt_path,
|
||||
debug_logger,
|
||||
model_profile: Optional[Dict[str, Any]] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
web_terminal=None,
|
||||
) -> Optional[str]:
|
||||
"""使用子智能体模型生成对话标题。
|
||||
|
||||
@ -48,6 +50,21 @@ async def _generate_title_async(
|
||||
return None
|
||||
|
||||
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:
|
||||
_title_debug_log("title_model_profile_missing")
|
||||
return None
|
||||
@ -125,7 +142,14 @@ def generate_conversation_title_background(
|
||||
if model_profile is None:
|
||||
_title_debug_log("title_model_profile_unavailable", title_model=title_model, conversation_id=conversation_id)
|
||||
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:
|
||||
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
||||
return
|
||||
|
||||
@ -8,6 +8,7 @@ from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from modules.external_session import new_external_session_id
|
||||
from modules.i18n import tr
|
||||
|
||||
def _load_summary_prompt(web_terminal) -> str:
|
||||
@ -650,6 +651,9 @@ async def run_deep_compression(
|
||||
"compression_error": summary_fail_reason,
|
||||
"compression_resume_payload": None,
|
||||
"is_ultra_long_conversation": False,
|
||||
# 压缩重写上下文后旧 session 的缓存亲和已失效,重置外部会话标识
|
||||
# (与 frozen prompt 一样随压缩周期重建;开关关闭时该值不会被发送)
|
||||
"external_session_id": new_external_session_id(),
|
||||
}
|
||||
for frozen_key in REBUILD_FROZEN_KEYS:
|
||||
meta_updates[frozen_key] = None
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||
|
||||
defineOptions({ name: 'ModelTab' });
|
||||
|
||||
@ -201,5 +202,21 @@ const {
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -332,6 +332,8 @@ export default {
|
||||
reviewDefaultModelDesc: 'Uses the model library default_model',
|
||||
titleModelTitle: 'Title generation model',
|
||||
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',
|
||||
reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking',
|
||||
timeoutTitle: 'Review request timeout',
|
||||
|
||||
@ -333,6 +333,8 @@ export default {
|
||||
reviewDefaultModelDesc: '使用模型库的 default_model',
|
||||
titleModelTitle: '标题生成模型',
|
||||
titleModelDesc: '选择用于生成对话标题的 AI 模型,与子智能体共用模型库',
|
||||
externalSessionHeaderTitle: '外部会话标识',
|
||||
externalSessionHeaderDesc: '向 OpenCode 端点发送每个对话稳定的 x-opencode-session 请求头,用于会话路由与缓存优化;关闭则不发送',
|
||||
titleDefaultModelDesc: '使用子智能体模型库的默认模型',
|
||||
reviewThinkingDesc: '模型不支持思考时自动回落快速模式',
|
||||
timeoutTitle: '审核请求超时',
|
||||
|
||||
@ -136,6 +136,7 @@ interface PersonalForm {
|
||||
versioning_backup_mode: VersioningBackupMode;
|
||||
versioning_restore_mode: 'overwrite';
|
||||
default_model: string | null;
|
||||
external_session_header: boolean;
|
||||
image_compression: string;
|
||||
auto_shallow_compress_enabled: boolean;
|
||||
auto_deep_compress_enabled: boolean;
|
||||
@ -343,6 +344,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
versioning_backup_mode: 'shallow',
|
||||
versioning_restore_mode: 'overwrite',
|
||||
default_model: null,
|
||||
external_session_header: false,
|
||||
image_compression: 'original',
|
||||
auto_shallow_compress_enabled: false,
|
||||
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_restore_mode: 'overwrite',
|
||||
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
|
||||
external_session_header: !!data.external_session_header,
|
||||
image_compression:
|
||||
typeof data.image_compression === 'string' ? data.image_compression : 'original',
|
||||
auto_shallow_compress_enabled: !!data.auto_shallow_compress_enabled,
|
||||
|
||||
@ -7,7 +7,7 @@ import asyncio
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import List, Dict, Optional, AsyncGenerator, Any
|
||||
from typing import List, Dict, Optional, AsyncGenerator, Any, Callable
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@ -72,6 +72,10 @@ class APIClientBaseMixin:
|
||||
self.supports_reasoning_effort = False # 当前模型是否支持推理强度,由 apply_profile 注入
|
||||
# 最近一次API错误详情
|
||||
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)
|
||||
self.request_dump_dir = Path(LOGS_DIR) / "api_requests"
|
||||
self.debug_log_path = Path(LOGS_DIR) / "api_debug.log"
|
||||
|
||||
@ -64,7 +64,7 @@ class APIClientChatMixin:
|
||||
# 决定是否使用思考模式(思考模式下每次请求都用思考配置)
|
||||
current_thinking_mode = self.get_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:
|
||||
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 modules.external_session import build_user_agent
|
||||
|
||||
from modules.i18n import tr
|
||||
|
||||
from utils.api_client.utils import _api_dump_enabled
|
||||
@ -105,11 +107,24 @@ class APIClientProfileMixin:
|
||||
"""获取当前应该使用的思考模式:思考模式下每次请求都用思考配置。"""
|
||||
return bool(self.thinking_mode)
|
||||
|
||||
def _build_headers(self, api_key: str) -> Dict[str, str]:
|
||||
return {
|
||||
def _build_headers(self, api_key: str, base_url: Optional[str] = None) -> Dict[str, str]:
|
||||
headers = {
|
||||
"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]:
|
||||
"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user