diff --git a/.env.example b/.env.example index f77e852..e3596f3 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ LANGCHAIN_ENDPOINT=https://api.smith.langchain.com LLM_PROVIDER=anthropic LLM_MODEL=claude-sonnet-4-6 +# Optional for openai/openrouter: true forces the Responses API, false forces +# Chat Completions. Leave unset to preserve LangChain's automatic selection. +# Enable only when the configured provider/base URL supports Responses. +# FENNEC_LLM_USE_RESPONSES_API=true + # Optional per-agent model overrides (fall back to LLM_MODEL if unset) RECON_LLM_MODEL= ANALYST_LLM_MODEL= diff --git a/CHANGELOG.md b/CHANGELOG.md index f502375..a9a1061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Optional `FENNEC_LLM_USE_RESPONSES_API` setting for OpenAI-compatible model factories, preserving automatic API selection when unset. + ## [0.2.0] - 2026-06-30 ### Added diff --git a/README.md b/README.md index 1ed1d47..a023ed1 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ Everything is `.env`-driven. The knobs that matter: |---|---| | `LLM_PROVIDER` | `anthropic` (default), `openai`, or `openrouter` | | `LLM_MODEL` | Model name for the provider (default `claude-sonnet-4-6`) | +| `FENNEC_LLM_USE_RESPONSES_API` | Optional for `openai` / `openrouter`: `true` selects Responses, `false` selects Chat Completions. Unset preserves LangChain's automatic selection. Applies to default and per-agent models; the configured endpoint must support the selected API. | | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` | One must be set, matching `LLM_PROVIDER` | | `DOCKER_IMAGE` | Kali image tag (default `fennec-linux`, built by `linux/Makefile`) | | `EXECUTION_MODE` | `docker` (default — spawn a Kali sibling per scan) or `local` (run tools in the agent container directly) | diff --git a/pyproject.toml b/pyproject.toml index 3be24d4..06bf1cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "langchain>=0.3.0", "langchain-core>=0.3.0", "langchain-anthropic>=0.2.0", - "langchain-openai>=0.2.0", + "langchain-openai>=0.3.9", "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "httpx>=0.28.0", diff --git a/requirements.txt b/requirements.txt index 788aa54..68f30c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ langgraph>=0.2.60 langchain>=0.3.0 langchain-core>=0.3.0 langchain-anthropic>=0.2.0 -langchain-openai>=0.2.0 +langchain-openai>=0.3.9 # API Framework fastapi>=0.115.0 diff --git a/src/config/settings.py b/src/config/settings.py index 3acb0e9..77e8141 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -28,6 +28,8 @@ class FennecConfig(BaseSettings): # LLM Configuration llm_provider: Literal["anthropic", "openai", "openrouter"] = "anthropic" llm_model: str = "claude-sonnet-4-6" + # None preserves LangChain's automatic selection; gateways can opt in/out. + llm_use_responses_api: Optional[bool] = None openrouter_api_key: Optional[str] = None openrouter_base_url: str = "https://openrouter.ai/api/v1" @@ -194,6 +196,7 @@ def get_default_llm(): model=config.llm_model, api_key=config.openrouter_api_key, base_url=config.openrouter_base_url, + use_responses_api=config.llm_use_responses_api, temperature=config.llm_temperature, max_tokens=config.llm_max_tokens, timeout=config.llm_timeout, @@ -208,6 +211,7 @@ def get_default_llm(): _llm_instance = ChatOpenAI( model=config.llm_model, api_key=config.openai_api_key, + use_responses_api=config.llm_use_responses_api, temperature=config.llm_temperature, max_tokens=config.llm_max_tokens, timeout=config.llm_timeout, @@ -240,6 +244,7 @@ def create_llm(model: str): model=model, api_key=config.openrouter_api_key, base_url=config.openrouter_base_url, + use_responses_api=config.llm_use_responses_api, temperature=config.llm_temperature, max_tokens=config.llm_max_tokens, timeout=config.llm_timeout, @@ -254,6 +259,7 @@ def create_llm(model: str): return ChatOpenAI( model=model, api_key=config.openai_api_key, + use_responses_api=config.llm_use_responses_api, temperature=config.llm_temperature, max_tokens=config.llm_max_tokens, timeout=config.llm_timeout, diff --git a/tests/test_llm_protocol.py b/tests/test_llm_protocol.py new file mode 100644 index 0000000..704be7a --- /dev/null +++ b/tests/test_llm_protocol.py @@ -0,0 +1,128 @@ +"""Offline model factory regressions: real SDK requests, no external API calls.""" + +import json +import os +from unittest.mock import Mock + +import httpx +import pytest +from pydantic import ValidationError + +from src.config import settings + + +@pytest.fixture(autouse=True) +def isolated_config(monkeypatch): + get_config = settings.get_config + for name in list(os.environ): + if name.startswith(("FENNEC_", "LLM_", "OPENAI_", "OPENROUTER_")): + monkeypatch.delenv(name) + monkeypatch.setenv("LANGSMITH_TRACING", "false") + monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false") + get_config.cache_clear() + monkeypatch.setattr(settings, "_llm_instance", None) + yield + get_config.cache_clear() + + +@pytest.mark.parametrize("provider", ["openai", "openrouter"]) +@pytest.mark.parametrize("factory", ["default", "override"]) +@pytest.mark.parametrize("protocol", [None, False, True]) +def test_factories_route_tool_calls(provider, factory, protocol, monkeypatch): + from langchain_openai import ChatOpenAI + + config = settings.FennecConfig( + _env_file=None, + llm_provider=provider, + llm_model="default-model", + llm_use_responses_api=protocol, + openai_api_key="offline-test-key", + openrouter_api_key="offline-test-key", + openrouter_base_url="https://gateway.invalid/v1", + ) + monkeypatch.setattr(settings, "get_config", lambda: config) + requests = [] + expected_model = "default-model" if factory == "default" else "agent-model" + + def respond(request): + requests.append(request) + body = json.loads(request.content) + assert body["model"] == expected_model + assert body["tools"] + if protocol is True: + assert request.url.path == "/v1/responses" + return httpx.Response(200, json={ + "id": "resp_offline", "object": "response", "created_at": 1, + "model": expected_model, "status": "completed", + "output": [{"type": "function_call", "id": "fc_offline", + "call_id": "call_offline", "name": "readiness_probe", + "arguments": '{"marker":"READY"}', "status": "completed"}], + "parallel_tool_calls": True, "tools": [], "tool_choice": "auto", + }) + assert request.url.path == "/v1/chat/completions" + return httpx.Response(200, json={ + "id": "chatcmpl_offline", "object": "chat.completion", "created": 1, + "model": expected_model, + "choices": [{"index": 0, "finish_reason": "tool_calls", "message": { + "role": "assistant", "content": None, + "tool_calls": [{"id": "call_offline", "type": "function", "function": { + "name": "readiness_probe", "arguments": '{"marker":"READY"}', + }}], + }}], + }) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + async_client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + + def make_model(**kwargs): + # Use the actual SDK/serializer while replacing its HTTP transport only. + return ChatOpenAI(**kwargs, http_client=client, http_async_client=async_client) + + monkeypatch.setattr("langchain_openai.ChatOpenAI", make_model) + model = (settings.get_default_llm() if factory == "default" + else settings.create_llm("agent-model")) + assert model.use_responses_api is protocol + tool = {"type": "function", "function": { + "name": "readiness_probe", "description": "Return a fixed test marker.", + "parameters": {"type": "object", "properties": {"marker": {"type": "string"}}, + "required": ["marker"]}, + }} + result = model.bind_tools([tool]).invoke("Return the fixed readiness marker.") + + assert len(requests) == 1 + assert result.tool_calls[0]["name"] == "readiness_probe" + assert result.tool_calls[0]["args"] == {"marker": "READY"} + if provider == "openrouter": + assert requests[0].url.host == "gateway.invalid" + assert requests[0].headers["X-Title"] == "Fennec AI" + + +@pytest.mark.parametrize("value, expected", [("true", True), ("false", False)]) +def test_environment_protocol_selection(value, expected, monkeypatch): + monkeypatch.setenv("FENNEC_LLM_USE_RESPONSES_API", value) + assert settings.FennecConfig(_env_file=None).llm_use_responses_api is expected + + +def test_unset_protocol_preserves_auto_selection(): + assert settings.FennecConfig(_env_file=None).llm_use_responses_api is None + + +def test_invalid_protocol_is_rejected(monkeypatch): + monkeypatch.setenv("FENNEC_LLM_USE_RESPONSES_API", "not-a-boolean") + with pytest.raises(ValidationError): + settings.FennecConfig(_env_file=None) + + +@pytest.mark.parametrize("factory", ["default", "override"]) +def test_responses_option_is_not_passed_to_anthropic(factory, monkeypatch): + config = settings.FennecConfig(_env_file=None, llm_provider="anthropic", + llm_use_responses_api=True, + anthropic_api_key="offline-test-key") + monkeypatch.setattr(settings, "get_config", lambda: config) + constructor = Mock() + monkeypatch.setattr("langchain_anthropic.ChatAnthropic", constructor) + if factory == "default": + settings.get_default_llm() + else: + settings.create_llm("agent-model") + assert "use_responses_api" not in constructor.call_args.kwargs