Răsfoiți Sursa

Added parameters for external LLMs

Efren Yevale Varela 1 lună în urmă
părinte
comite
e388139ecb

+ 7 - 0
config.example.toml

@@ -71,6 +71,13 @@ dir     = "troubleshoot/audit"
 [llm]
 enabled                        = true
 endpoint                       = "http://localhost:9931/v1/chat/completions"
+model                          = ""
+api_key_env                    = ""
+session_id                     = ""
+session_header                 = ""
+user_agent                     = ""
+reasoning_effort               = ""
+extra_body                     = {}
 temperature                    = 0.7
 max_tokens                     = 1024
 timeout_s                      = 120.0

+ 7 - 0
docs/CONFIGURATION.md

@@ -95,6 +95,13 @@ dir     = "troubleshoot/audit"
 [llm]                                       # local llama.cpp (OpenAI-compatible)
 enabled                  = true
 endpoint                 = "http://localhost:9931/v1/chat/completions"
+model                    = ""               # value sent as the request `model`; empty = omit (llama.cpp ignores it, hosted OpenAI-compatible APIs require it)
+api_key_env              = ""               # name of the env var holding the bearer token; empty = no Authorization header (never put the secret here)
+session_id               = ""               # value sent in `session_header`; stable per lane, e.g. "stt-chat"
+session_header           = ""               # header name carrying the session id; OpenCode Go requires "x-opencode-session" (400 MissingSessionID without it)
+user_agent               = ""               # User-Agent header; OpenCode Go asks clients to self-identify (generic SDK names discouraged)
+reasoning_effort         = ""               # OpenAI-style thinking level sent as `reasoning_effort` (e.g. "low"/"medium"/"high"); empty = omit
+extra_body               = {}               # provider-specific request-body passthrough, merged last (e.g. { enable_thinking = true, chat_template_kwargs = { enable_thinking = true } })
 temperature              = 0.7
 max_tokens               = 1024             # max output per request; reasoning models spend tokens before the answer, so keep generous
 timeout_s                = 120.0

+ 47 - 2
src/stt_daemon/assistant/llm.py

@@ -6,6 +6,7 @@ by field name — never by scraping it out of `content`.
 
 import json
 import logging
+import os
 import threading
 import urllib.error
 import urllib.request
@@ -48,7 +49,7 @@ def _default_opener(payload: dict, timeout: float) -> dict:
     request = urllib.request.Request(
         payload["__url__"],
         data=json.dumps(payload["body"]).encode("utf-8"),
-        headers={"Content-Type": "application/json"},
+        headers=payload.get("headers") or {"Content-Type": "application/json"},
     )
     with urllib.request.urlopen(request, timeout=timeout) as response:
         return json.loads(response.read().decode("utf-8"))
@@ -74,6 +75,39 @@ class LlmClient:
         """
         self.cfg = cfg
         self._opener = opener or _default_opener
+        self._warned_missing_key = False
+        self._warned_missing_session = False
+
+    def _headers(self) -> dict[str, str]:
+        """Request headers, adding a bearer token when ``api_key_env`` resolves.
+
+        Also carries an optional session header (e.g. OpenCode Go's
+        ``x-opencode-session``, required by its gateway) and user agent.
+        """
+        headers = {"Content-Type": "application/json"}
+        if self.cfg.api_key_env:
+            api_key = os.environ.get(self.cfg.api_key_env, "")
+            if api_key:
+                headers["Authorization"] = f"Bearer {api_key}"
+            elif not self._warned_missing_key:
+                logger.warning(
+                    "llm.api_key_env %r is set but missing from the environment; "
+                    "sending no Authorization header",
+                    self.cfg.api_key_env,
+                )
+                self._warned_missing_key = True
+        if self.cfg.session_header and self.cfg.session_id:
+            headers[self.cfg.session_header] = self.cfg.session_id
+        elif self.cfg.session_header and not self._warned_missing_session:
+            logger.warning(
+                "llm.session_header %r is set but llm.session_id is empty; "
+                "sending no session header",
+                self.cfg.session_header,
+            )
+            self._warned_missing_session = True
+        if self.cfg.user_agent:
+            headers["User-Agent"] = self.cfg.user_agent
+        return headers
 
     def chat(
         self,
@@ -106,11 +140,17 @@ class LlmClient:
             "cache_prompt": self.cfg.cache_prompt,
             "stream": False,
         }
+        if self.cfg.model:
+            body["model"] = self.cfg.model
         if slot_id is not None:
             body["id_slot"] = slot_id
         if tools:
             body["tools"] = tools
             body["tool_choice"] = "auto"
+        if self.cfg.reasoning_effort:
+            body["reasoning_effort"] = self.cfg.reasoning_effort
+        if self.cfg.extra_body:
+            body.update(self.cfg.extra_body)
 
         if on_io is not None:
             _safe_io(
@@ -119,7 +159,12 @@ class LlmClient:
 
         try:
             payload = self._opener(
-                {"__url__": self.cfg.endpoint, "body": body}, effective_timeout
+                {
+                    "__url__": self.cfg.endpoint,
+                    "body": body,
+                    "headers": self._headers(),
+                },
+                effective_timeout,
             )
         except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
             if on_io is not None:

+ 16 - 0
src/stt_daemon/config.py

@@ -121,6 +121,13 @@ class LlmConfig:
     """llama.cpp (OpenAI-compatible) configuration."""
     enabled: bool = True
     endpoint: str = "http://localhost:9931/v1/chat/completions"
+    model: str = ""
+    api_key_env: str = ""
+    session_id: str = ""
+    session_header: str = ""
+    user_agent: str = ""
+    reasoning_effort: str = ""
+    extra_body: dict[str, Any] = field(default_factory=dict)
     temperature: float = 0.7
     max_tokens: int = 1024
     timeout_s: float = 120.0
@@ -471,6 +478,15 @@ def load_config(path: str | None = None) -> Config:
             cfg.llm = LlmConfig(
                 enabled=llm_data.get("enabled", cfg.llm.enabled),
                 endpoint=llm_data.get("endpoint", cfg.llm.endpoint),
+                model=llm_data.get("model", cfg.llm.model),
+                api_key_env=llm_data.get("api_key_env", cfg.llm.api_key_env),
+                session_id=llm_data.get("session_id", cfg.llm.session_id),
+                session_header=llm_data.get("session_header", cfg.llm.session_header),
+                user_agent=llm_data.get("user_agent", cfg.llm.user_agent),
+                reasoning_effort=llm_data.get(
+                    "reasoning_effort", cfg.llm.reasoning_effort
+                ),
+                extra_body=dict(llm_data.get("extra_body", cfg.llm.extra_body)),
                 temperature=llm_data.get("temperature", cfg.llm.temperature),
                 max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
                 timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),

+ 45 - 0
tests/test_config.py

@@ -463,6 +463,51 @@ inference_queue_size = 3
     assert cfg.llm.prompt_budget == 8192 - 2048 - 128
 
 
+def test_llm_auth_and_thinking_fields_parsed():
+    config_toml = """
+[llm]
+model = "gpt-4o-mini"
+api_key_env = "OPENAI_API_KEY"
+session_id = "stt-chat"
+session_header = "x-opencode-session"
+user_agent = "stt-daemon/1.0"
+reasoning_effort = "high"
+extra_body = { enable_thinking = true, top_k = 20 }
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.llm.model == "gpt-4o-mini"
+    assert cfg.llm.api_key_env == "OPENAI_API_KEY"
+    assert cfg.llm.session_id == "stt-chat"
+    assert cfg.llm.session_header == "x-opencode-session"
+    assert cfg.llm.user_agent == "stt-daemon/1.0"
+    assert cfg.llm.reasoning_effort == "high"
+    assert cfg.llm.extra_body == {"enable_thinking": True, "top_k": 20}
+
+
+def test_llm_auth_and_thinking_defaults():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.llm.model == ""
+    assert cfg.llm.api_key_env == ""
+    assert cfg.llm.session_id == ""
+    assert cfg.llm.session_header == ""
+    assert cfg.llm.user_agent == ""
+    assert cfg.llm.reasoning_effort == ""
+    assert cfg.llm.extra_body == {}
+
+
 def test_max_prompt_tokens_is_deprecated_and_ignored(caplog):
     config_toml = """
 [llm]

+ 107 - 0
tests/test_llm.py

@@ -51,6 +51,113 @@ def test_cache_prompt_and_endpoint_are_sent():
     assert calls[0]["__url__"] == LlmConfig().endpoint
 
 
+def test_model_is_sent_when_set():
+    cfg = LlmConfig(model="gpt-4o-mini")
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["body"]["model"] == "gpt-4o-mini"
+
+
+def test_model_omitted_when_empty():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert "model" not in calls[0]["body"]
+
+
+def test_api_key_env_adds_bearer_header(monkeypatch):
+    monkeypatch.setenv("STT_TEST_LLM_KEY", "sekret")
+    cfg = LlmConfig(api_key_env="STT_TEST_LLM_KEY")
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["headers"]["Authorization"] == "Bearer sekret"
+    assert calls[0]["headers"]["Content-Type"] == "application/json"
+
+
+def test_missing_api_key_env_sends_no_authorization(monkeypatch):
+    monkeypatch.delenv("STT_TEST_LLM_KEY", raising=False)
+    cfg = LlmConfig(api_key_env="STT_TEST_LLM_KEY")
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert "Authorization" not in calls[0]["headers"]
+
+
+def test_no_api_key_sends_only_content_type(monkeypatch):
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["headers"] == {"Content-Type": "application/json"}
+
+
+def test_session_header_and_user_agent_sent_when_set():
+    cfg = LlmConfig(
+        session_header="x-opencode-session",
+        session_id="stt-chat",
+        user_agent="stt-daemon/1.0",
+    )
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["headers"]["x-opencode-session"] == "stt-chat"
+    assert calls[0]["headers"]["User-Agent"] == "stt-daemon/1.0"
+
+
+def test_session_header_omitted_when_id_empty(caplog):
+    cfg = LlmConfig(session_header="x-opencode-session", session_id="")
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert "x-opencode-session" not in calls[0]["headers"]
+    assert "session_id is empty" in caplog.text
+
+
+def test_session_header_omitted_when_header_empty():
+    cfg = LlmConfig(session_header="", session_id="stt-chat")
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert "stt-chat" not in calls[0]["headers"].values()
+    assert calls[0]["headers"] == {"Content-Type": "application/json"}
+
+
+def test_reasoning_effort_is_sent_when_set():
+    cfg = LlmConfig(reasoning_effort="high")
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["body"]["reasoning_effort"] == "high"
+
+
+def test_reasoning_effort_omitted_when_empty():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert "reasoning_effort" not in calls[0]["body"]
+
+
+def test_extra_body_merged_and_overrides_defaults():
+    cfg = LlmConfig(extra_body={"enable_thinking": True, "temperature": 0.1})
+    client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
+
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["body"]["enable_thinking"] is True
+    assert calls[0]["body"]["temperature"] == 0.1
+
+
 def test_tools_sent_only_when_provided():
     client, calls = _make_client(lambda _p: _reply(content="ok"))
     client.chat([{"role": "user", "content": "hi"}])