mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
fix(agent): send stable session headers to OpenCode
This commit is contained in:
@@ -7,6 +7,7 @@ import time
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
@@ -881,11 +882,17 @@ class LLMHelper:
|
||||
default_headers: dict[str, str] | None,
|
||||
model_kwargs: dict[str, Any],
|
||||
) -> tuple[dict[str, str] | None, dict[str, Any]]:
|
||||
"""为 OpenAI 与 xAI 官方端点构造稳定提示词缓存路由参数。"""
|
||||
"""为官方端点构造稳定会话及提示词缓存路由参数。"""
|
||||
cache_key = str(prompt_cache_key or "").strip()
|
||||
headers = dict(default_headers or {})
|
||||
kwargs = dict(model_kwargs)
|
||||
provider_name = str(provider or "").strip().lower()
|
||||
if cls._matches_endpoint_host(base_url, "opencode.ai"):
|
||||
# 主对话沿用脱敏缓存键;独立测试、摘要调用的标识在模型实例内复用。
|
||||
headers["x-opencode-session"] = cache_key or f"moviepilot-{uuid4().hex}"
|
||||
if not any(key.lower() == "user-agent" for key in headers):
|
||||
headers["User-Agent"] = "MoviePilot"
|
||||
return headers, kwargs
|
||||
if not cache_key:
|
||||
return headers or None, kwargs
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""OpenCode 官方端点的会话路由请求头回归测试。"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.agent.llm import helper
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://opencode.ai/zen/v1",
|
||||
"https://opencode.ai/zen/go/v1",
|
||||
])
|
||||
@pytest.mark.parametrize("cache_key", ["moviepilot-agent-private-hash", None])
|
||||
def test_opencode_model_sends_stable_session_on_every_request(monkeypatch, base_url, cache_key):
|
||||
"""主对话与独立调用经过真实 SDK 后仍携带稳定标识,工具绑定不丢失请求头。"""
|
||||
requests = []
|
||||
|
||||
def respond(request):
|
||||
"""在本地捕获 SDK 请求并返回最小聊天响应,禁止真实出站。"""
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "test-model",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"}],
|
||||
})
|
||||
|
||||
transport = httpx.MockTransport(respond)
|
||||
with httpx.Client(transport=transport) as client:
|
||||
async def invoke():
|
||||
"""使用离线运行时创建模型,并验证同步与异步请求使用同一标识。"""
|
||||
async with httpx.AsyncClient(transport=transport) as async_client:
|
||||
monkeypatch.setattr(
|
||||
helper, "_build_httpx_client",
|
||||
lambda _proxy, **kwargs: async_client if kwargs.get("async_client") else client,
|
||||
)
|
||||
runtime = AsyncMock()
|
||||
runtime.resolve_runtime.return_value = {
|
||||
"runtime": "openai_compatible", "model_id": "test-model",
|
||||
"api_key": "test-key", "base_url": base_url,
|
||||
}
|
||||
model = await LLMHelper.get_llm(
|
||||
provider="opencode", model="test-model", user_agent="",
|
||||
use_proxy=False, api_protocol="chat_completions", web_search_mode="disabled",
|
||||
prompt_cache_key=cache_key, provider_runtime=runtime,
|
||||
)
|
||||
bound = model.bind_tools([{
|
||||
"type": "function", "function": {"name": "example", "description": "测试工具",
|
||||
"parameters": {"type": "object", "properties": {}}},
|
||||
}])
|
||||
assert bound.invoke("hello").content == "OK"
|
||||
assert (await bound.ainvoke("again")).content == "OK"
|
||||
|
||||
asyncio.run(invoke())
|
||||
|
||||
assert len(requests) == 2
|
||||
session = requests[0].headers["x-opencode-session"]
|
||||
assert session
|
||||
assert requests[1].headers["x-opencode-session"] == session
|
||||
if cache_key:
|
||||
assert session == cache_key
|
||||
assert requests[0].headers["user-agent"] == "MoviePilot"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://opencode.ai/zen/go/v1", "https://OPENCODE.AI/zen/v1",
|
||||
])
|
||||
def test_opencode_independent_models_have_distinct_sessions(base_url):
|
||||
"""没有对话标识的独立模型互不共用会话,并保留自定义 UA 和模型参数。"""
|
||||
original = {"user-agent": "custom-client/1.0", "X-Test": "value"}
|
||||
options = {"extra_body": {"test": True}}
|
||||
headers = [LLMHelper._build_openai_prompt_cache_options(
|
||||
provider="custom", base_url=base_url, use_responses_api=True,
|
||||
prompt_cache_key=None, default_headers=original, model_kwargs=options,
|
||||
) for _ in range(2)]
|
||||
assert headers[0][0]["x-opencode-session"] != headers[1][0]["x-opencode-session"]
|
||||
assert headers[0][0]["user-agent"] == "custom-client/1.0"
|
||||
assert "User-Agent" not in headers[0][0]
|
||||
assert headers[0][0]["X-Test"] == "value"
|
||||
assert headers[0][1] == options
|
||||
assert "x-opencode-session" not in original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://opencode.ai.example/zen/go/v1", "https://proxy.example/opencode.ai",
|
||||
"https://opencode.ai@proxy.example/v1", "https://[invalid", None,
|
||||
])
|
||||
def test_opencode_headers_do_not_leak_to_other_hosts(base_url):
|
||||
"""供应商名称与路径不能代替官方主机校验,兼容端点保持原有参数。"""
|
||||
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
|
||||
provider="opencode", base_url=base_url, use_responses_api=False,
|
||||
prompt_cache_key="private-cache-key", default_headers=None, model_kwargs={},
|
||||
)
|
||||
assert headers is None
|
||||
assert kwargs == {}
|
||||
Reference in New Issue
Block a user