-
Notifications
You must be signed in to change notification settings - Fork 347
Expand file tree
/
Copy pathconfig.py
More file actions
257 lines (209 loc) · 8.93 KB
/
Copy pathconfig.py
File metadata and controls
257 lines (209 loc) · 8.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
from __future__ import annotations
import contextlib
import logging
import math
import re
from pathlib import Path
from typing import Any, Iterator
import yaml
from openkb.locks import atomic_write_text, flock, funlock
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict[str, Any] = {
"model": "gpt-5.4-mini",
"language": "en",
"pageindex_threshold": 20,
"pdf_parser": "local",
}
# Default entity-type vocabulary. Overridable per-KB via the optional
# ``entity_types:`` config key (see ``resolve_entity_types``).
DEFAULT_ENTITY_TYPES: tuple[str, ...] = (
"person", "organization", "place", "product", "work", "event", "other",
)
GLOBAL_CONFIG_DIR = Path.home() / ".config" / "openkb"
GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / "global.yaml"
GLOBAL_CONFIG_LOCK_PATH = GLOBAL_CONFIG_DIR / "global.lock"
@contextlib.contextmanager
def _with_global_config_lock() -> Iterator[None]:
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with GLOBAL_CONFIG_LOCK_PATH.open("a+", encoding="utf-8") as fh:
flock(fh, exclusive=True)
try:
yield
finally:
funlock(fh)
def _atomic_yaml_dump(path: Path, config: dict[str, Any]) -> None:
atomic_write_text(
path,
yaml.safe_dump(config, allow_unicode=True, sort_keys=True),
)
def _load_global_config_unlocked() -> dict[str, Any]:
if GLOBAL_CONFIG_PATH.exists():
with GLOBAL_CONFIG_PATH.open("r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
return {}
def resolve_entity_types(config: dict) -> list[str]:
"""Resolve the effective entity-type list from a loaded config dict.
If ``config["entity_types"]`` is a non-empty list, each string item is
cleaned (lowercased, trimmed, restricted to ``[a-z0-9 _-]`` so a stray
brace/punctuation can't leak into a prompt template or frontmatter value);
non-string items (YAML nulls, numbers) are skipped. The cleaned list is
de-duped (order preserving) and ``"other"`` is always appended when missing
(it is the coercion fallback). Otherwise — key absent, not a list, empty,
or fully malformed — :data:`DEFAULT_ENTITY_TYPES` is returned, so behavior
is byte-identical to the default. A warning is logged only when
``entity_types`` was present-but-malformed.
"""
raw = config.get("entity_types")
if raw is None:
return list(DEFAULT_ENTITY_TYPES)
if not isinstance(raw, list):
logger.warning(
"config: 'entity_types' must be a list of strings, got %s — "
"falling back to the default entity types.",
type(raw).__name__,
)
return list(DEFAULT_ENTITY_TYPES)
cleaned: list[str] = []
for x in raw:
if not isinstance(x, str):
continue # skip YAML nulls/numbers (str(None) would become "none")
s = re.sub(r"[^a-z0-9 _-]+", "", x.strip().lower()).strip()
if s and s not in cleaned:
cleaned.append(s)
if not cleaned:
logger.warning(
"config: 'entity_types' was present but yielded no usable values — "
"falling back to the default entity types.",
)
return list(DEFAULT_ENTITY_TYPES)
if "other" not in cleaned:
cleaned.append("other")
return cleaned
def resolve_extra_headers(config: dict) -> dict[str, str]:
"""Resolve the optional ``extra_headers:`` config key into a str→str dict.
Some LiteLLM providers need extra HTTP headers on every request (e.g.
GitHub Copilot's ``Editor-Version`` IDE-auth headers). Users opt in via
an ``extra_headers:`` mapping in config.yaml; the result is forwarded to
LiteLLM's ``extra_headers`` parameter on all LLM calls.
Values are stringified (YAML may parse version-like values as numbers).
Entries with a non-string/empty key or a non-scalar value are skipped.
A non-mapping ``extra_headers`` is ignored entirely. Warnings are logged
only when the key was present but malformed.
"""
raw = config.get("extra_headers")
if raw is None:
return {}
if not isinstance(raw, dict):
logger.warning(
"config: 'extra_headers' must be a mapping of header name to "
"value, got %s — ignoring it.",
type(raw).__name__,
)
return {}
headers: dict[str, str] = {}
for key, value in raw.items():
if not isinstance(key, str) or not key.strip():
logger.warning(
"config: skipping 'extra_headers' entry with non-string "
"or empty key: %r", key,
)
continue
if value is None or not isinstance(value, (str, int, float, bool)):
logger.warning(
"config: skipping 'extra_headers' entry %r with "
"non-scalar value: %r", key, value,
)
continue
headers[key.strip()] = str(value)
return headers
def resolve_timeout(config: dict) -> float | None:
"""Resolve the optional ``timeout:`` key to a finite positive number of seconds.
Returns ``None`` (use LiteLLM's default) when absent or invalid; rejects
bools and ``nan``/``inf``, warning when present but unusable.
"""
raw = config.get("timeout")
if raw is None:
return None
if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
logger.warning(
"config: 'timeout' must be a positive number of seconds, got %s — "
"ignoring it.",
type(raw).__name__,
)
return None
try:
value = float(raw)
except (TypeError, ValueError):
logger.warning(
"config: 'timeout' must be a positive number of seconds, got %r — "
"ignoring it.",
raw,
)
return None
if not math.isfinite(value) or value <= 0:
logger.warning(
"config: 'timeout' must be a finite positive number of seconds, got "
"%s — ignoring it.",
value,
)
return None
return value
# Process-wide extra headers for LLM requests, resolved from the active KB's
# config by the CLI entry points (cli._setup_llm_key). LLM call sites read it
# via get_extra_headers() so the value doesn't have to be threaded through
# every compile/agent call chain — mirroring how the API key is applied
# globally via litellm.api_key / provider env vars.
_runtime_extra_headers: dict[str, str] = {}
def set_extra_headers(headers: dict[str, str]) -> None:
"""Set the process-wide extra headers for LLM requests."""
global _runtime_extra_headers
_runtime_extra_headers = dict(headers)
def get_extra_headers() -> dict[str, str]:
"""Return a copy of the process-wide extra headers for LLM requests."""
return dict(_runtime_extra_headers)
# Process-wide LLM request timeout (seconds), set from config by the CLI and
# read at the call sites via get_timeout(). None = use LiteLLM's default.
_runtime_timeout: float | None = None
def set_timeout(timeout: float | None) -> None:
"""Set the process-wide LLM request timeout in seconds; ``None`` clears it."""
global _runtime_timeout
_runtime_timeout = timeout
def get_timeout() -> float | None:
"""Return the process-wide LLM request timeout in seconds, or ``None``."""
return _runtime_timeout
def get_timeout_extra_args() -> dict[str, float] | None:
"""Timeout as Agents-SDK ``ModelSettings.extra_args`` (it has no ``timeout``
field), or ``None``. The LiteLLM provider forwards it to the completion call.
"""
return {"timeout": _runtime_timeout} if _runtime_timeout is not None else None
def load_config(config_path: Path) -> dict[str, Any]:
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.
If the file does not exist, returns a copy of the defaults.
"""
config = dict(DEFAULT_CONFIG)
if config_path.exists():
with config_path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
config.update(data)
return config
def save_config(config_path: Path, config: dict) -> None:
"""Persist config dict to YAML, creating parent directories as needed."""
_atomic_yaml_dump(config_path, config)
def load_global_config() -> dict[str, Any]:
"""Load the global config from ~/.config/openkb/global.yaml."""
return _load_global_config_unlocked()
def save_global_config(config: dict[str, Any]) -> None:
"""Save the global config to ~/.config/openkb/global.yaml."""
with _with_global_config_lock():
_atomic_yaml_dump(GLOBAL_CONFIG_PATH, config)
def register_kb(kb_path: Path) -> None:
"""Register a KB path in the global config's known_kbs list."""
with _with_global_config_lock():
gc = _load_global_config_unlocked()
known = gc.get("known_kbs", [])
resolved = str(kb_path.resolve())
if resolved not in known:
known.append(resolved)
gc["known_kbs"] = known
gc["default_kb"] = resolved
_atomic_yaml_dump(GLOBAL_CONFIG_PATH, gc)