diff --git a/backend/app/agent_runtime/context/compaction/service.py b/backend/app/agent_runtime/context/compaction/service.py index 52c26630..9a028b98 100644 --- a/backend/app/agent_runtime/context/compaction/service.py +++ b/backend/app/agent_runtime/context/compaction/service.py @@ -202,7 +202,9 @@ async def compact_window( effective_model_config["reasoning_effort"] = normalize_reasoning_effort( effort_setting.value if effort_setting else None ) - model = create_chat_model(ModelConfig(**to_client_model_config(effective_model_config))) + client_model_config = to_client_model_config(effective_model_config) + client_model_config["session_id"] = state["session_id"] + model = create_chat_model(ModelConfig(**client_model_config)) response = await model.ainvoke(messages) except Exception as exc: logger.opt(exception=True).error("Compaction LLM request failed") diff --git a/backend/app/agent_runtime/graph/orchestrator/graph.py b/backend/app/agent_runtime/graph/orchestrator/graph.py index 6da223a6..a0d4aa2e 100644 --- a/backend/app/agent_runtime/graph/orchestrator/graph.py +++ b/backend/app/agent_runtime/graph/orchestrator/graph.py @@ -150,7 +150,9 @@ async def primary_node( ) if not isinstance(runtime_model_config, dict): raise ValueError("Agent 运行时模型配置不可用") - model_config = ModelConfig(**to_client_model_config(runtime_model_config)) + client_model_config = to_client_model_config(runtime_model_config) + client_model_config["session_id"] = state.get("session_id") + model_config = ModelConfig(**client_model_config) model = create_chat_model(model_config) agent_key = state.get("agent_key", "build") referenced_skill_ids = _primary_referenced_skill_ids(state) diff --git a/backend/app/agent_runtime/runner/subagent_runner.py b/backend/app/agent_runtime/runner/subagent_runner.py index 1e8187e0..cfb75e47 100644 --- a/backend/app/agent_runtime/runner/subagent_runner.py +++ b/backend/app/agent_runtime/runner/subagent_runner.py @@ -437,7 +437,11 @@ async def _build_graph( tools=await self._build_tools(definition, runtime_state), termination=TerminationCondition(mode="no_tool_call"), ) - model = create_chat_model(ModelConfig(**to_client_model_config(model_config))) + client_model_config = to_client_model_config(model_config) + session_id = runtime_state.get("session_id") or getattr(row, "child_thread_id", None) + if session_id: + client_model_config["session_id"] = session_id + model = create_chat_model(ModelConfig(**client_model_config)) graph = create_react_agent( agent_config, model=model, diff --git a/backend/app/models/clients/llm_client.py b/backend/app/models/clients/llm_client.py index a06e0fa3..dc61c67b 100644 --- a/backend/app/models/clients/llm_client.py +++ b/backend/app/models/clients/llm_client.py @@ -48,6 +48,7 @@ class LLMConfig: api_key: str model_id: str custom_headers: dict[str, str] | None = None + session_id: str | None = None temperature: float | None = 1.0 top_p: float | None = 1.0 top_k: int | None = 0 @@ -111,6 +112,7 @@ def _get_llm(self) -> Runnable[LanguageModelInput, BaseMessage]: api_key=config.api_key, model_id=config.model_id, custom_headers=config.custom_headers, + session_id=config.session_id, temperature=config.temperature, top_p=config.top_p, top_k=config.top_k, diff --git a/backend/app/models/clients/model_factory.py b/backend/app/models/clients/model_factory.py index aa5c834b..fac63cc5 100644 --- a/backend/app/models/clients/model_factory.py +++ b/backend/app/models/clients/model_factory.py @@ -1,10 +1,12 @@ from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse from langchain_core.language_models import LanguageModelInput from langchain_core.messages import BaseMessage from langchain_core.runnables import Runnable +from app.core.ids import generate_id from app.core.utils.tiktoken import seed_bundled_encodings from app.models.adapters.anthropic_compatible import ANTHROPIC_COMPATIBLE_PROVIDER_TYPES from app.models.clients.deepseek_payload import patch_deepseek_reasoning_payload @@ -37,6 +39,7 @@ class ModelConfig: api_key: str model_id: str custom_headers: dict[str, str] | None = None + session_id: str | None = None max_context_tokens: int | None = None temperature: float | None = DEFAULT_TEMPERATURE top_p: float | None = DEFAULT_TOP_P @@ -108,12 +111,49 @@ def _gemini_compatible_base_url(base_url: str) -> str: return normalized_url.removesuffix("/v1beta") +def _application_user_agent() -> str: + from app.settings import settings + + return f"{settings.app_name}/{settings.app_version}" + + +def _set_header(headers: dict[str, str], name: str, value: str) -> None: + for existing_name in tuple(headers): + if existing_name.lower() == name.lower(): + del headers[existing_name] + headers[name] = value + + +def _is_opencode_provider(config: ModelConfig) -> bool: + if config.provider_type.lower().startswith("opencode"): + return True + return (urlparse(config.base_url).hostname or "").lower() == "opencode.ai" + + +def _model_request_headers(config: ModelConfig) -> dict[str, str]: + headers = dict(config.custom_headers or {}) + _set_header(headers, "User-Agent", _application_user_agent()) + is_opencode_provider = _is_opencode_provider(config) + if is_opencode_provider: + if not config.session_id: + config.session_id = f"openfic-{generate_id()}" + _set_header(headers, "x-opencode-session", config.session_id) + return headers + + +def _update_http_client_headers(client: Any, headers: dict[str, str]) -> None: + request_client = getattr(client, "_client", client) + client_headers = getattr(request_client, "headers", None) + if client_headers is not None: + client_headers.update(headers) + + def _openai_compatible_kwargs(config: ModelConfig) -> dict[str, Any]: kwargs = _compact_kwargs( model=config.model_id, api_key=config.api_key, base_url=config.base_url or None, - default_headers=config.custom_headers or None, + default_headers=_model_request_headers(config), temperature=_non_default(config.temperature, DEFAULT_TEMPERATURE), top_p=_non_default(config.top_p, DEFAULT_TOP_P), max_tokens=config.max_tokens, @@ -158,7 +198,7 @@ def create_chat_model(config: ModelConfig) -> Runnable[LanguageModelInput, BaseM model=config.model_id, api_key=config.api_key, base_url=config.base_url or None, - default_headers=config.custom_headers or None, + default_headers=_model_request_headers(config), temperature=_non_default(config.temperature, DEFAULT_TEMPERATURE), top_p=_non_default(config.top_p, DEFAULT_TOP_P), top_k=_non_default(config.top_k, DEFAULT_TOP_K), @@ -179,6 +219,7 @@ def create_chat_model(config: ModelConfig) -> Runnable[LanguageModelInput, BaseM top_k=_non_default(config.top_k, DEFAULT_TOP_K), max_output_tokens=config.max_tokens, thinking_level=_three_level_reasoning_effort(reasoning_effort), + additional_headers=_model_request_headers(config), max_retries=1, ) if config.base_url: @@ -196,7 +237,7 @@ def create_chat_model(config: ModelConfig) -> Runnable[LanguageModelInput, BaseM top_k=_non_default(config.top_k, DEFAULT_TOP_K), max_output_tokens=config.max_tokens, thinking_level=_three_level_reasoning_effort(reasoning_effort), - additional_headers=config.custom_headers or None, + additional_headers=_model_request_headers(config), max_retries=0, api_version="v1beta", ) @@ -225,6 +266,7 @@ def _get_request_payload( model=config.model_id, api_key=config.api_key, base_url=config.base_url or None, + default_headers=_model_request_headers(config), temperature=_non_default(config.temperature, DEFAULT_TEMPERATURE), max_tokens=config.max_tokens, reasoning_effort=reasoning_effort, @@ -249,12 +291,16 @@ def _get_request_payload( ) if reasoning_effort: mistral_kwargs["model_kwargs"] = {"reasoning_effort": reasoning_effort} - return ChatMistralAI(**mistral_kwargs) + model = ChatMistralAI(**mistral_kwargs) + headers = _model_request_headers(config) + _update_http_client_headers(model.client, headers) + _update_http_client_headers(model.async_client, headers) + return model if provider == "openrouter": from langchain_openrouter import ChatOpenRouter - return ChatOpenRouter(**_compact_kwargs( + model = ChatOpenRouter(**_compact_kwargs( model=config.model_id, api_key=config.api_key, base_url=config.base_url or None, @@ -274,6 +320,10 @@ def _get_request_payload( max_retries=0, timeout=int(_request_timeout()[1] * 1000), )) + headers = _model_request_headers(config) + _update_http_client_headers(model.client.sdk_configuration.client, headers) + _update_http_client_headers(model.client.sdk_configuration.async_client, headers) + return model if provider == "groq": from langchain_groq import ChatGroq @@ -287,6 +337,7 @@ def _get_request_payload( reasoning_effort=_three_level_reasoning_effort(reasoning_effort), max_retries=0, timeout=_request_timeout(), + default_headers=_model_request_headers(config), ) if config.top_p is not None: groq_kwargs["model_kwargs"] = {"top_p": config.top_p} @@ -315,6 +366,7 @@ def _default_params(self) -> dict[str, Any]: cohere_kwargs = _compact_kwargs( model=config.model_id, cohere_api_key=config.api_key, + user_agent=_application_user_agent(), base_url=config.base_url or None, temperature=_non_default(config.temperature, DEFAULT_TEMPERATURE), ) @@ -325,7 +377,7 @@ def _default_params(self) -> dict[str, Any]: if provider == "amazon-nova": from langchain_amazon_nova import ChatAmazonNova - return ChatAmazonNova(**_compact_kwargs( + model = ChatAmazonNova(**_compact_kwargs( model=config.model_id, api_key=config.api_key, base_url=config.base_url or None, @@ -335,6 +387,10 @@ def _default_params(self) -> dict[str, Any]: reasoning_effort=_three_level_reasoning_effort(reasoning_effort), max_retries=0, )) + headers = _model_request_headers(config) + _update_http_client_headers(model.client, headers) + _update_http_client_headers(model.async_client, headers) + return model if provider == "openai-compatible-responses": from langchain_openai import ChatOpenAI @@ -354,6 +410,7 @@ def _default_params(self) -> dict[str, Any]: temperature=_non_default(config.temperature, DEFAULT_TEMPERATURE), top_p=_non_default(config.top_p, DEFAULT_TOP_P), max_completion_tokens=config.max_tokens, + default_headers=_model_request_headers(config), ) model = ChatNVIDIA(**nvidia_kwargs) return model.with_thinking_mode(enabled=True) if reasoning_effort else model diff --git a/backend/tests/agent_runtime/context/test_compaction_service.py b/backend/tests/agent_runtime/context/test_compaction_service.py index 7492502c..14724cc8 100644 --- a/backend/tests/agent_runtime/context/test_compaction_service.py +++ b/backend/tests/agent_runtime/context/test_compaction_service.py @@ -313,6 +313,7 @@ async def lookup_setting(_session, key: str): model_reference=model_reference, ) assert selected_configs[0].model_id == "light-llm" + assert selected_configs[0].session_id == "session_test" assert selected_configs[0].reasoning_effort == "high" record_lookup.assert_awaited_once_with( db_session, "light-record" if model_reference == "__system_light_model__" else model_reference diff --git a/backend/tests/agent_runtime/runner/test_subagent_runner.py b/backend/tests/agent_runtime/runner/test_subagent_runner.py index be886c63..b8ed2b82 100644 --- a/backend/tests/agent_runtime/runner/test_subagent_runner.py +++ b/backend/tests/agent_runtime/runner/test_subagent_runner.py @@ -235,6 +235,7 @@ async def test_subagent_graph_uses_resolved_model_config_for_runtime_state( "reasoning_effort": "high", } captured_states: list[dict[str, Any]] = [] + captured_configs: list[Any] = [] runner = SubagentRunner( session_factory=db_session_factory, @@ -265,7 +266,7 @@ async def fake_build_tools(_definition, runtime_state): ) monkeypatch.setattr( "app.agent_runtime.runner.subagent_runner.create_chat_model", - lambda _config: object(), + lambda config: captured_configs.append(config) or object(), ) monkeypatch.setattr( "app.agent_runtime.runner.subagent_runner.create_react_agent", @@ -273,7 +274,7 @@ async def fake_build_tools(_definition, runtime_state): ) graph, model_config = await runner._build_graph( - SimpleNamespace(agent_key="configured-subagent"), + SimpleNamespace(agent_key="configured-subagent", child_thread_id="child-thread"), definition, {"model_config": dict(parent_config)}, ) @@ -281,6 +282,7 @@ async def fake_build_tools(_definition, runtime_state): assert graph is not None assert model_config == resolved_config assert captured_states == [{"model_config": resolved_config}] + assert captured_configs[0].session_id == "child-thread" @pytest.mark.asyncio diff --git a/backend/tests/agent_runtime/test_model_factory.py b/backend/tests/agent_runtime/test_model_factory.py index 9b2b8d06..e12a0411 100644 --- a/backend/tests/agent_runtime/test_model_factory.py +++ b/backend/tests/agent_runtime/test_model_factory.py @@ -129,7 +129,8 @@ def test_create_chat_model_gemini_compatible_uses_custom_native_client(): assert isinstance(model, ChatGoogleGenerativeAI) assert model.base_url == {"api_endpoint": "https://gateway.example/gemini"} assert model.api_version == "v1beta" - assert model.additional_headers == {"X-Provider-Token": "custom-token"} + assert model.additional_headers["X-Provider-Token"] == "custom-token" + assert model.additional_headers["User-Agent"].startswith("OpenFic/") assert model.thinking_level == "high" assert model.max_retries == 0 @@ -156,6 +157,101 @@ def test_create_chat_model_custom_providers_send_custom_headers(): assert openai_model.default_headers["X-Provider-Token"] == "custom-token" assert anthropic_model.default_headers["X-Provider-Token"] == "custom-token" + assert openai_model.default_headers["User-Agent"].startswith("OpenFic/") + assert anthropic_model.default_headers["User-Agent"].startswith("OpenFic/") + + +def test_create_chat_model_adds_versioned_application_user_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.settings import settings + + monkeypatch.setattr(settings, "app_name", "OpenFic") + monkeypatch.setattr(settings, "app_version", "0.11.1") + + model = create_chat_model( + ModelConfig( + provider_type="opencode", + base_url="https://opencode.example/v1", + api_key="test-key", + model_id="custom-model", + session_id="agent-session-1", + custom_headers={"X-Provider-Token": "custom-token"}, + ) + ) + + assert model.default_headers["User-Agent"] == "OpenFic/0.11.1" + assert model.default_headers["X-Provider-Token"] == "custom-token" + + +@pytest.mark.parametrize("provider_type", ["opencode", "opencode-go"]) +def test_create_chat_model_adds_opencode_session_header(provider_type: str) -> None: + model = create_chat_model( + ModelConfig( + provider_type=provider_type, + base_url="https://opencode.example/v1", + api_key="test-key", + model_id="test-model", + session_id="agent-session-1", + ) + ) + + assert model.default_headers["x-opencode-session"] == "agent-session-1" + + +def test_create_chat_model_adds_opencode_headers_for_openai_compatible_endpoint() -> None: + model = create_chat_model( + ModelConfig( + provider_type="openai-compatible", + base_url="https://opencode.ai/zen/go/v1", + api_key="test-key", + model_id="test-model", + session_id="agent-session-1", + ) + ) + + assert model.default_headers["User-Agent"] == "OpenFic/0.11.1" + assert model.default_headers["x-opencode-session"] == "agent-session-1" + + +def test_create_chat_model_generates_opencode_session_when_not_provided() -> None: + model = create_chat_model( + ModelConfig( + provider_type="openai-compatible", + base_url="https://opencode.ai/zen/go/v1", + api_key="test-key", + model_id="test-model", + ) + ) + + assert model.default_headers["x-opencode-session"] + + +def test_create_chat_model_does_not_add_opencode_header_to_other_providers() -> None: + model = create_chat_model( + ModelConfig( + provider_type="openai-compatible", + base_url="https://gateway.example/v1", + api_key="test-key", + model_id="test-model", + session_id="agent-session-1", + ) + ) + + assert "x-opencode-session" not in model.default_headers + + +def test_create_chat_model_adds_application_user_agent_to_non_opencode_provider() -> None: + model = create_chat_model( + ModelConfig( + provider_type="openai-compatible", + base_url="https://gateway.example/v1", + api_key="test-key", + model_id="test-model", + ) + ) + + assert model.default_headers["User-Agent"].startswith("OpenFic/") def test_create_chat_model_with_temperature(): diff --git a/backend/tests/agent_runtime/test_orchestrator_graph.py b/backend/tests/agent_runtime/test_orchestrator_graph.py index 2a749781..98d9821f 100644 --- a/backend/tests/agent_runtime/test_orchestrator_graph.py +++ b/backend/tests/agent_runtime/test_orchestrator_graph.py @@ -102,6 +102,61 @@ async def test_primary_tool_names_forward_explicit_skill_references() -> None: ) +@pytest.mark.asyncio +async def test_primary_node_passes_agent_session_id_to_model_config() -> None: + from app.agent_runtime.graph.orchestrator.graph import primary_node + + captured_configs = [] + + class _Graph: + async def ainvoke(self, *_args, **_kwargs): + return {} + + state = { + "session_id": "agent-session-1", + "task_id": "task-1", + "project_id": "project-1", + "model_config": { + "provider_type": "openai-compatible", + "base_url": "https://gateway.example/v1", + "api_key": "test-key", + "model_id": "test-model", + "max_context_tokens": 8000, + }, + "agent_key": "build", + "messages": [], + "user_request": "continue", + "user_attachments": [], + "is_completed": False, + "error": None, + "retry_count": 0, + "current_revision_id": None, + } + config = {"configurable": {"model_config": state["model_config"]}} + + with ( + patch( + "app.agent_runtime.graph.orchestrator.graph.create_chat_model", + side_effect=lambda model_config: captured_configs.append(model_config) or object(), + ), + patch( + "app.agent_runtime.graph.orchestrator.graph._primary_tool_names", + AsyncMock(return_value=[]), + ), + patch( + "app.agent_runtime.graph.orchestrator.graph._primary_build_hooks", + AsyncMock(return_value=[]), + ), + patch( + "app.agent_runtime.graph.orchestrator.graph.create_react_agent", + return_value=_Graph(), + ), + ): + await primary_node(state, config) + + assert captured_configs[0].session_id == "agent-session-1" + + def test_session_runner_constructor_no_longer_accepts_mode(): with pytest.raises(TypeError): SessionRunner(