Skip to content

Commit 62877ee

Browse files
fix: use latest UI LLM settings for ingest
1 parent ff54396 commit 62877ee

8 files changed

Lines changed: 188 additions & 28 deletions

File tree

openkb/api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,6 @@ async def add_endpoint(
248248
_: None = Depends(require_bearer_token),
249249
) -> Any:
250250
resolved_kb_dir = _resolve_kb(kb)
251-
bundle = resolve_credential_bundle(resolved_kb_dir)
252251
if not files:
253252
raise HTTPException(
254253
status_code=status.HTTP_400_BAD_REQUEST,
@@ -263,10 +262,10 @@ async def add_endpoint(
263262
saved_uploads = await _write_add_uploads(reserved, files)
264263
if _parse_stream_form(stream):
265264
return StreamingResponse(
266-
_stream_add_uploads(kb, resolved_kb_dir, saved_uploads, bundle=bundle),
265+
_stream_add_uploads(kb, resolved_kb_dir, saved_uploads),
267266
media_type="text/event-stream",
268267
)
269-
return await _run_add_uploads(kb, resolved_kb_dir, saved_uploads, bundle=bundle)
268+
return await _run_add_uploads(kb, resolved_kb_dir, saved_uploads)
270269

271270
@app.post("/api/v1/query", response_model=QueryResponse)
272271
async def query_endpoint(

openkb/api_helpers.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -335,21 +335,17 @@ async def _run_add_uploads(
335335
kb: str,
336336
kb_dir: Path,
337337
saved_uploads: list[tuple[Path, str]],
338-
*,
339-
bundle=None,
340338
) -> AddResponse:
341339
results = []
342340
for saved_path, original_name in saved_uploads:
343-
results.append(await _add_saved_file(kb_dir, saved_path, original_name, bundle=bundle))
341+
results.append(await _add_saved_file(kb_dir, saved_path, original_name))
344342
return _summarize_add_results(kb, results)
345343

346344

347345
async def _stream_add_uploads(
348346
kb: str,
349347
kb_dir: Path,
350348
saved_uploads: list[tuple[Path, str]],
351-
*,
352-
bundle=None,
353349
) -> AsyncIterator[str]:
354350
yield _sse(
355351
"start",
@@ -366,7 +362,7 @@ async def _stream_add_uploads(
366362
"file_start",
367363
{"original_name": original_name, "saved_path": str(saved_path)},
368364
)
369-
item = await _add_saved_file(kb_dir, saved_path, original_name, bundle=bundle)
365+
item = await _add_saved_file(kb_dir, saved_path, original_name)
370366
results.append(item)
371367
yield _sse("file_done", _model_payload(item))
372368
final = _summarize_add_results(kb, results)
@@ -378,10 +374,8 @@ async def _stream_add_uploads(
378374
yield _sse("done", {})
379375

380376

381-
async def _add_saved_file(
382-
kb_dir: Path, saved_path: Path, original_name: str, *, bundle=None
383-
) -> AddFileItem:
384-
result = await run_in_threadpool(_add_for_api, saved_path, kb_dir, bundle=bundle)
377+
async def _add_saved_file(kb_dir: Path, saved_path: Path, original_name: str) -> AddFileItem:
378+
result = await run_in_threadpool(_add_for_api, saved_path, kb_dir)
385379
item = AddFileItem(**result.__dict__)
386380
item.original_name = original_name
387381
if item.status == "skipped":

openkb/cli.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,12 @@ def commit_body(snapshot) -> None:
527527
try:
528528
from openkb.indexer import index_long_document
529529

530-
index_result = index_long_document(result.raw_path, kb_dir, doc_name=doc_name)
530+
index_result = index_long_document(
531+
result.raw_path,
532+
kb_dir,
533+
doc_name=doc_name,
534+
bundle=bundle,
535+
)
531536
except Exception as exc:
532537
click.echo(f" [ERROR] Indexing failed: {exc}")
533538
logger.debug("Indexing traceback:", exc_info=True)
@@ -647,7 +652,7 @@ class AddFileResult:
647652
message: str
648653

649654

650-
def _add_for_api(file_path: Path, kb_dir: Path, *, bundle=None) -> AddFileResult:
655+
def _add_for_api(file_path: Path, kb_dir: Path) -> AddFileResult:
651656
"""Run the locked add pipeline and return a structured result for the API.
652657
653658
Reuses the upstream ``add_single_file`` (which already holds the ingest
@@ -656,6 +661,13 @@ def _add_for_api(file_path: Path, kb_dir: Path, *, bundle=None) -> AddFileResult
656661
``AddFileResult``; on ``skipped`` the caller (api._add_saved_file) deletes
657662
the freshly uploaded raw copy to avoid orphaning it.
658663
"""
664+
# Resolve at the start of EACH file ingest, not when an upload request or
665+
# long-lived watcher starts. Settings can be changed from the Workbench
666+
# while either is active; a captured bundle would keep using the old model,
667+
# key, base URL, headers, and timeout until the next request/restart.
668+
from openkb.config import resolve_credential_bundle
669+
670+
bundle = resolve_credential_bundle(kb_dir)
659671
status_str = add_single_file(file_path, kb_dir, bundle=bundle)
660672
if status_str == "skipped":
661673
message = f"Already in knowledge base: {file_path.name}"

openkb/indexer.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,27 @@
1111

1212
from pageindex import IndexConfig, PageIndexClient
1313

14-
from openkb.config import resolve_concurrency, resolve_effective_config
14+
from openkb.config import LlmCredentialBundle, resolve_concurrency, resolve_effective_config
1515
from openkb.tree_renderer import render_summary_md
1616

1717
logger = logging.getLogger(__name__)
1818

1919

20+
class _PerRequestPageIndexClient(PageIndexClient):
21+
"""PageIndex local client for credentials supplied in ``IndexConfig``.
22+
23+
PageIndex validates only process-wide provider environment variables during
24+
construction, before its scoped ``llm_params`` reach LiteLLM. REST requests
25+
deliberately keep credentials out of process globals, so this client skips
26+
that premature check; LiteLLM still validates the per-call key/base URL when
27+
the indexing request is made.
28+
"""
29+
30+
@staticmethod
31+
def _validate_llm_provider(model: str) -> None:
32+
return None
33+
34+
2035
@dataclass
2136
class IndexResult:
2237
"""Result of indexing a long document via PageIndex."""
@@ -153,7 +168,11 @@ def _write_long_doc_artifacts(
153168
return summary_path
154169

155170

156-
def _build_index_config(config: dict[str, Any]) -> IndexConfig:
171+
def _build_index_config(
172+
config: dict[str, Any],
173+
*,
174+
bundle: LlmCredentialBundle | None = None,
175+
) -> IndexConfig:
157176
"""Build the PageIndex ``IndexConfig`` for local indexing.
158177
159178
Forwards the KB's ``concurrency`` setting to PageIndex, which caps how many
@@ -177,10 +196,29 @@ def _build_index_config(config: dict[str, Any]) -> IndexConfig:
177196
"config: 'concurrency' is set but the installed PageIndex "
178197
"version does not support it yet — ignoring it."
179198
)
199+
if bundle is not None:
200+
llm_params = {
201+
key: value
202+
for key, value in {
203+
"api_key": bundle.api_key,
204+
"base_url": bundle.base_url,
205+
"extra_headers": bundle.extra_headers or None,
206+
"timeout": bundle.timeout,
207+
}.items()
208+
if value is not None
209+
}
210+
if llm_params:
211+
kwargs["llm_params"] = llm_params
180212
return IndexConfig(**kwargs)
181213

182214

183-
def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = None) -> IndexResult:
215+
def index_long_document(
216+
pdf_path: Path,
217+
kb_dir: Path,
218+
doc_name: str | None = None,
219+
*,
220+
bundle: LlmCredentialBundle | None = None,
221+
) -> IndexResult:
184222
"""Index a long PDF document using PageIndex and write wiki pages.
185223
186224
``doc_name`` is the collision-resistant wiki name used for all written
@@ -193,9 +231,14 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
193231
model: str = config.get("model", "gpt-5.4")
194232
pageindex_api_key = os.environ.get("PAGEINDEX_API_KEY", "")
195233

196-
index_config = _build_index_config(config)
234+
index_config = _build_index_config(config, bundle=bundle)
197235

198-
client = PageIndexClient(
236+
client_type = (
237+
_PerRequestPageIndexClient
238+
if bundle is not None and bundle.api_key and not pageindex_api_key
239+
else PageIndexClient
240+
)
241+
client = client_type(
199242
api_key=pageindex_api_key or None,
200243
model=model,
201244
storage_path=str(openkb_dir),

openkb/watch_service.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
from watchdog.observers import Observer
3232

3333
from openkb.cli import SUPPORTED_EXTENSIONS, _add_for_api
34-
from openkb.config import LlmCredentialBundle, resolve_credential_bundle
3534
from openkb.watcher import start_watch
3635

3736
# How many recent events to retain per KB for status() and SSE replay.
@@ -55,7 +54,6 @@ class WatcherState:
5554
counters: dict[str, int] = field(
5655
default_factory=lambda: {"added": 0, "skipped": 0, "failed": 0}
5756
)
58-
bundle: LlmCredentialBundle | None = None
5957
_seq: int = 0
6058
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
6159

@@ -130,7 +128,7 @@ def _process_file(state: WatcherState, raw_path: str) -> None:
130128
},
131129
)
132130
try:
133-
result = _add_for_api(path, state.kb_dir, bundle=state.bundle)
131+
result = _add_for_api(path, state.kb_dir)
134132
except Exception as exc: # worker must never die
135133
_record_event(
136134
state,
@@ -184,16 +182,11 @@ def start(self, kb: str, kb_dir: Path, debounce: float = 2.0) -> WatcherState:
184182
self._watchers.pop(kb, None)
185183
raw_dir = kb_dir / "raw"
186184
raw_dir.mkdir(parents=True, exist_ok=True)
187-
# Resolve the KB's credentials once at watcher start so background
188-
# ingests use the same per-KB bundle as REST endpoints instead of
189-
# mutating process-wide env state.
190-
bundle = resolve_credential_bundle(kb_dir)
191185
state = WatcherState(
192186
kb=kb,
193187
kb_dir=kb_dir,
194188
raw_dir=raw_dir,
195189
debounce=debounce,
196-
bundle=bundle,
197190
started_at=time.time(),
198191
events=deque(maxlen=self._max_events),
199192
)

tests/test_add_command.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,38 @@ def test_returns_none_if_no_openkb(self, tmp_path, monkeypatch):
4141
assert result is None
4242

4343

44+
def test_add_for_api_resolves_latest_persisted_credentials_each_call(tmp_path):
45+
from openkb.cli import _add_for_api
46+
47+
(tmp_path / ".openkb").mkdir()
48+
(tmp_path / ".openkb" / "config.yaml").write_text("model: openai/cs/abc_ex\n", encoding="utf-8")
49+
env_path = tmp_path / ".env"
50+
env_path.write_text(
51+
"LLM_API_KEY=old-key\nOPENAI_API_BASE=https://old.example/v1\n",
52+
encoding="utf-8",
53+
)
54+
doc = tmp_path / "doc.md"
55+
doc.write_text("# Doc\n", encoding="utf-8")
56+
seen = []
57+
58+
def fake_add(_path, _kb_dir, *, bundle=None):
59+
seen.append((bundle.api_key, bundle.base_url))
60+
return "added"
61+
62+
with patch("openkb.cli.add_single_file", side_effect=fake_add):
63+
_add_for_api(doc, tmp_path)
64+
env_path.write_text(
65+
"LLM_API_KEY=new-key\nOPENAI_API_BASE=https://new.example/v1\n",
66+
encoding="utf-8",
67+
)
68+
_add_for_api(doc, tmp_path)
69+
70+
assert seen == [
71+
("old-key", "https://old.example/v1"),
72+
("new-key", "https://new.example/v1"),
73+
]
74+
75+
4476
class TestAddCommand:
4577
def _setup_kb(self, tmp_path):
4678
"""Create a minimal KB structure."""

tests/test_indexer.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from unittest.mock import MagicMock, patch
77

88
import pytest
9+
from pageindex import PageIndexClient
910

11+
from openkb.config import LlmCredentialBundle
1012
from openkb.indexer import (
1113
IndexResult,
1214
_build_index_config,
@@ -94,6 +96,23 @@ def test_no_warning_when_supported(self, monkeypatch, caplog):
9496
_build_index_config({"concurrency": 8})
9597
assert caplog.text == ""
9698

99+
def test_forwards_rest_credentials_as_pageindex_per_call_params(self):
100+
bundle = LlmCredentialBundle(
101+
api_key="ui-key",
102+
base_url="https://gateway.example/v1",
103+
extra_headers={"X-Tenant": "alpha"},
104+
timeout=45,
105+
)
106+
107+
cfg = _build_index_config({}, bundle=bundle)
108+
109+
assert cfg.llm_params == {
110+
"api_key": "ui-key",
111+
"base_url": "https://gateway.example/v1",
112+
"extra_headers": {"X-Tenant": "alpha"},
113+
"timeout": 45,
114+
}
115+
97116

98117
class TestNormalizePageContent:
99118
def test_normalizes_pageindex_dicts(self):
@@ -313,6 +332,57 @@ def test_concurrency_flows_from_kb_config(self, kb_dir, sample_tree, tmp_path):
313332
_, kwargs = mock_cls.call_args
314333
assert kwargs["index_config"].max_concurrency == 7
315334

335+
def test_rest_bundle_reaches_local_pageindex_client(self, kb_dir, sample_tree, tmp_path):
336+
doc_id = "bundle-123"
337+
fake_col = self._make_fake_collection(doc_id, sample_tree)
338+
fake_client = MagicMock()
339+
fake_client.collection.return_value = fake_col
340+
bundle = LlmCredentialBundle(
341+
api_key="ui-key",
342+
base_url="https://gateway.example/v1",
343+
)
344+
pdf_path = tmp_path / "report.pdf"
345+
pdf_path.write_bytes(b"%PDF-1.4 fake")
346+
347+
with (
348+
patch(
349+
"openkb.indexer._PerRequestPageIndexClient",
350+
return_value=fake_client,
351+
) as mock_cls,
352+
patch("openkb.images.convert_pdf_to_pages", return_value=self._fake_pages()),
353+
):
354+
index_long_document(pdf_path, kb_dir, bundle=bundle)
355+
356+
_, kwargs = mock_cls.call_args
357+
assert kwargs["index_config"].llm_params == {
358+
"api_key": "ui-key",
359+
"base_url": "https://gateway.example/v1",
360+
}
361+
362+
def test_rest_bundle_does_not_require_provider_key_in_process_env(
363+
self, kb_dir, sample_tree, tmp_path, monkeypatch
364+
):
365+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
366+
monkeypatch.setattr("litellm.api_key", None)
367+
(kb_dir / ".openkb" / "config.yaml").write_text(
368+
"model: openai/cs/abc_ex\n", encoding="utf-8"
369+
)
370+
fake_col = self._make_fake_collection("bundle-456", sample_tree)
371+
bundle = LlmCredentialBundle(
372+
api_key="ui-key",
373+
base_url="https://gateway.example/v1",
374+
)
375+
pdf_path = tmp_path / "report.pdf"
376+
pdf_path.write_bytes(b"%PDF-1.4 fake")
377+
378+
with (
379+
patch.object(PageIndexClient, "collection", return_value=fake_col),
380+
patch("openkb.images.convert_pdf_to_pages", return_value=self._fake_pages()),
381+
):
382+
result = index_long_document(pdf_path, kb_dir, bundle=bundle)
383+
384+
assert result.doc_id == "bundle-456"
385+
316386
def test_cloud_page_content_is_normalized(self, kb_dir, sample_tree, tmp_path, monkeypatch):
317387
doc_id = "cloud-123"
318388
fake_col = self._make_fake_collection(doc_id, sample_tree)

tests/test_watch_service.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pathlib import Path
1515

1616
from openkb.cli import AddFileResult
17+
from openkb.config import LlmCredentialBundle
1718
from openkb.watch_service import (
1819
WatcherState,
1920
WatchRegistry,
@@ -117,6 +118,22 @@ def test_worker_added_records_file_start_done_and_counter(kb_dir, monkeypatch):
117118
assert state.counters == {"added": 1, "skipped": 0, "failed": 0}
118119

119120

121+
def test_worker_does_not_reuse_bundle_captured_at_watcher_start(kb_dir, monkeypatch):
122+
state = _make_state(kb_dir)
123+
state.bundle = LlmCredentialBundle(api_key="stale-key")
124+
state.queue.put([str(kb_dir / "raw" / "paper.md")])
125+
seen = []
126+
127+
def fake_add(path, kb, bundle=None):
128+
seen.append(bundle)
129+
return AddFileResult(path.name, str(path), "added", "Added.")
130+
131+
monkeypatch.setattr("openkb.watch_service._add_for_api", fake_add)
132+
_drain_worker(state)
133+
134+
assert seen == [None]
135+
136+
120137
def test_worker_skipped_and_failed_branches(kb_dir, monkeypatch):
121138
state = _make_state(kb_dir)
122139
state.queue.put([str(kb_dir / "raw" / "dup.md"), str(kb_dir / "raw" / "boom.md")])

0 commit comments

Comments
 (0)