Skip to content

Commit 29af484

Browse files
committed
feat(config): pass through LiteLLM settings via a litellm: config block
Add an optional `litellm:` mapping in .openkb/config.yaml as the single place to tune LiteLLM. Keys are forwarded to LiteLLM: `timeout` and `extra_headers` apply per request (the existing per-call mechanism, covering both the compiler and the agents-SDK paths), and the rest are set as litellm module globals (drop_params, num_retries, ssl_verify, ...). Resolves the Ollama UnsupportedParamsError in #137: `litellm: {drop_params: true}` lets LiteLLM drop params a provider rejects (e.g. parallel_tool_calls on Ollama). - config.resolve_litellm_settings(): validate the value is a mapping, drop non-string keys, pass values through verbatim (the user owns them). - cli._setup_llm_key(): the `litellm:` block is canonical — when it specifies `timeout` / `extra_headers` they route to the per-call stashes and replace the legacy top-level keys (an empty `extra_headers: {}` clears, not reverts); the rest go to cli._apply_litellm_settings(), which setattrs each onto litellm. Guards: skip+warn an unknown key, and refuse to overwrite a litellm function. Globals are applied process-wide and not reset. - Back-compat: the legacy top-level `timeout:` / `extra_headers:` keys still work (now undocumented; the `litellm:` block is the documented surface). - warnings go through the logger (stderr), not click.echo, so a typo can't corrupt piped stdout (e.g. `openkb query > out.txt`). - docs: config.yaml.example consolidated under `litellm:`; README drops the now-redundant top-level extra_headers section. Closes #137
1 parent b2a4775 commit 29af484

6 files changed

Lines changed: 344 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -369,19 +369,11 @@ Model names use `provider/model` LiteLLM [format](https://docs.litellm.ai/docs/p
369369
| Gemini | `gemini/gemini-3.1-pro-preview` |
370370

371371
<details>
372-
<summary><i>Advanced options (<code>entity_types</code>, <code>extra_headers</code>, OAuth):</i></summary>
372+
<summary><i>Advanced options (<code>entity_types</code>, OAuth):</i></summary>
373373
<br>
374374

375375
`entity_types` (optional): a YAML list overriding the entity-type vocabulary used for entity pages; omit it to use the default `person`, `organization`, `place`, `product`, `work`, `event`, `other`.
376376

377-
`extra_headers` (optional): a YAML mapping of extra HTTP headers sent with every LLM request (forwarded to LiteLLM's `extra_headers`). Useful for providers that expect custom headers, e.g. GitHub Copilot IDE-auth headers:
378-
379-
```yaml
380-
extra_headers:
381-
Editor-Version: vscode/1.95.0
382-
Copilot-Integration-Id: vscode-chat
383-
```
384-
385377
Subscription-based providers that authenticate via OAuth device flow (e.g. `chatgpt/*`, `github_copilot/*`) need no API key; OpenKB skips the missing-key warning for them.
386378

387379
</details>

config.yaml.example

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider)
22
language: en # Wiki output language
33
pageindex_threshold: 20 # PDF pages threshold for PageIndex
44

5-
# Optional: extra HTTP headers sent with every LLM request (forwarded to
6-
# LiteLLM's extra_headers). Some providers need these — e.g. GitHub Copilot
7-
# IDE-auth headers on older litellm versions:
8-
# extra_headers:
9-
# Editor-Version: vscode/1.95.0
10-
# Copilot-Integration-Id: vscode-chat
11-
125
# Optional: override the entity-type vocabulary used for entity pages.
136
# Omit this key to use the default 7 types
147
# (person, organization, place, product, work, event, other).
@@ -18,6 +11,12 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
1811
# - dataset
1912
# - model
2013

21-
# Optional: per-request LLM timeout in seconds, forwarded to LiteLLM.
22-
# Defaults to LiteLLM's 600s; raise it for slow local backends (e.g. Ollama).
23-
# timeout: 1200
14+
# Optional: LLM / LiteLLM tuning. Keys are forwarded to LiteLLM; `timeout` and
15+
# `extra_headers` apply per request, the rest are set as litellm.<key>.
16+
# litellm:
17+
# timeout: 1200 # per-request timeout (s); raise for slow local backends (Ollama)
18+
# drop_params: true # let LiteLLM drop params a provider rejects (e.g. Ollama)
19+
# num_retries: 3
20+
# extra_headers: # extra HTTP headers some providers need (e.g. GitHub Copilot)
21+
# Editor-Version: vscode/1.95.0
22+
# Copilot-Integration-Id: vscode-chat

openkb/cli.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def filter(self, record: logging.LogRecord) -> bool:
4444
from openkb.config import (
4545
DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb,
4646
resolve_extra_headers, set_extra_headers, resolve_timeout, set_timeout,
47+
resolve_litellm_settings,
4748
)
4849
from openkb.converter import _registry_path, convert_document
4950
from openkb.locks import atomic_write_json, atomic_write_text, kb_ingest_lock, kb_read_lock
@@ -56,6 +57,8 @@ def filter(self, record: logging.LogRecord) -> bool:
5657

5758
load_dotenv() # load from cwd (covers running inside the KB dir)
5859

60+
logger = logging.getLogger(__name__)
61+
5962

6063
_KNOWN_PROVIDER_KEYS = (
6164
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY",
@@ -83,6 +86,31 @@ def _extract_provider(model: str) -> str | None:
8386
return "openai"
8487

8588

89+
def _apply_litellm_settings(settings: dict) -> None:
90+
"""Set each ``litellm:`` key verbatim onto the litellm module (process-wide
91+
globals, so they reach every LiteLLM call). Skips with a warning a key the
92+
installed litellm doesn't define, or one that is a litellm function (e.g.
93+
``completion``) since overwriting it would break later calls. Applied, never
94+
reset — the values persist for the life of the process.
95+
"""
96+
for key, value in settings.items():
97+
if not hasattr(litellm, key):
98+
logger.warning(
99+
"config: LiteLLM has no setting %r — ignoring it "
100+
"(check the spelling or your installed litellm version).",
101+
key,
102+
)
103+
continue
104+
if callable(getattr(litellm, key)):
105+
logger.warning(
106+
"config: 'litellm.%s' is a LiteLLM function, not a setting — "
107+
"refusing to overwrite it from the litellm: config block.",
108+
key,
109+
)
110+
continue
111+
setattr(litellm, key, value)
112+
113+
86114
def _setup_llm_key(kb_dir: Path | None = None) -> None:
87115
"""Set LiteLLM API key from LLM_API_KEY env var if present.
88116
@@ -113,6 +141,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
113141
provider: str | None = None
114142
extra_headers: dict[str, str] = {}
115143
timeout: float | None = None
144+
litellm_settings: dict = {}
116145
if kb_dir is not None:
117146
config_path = kb_dir / ".openkb" / "config.yaml"
118147
if config_path.exists():
@@ -121,8 +150,24 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
121150
provider = _extract_provider(str(model))
122151
extra_headers = resolve_extra_headers(config)
123152
timeout = resolve_timeout(config)
153+
litellm_settings = resolve_litellm_settings(config)
154+
# The litellm: block is the canonical place for LLM tuning. It may
155+
# also carry `timeout` / `extra_headers`; those route to the per-call
156+
# mechanisms, while the rest are applied as litellm module globals.
157+
# When the block specifies one of these keys it is authoritative —
158+
# its resolved value replaces the legacy top-level one (so an empty
159+
# `extra_headers: {}` clears rather than reverting to the old value).
160+
if "extra_headers" in litellm_settings:
161+
extra_headers = resolve_extra_headers(
162+
{"extra_headers": litellm_settings.pop("extra_headers")}
163+
)
164+
if "timeout" in litellm_settings:
165+
timeout = resolve_timeout(
166+
{"timeout": litellm_settings.pop("timeout")}
167+
)
124168
set_extra_headers(extra_headers)
125169
set_timeout(timeout)
170+
_apply_litellm_settings(litellm_settings)
126171

127172
if not api_key:
128173
# Check if any provider key is already set. OAuth-based providers
@@ -304,7 +349,6 @@ def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", "
304349
from openkb.agent.compiler import compile_long_doc, compile_short_doc
305350
from openkb.state import HashRegistry
306351

307-
logger = logging.getLogger(__name__)
308352
openkb_dir = kb_dir / ".openkb"
309353
config = load_config(openkb_dir / "config.yaml")
310354
_setup_llm_key(kb_dir)

openkb/config.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,34 @@ def resolve_timeout(config: dict) -> float | None:
172172
return value
173173

174174

175+
def resolve_litellm_settings(config: dict) -> dict[str, Any]:
176+
"""Resolve the optional ``litellm:`` mapping of LiteLLM module settings.
177+
178+
Values are forwarded verbatim (the user owns them); only the container shape
179+
is enforced — returns ``{}`` if absent or not a mapping, and drops non-string
180+
keys. ``cli._apply_litellm_settings`` applies them.
181+
"""
182+
raw = config.get("litellm")
183+
if raw is None:
184+
return {}
185+
if not isinstance(raw, dict):
186+
logger.warning(
187+
"config: 'litellm' must be a mapping of LiteLLM settings, got %s — "
188+
"ignoring it.",
189+
type(raw).__name__,
190+
)
191+
return {}
192+
settings: dict[str, Any] = {}
193+
for key, value in raw.items():
194+
if not isinstance(key, str):
195+
logger.warning(
196+
"config: skipping 'litellm' entry with non-string key %r.", key
197+
)
198+
continue
199+
settings[key] = value
200+
return settings
201+
202+
175203
# Process-wide extra headers for LLM requests, resolved from the active KB's
176204
# config by the CLI entry points (cli._setup_llm_key). LLM call sites read it
177205
# via get_extra_headers() so the value doesn't have to be threaded through

tests/test_config.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import logging
2+
13
from openkb.config import (
24
DEFAULT_CONFIG,
35
get_extra_headers,
46
get_timeout,
57
load_config,
68
resolve_extra_headers,
9+
resolve_litellm_settings,
710
resolve_timeout,
811
save_config,
912
set_extra_headers,
@@ -150,3 +153,41 @@ def test_timeout_stash_roundtrip_and_reset():
150153
assert get_timeout() == 1200.0
151154
set_timeout(None)
152155
assert get_timeout() is None
156+
157+
158+
def test_resolve_litellm_settings_absent_returns_empty():
159+
assert resolve_litellm_settings({}) == {}
160+
161+
162+
def test_resolve_litellm_settings_passes_mapping_through_verbatim():
163+
# Values are forwarded as-is — no validation or coercion.
164+
config = {"litellm": {"drop_params": True, "num_retries": 3, "ssl_verify": False}}
165+
assert resolve_litellm_settings(config) == {
166+
"drop_params": True,
167+
"num_retries": 3,
168+
"ssl_verify": False,
169+
}
170+
171+
172+
def test_resolve_litellm_settings_non_mapping_ignored():
173+
assert resolve_litellm_settings({"litellm": ["drop_params"]}) == {}
174+
assert resolve_litellm_settings({"litellm": "drop_params=true"}) == {}
175+
assert resolve_litellm_settings({"litellm": True}) == {}
176+
177+
178+
def test_resolve_litellm_settings_drops_non_string_keys():
179+
assert resolve_litellm_settings({"litellm": {5: "x", "drop_params": True}}) == {
180+
"drop_params": True
181+
}
182+
183+
184+
def test_resolve_litellm_settings_warns_on_non_mapping(caplog):
185+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
186+
assert resolve_litellm_settings({"litellm": ["drop_params"]}) == {}
187+
assert "must be a mapping" in caplog.text
188+
189+
190+
def test_resolve_litellm_settings_warns_on_non_string_key(caplog):
191+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
192+
resolve_litellm_settings({"litellm": {5: "x", "drop_params": True}})
193+
assert "non-string key" in caplog.text

0 commit comments

Comments
 (0)