diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index 3fd0d1c18..8d6d0c868 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -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 diff --git a/tests/test_llm_opencode_session.py b/tests/test_llm_opencode_session.py new file mode 100644 index 000000000..f1b42c7e8 --- /dev/null +++ b/tests/test_llm_opencode_session.py @@ -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 == {}