Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/app/agent_runtime/context/compaction/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion backend/app/agent_runtime/graph/orchestrator/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion backend/app/agent_runtime/runner/subagent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/clients/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
69 changes: 63 additions & 6 deletions backend/app/models/clients/model_factory.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand All @@ -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",
)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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),
)
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions backend/tests/agent_runtime/runner/test_subagent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -265,22 +266,23 @@ 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",
lambda *_args, **_kwargs: object(),
)

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

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
Expand Down
98 changes: 97 additions & 1 deletion backend/tests/agent_runtime/test_model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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():
Expand Down
Loading
Loading