From cd23188a0545a037505142edc562b96ea7eeb014 Mon Sep 17 00:00:00 2001 From: Sebastian Braun Date: Tue, 8 Sep 2026 09:05:22 +0200 Subject: [PATCH 1/2] fix(indexer): close PageIndex's local SQLite connection and forward LLM credentials index_long_document() now closes PageIndex's local SQLite connection(s) in a finally block covering both the success and the failure path (via a new best-effort _close_pageindex_client() helper, reaching into the pinned pageindex version's private LocalBackend/SQLiteStorage since it exposes no public close()/context-manager API). Without this, a subsequent mutation rollback could not unlink/rename pageindex.db on Windows while this process still held it open (WinError 32), leaving a dirty mutation journal and aborting the rest of a multi-file 'openkb add' batch (#249). Also resolves the KB's LlmCredentialBundle (the same LLM_API_KEY/base_url compiler.py's own _llm_call/_llm_call_async use) and forwards it into PageIndex's own internal LLM calls via IndexConfig(llm_params=...) -- PageIndex's pinned version scopes llm_params per-call (pageindex.config.llm_params_scope, context-isolated, safe under concurrent multi-KB use) but had no wiring from OpenKB's side, so a custom LLM_API_KEY/gateway base_url never reached PageIndex's TOC/tree/summary generation calls, which instead fell back to LiteLLM's default provider-key/env-var lookup (#219). Guarded by the same IndexConfig.model_fields check already used for max_concurrency, so it degrades gracefully against an older pinned pageindex. --- openkb/indexer.py | 227 ++++++++++++++++++++++++++---------------- tests/test_indexer.py | 132 +++++++++++++++++++++++- 2 files changed, 273 insertions(+), 86 deletions(-) diff --git a/openkb/indexer.py b/openkb/indexer.py index 6a4ae1ee0..591d93d6b 100644 --- a/openkb/indexer.py +++ b/openkb/indexer.py @@ -11,7 +11,7 @@ from pageindex import IndexConfig, PageIndexClient -from openkb.config import resolve_concurrency, resolve_effective_config +from openkb.config import resolve_concurrency, resolve_credential_bundle, resolve_effective_config from openkb.tree_renderer import render_summary_md logger = logging.getLogger(__name__) @@ -153,7 +153,7 @@ def _write_long_doc_artifacts( return summary_path -def _build_index_config(config: dict[str, Any]) -> IndexConfig: +def _build_index_config(config: dict[str, Any], bundle=None) -> IndexConfig: """Build the PageIndex ``IndexConfig`` for local indexing. Forwards the KB's ``concurrency`` setting to PageIndex, which caps how many @@ -162,6 +162,15 @@ def _build_index_config(config: dict[str, Any]) -> IndexConfig: installed PageIndex's ``IndexConfig`` declares the field, so OpenKB keeps working against a pinned PageIndex that predates it (``IndexConfig`` forbids unknown kwargs). + + ``bundle``'s ``api_key``/``base_url`` (the same credentials ``compiler.py``'s + own LLM calls use — see :func:`openkb.config.resolve_credential_bundle`) are + forwarded as PageIndex's own per-call ``llm_params`` (see #219): without + this, PageIndex's internal indexing calls (TOC/tree/summary generation) + fall back to LiteLLM's default provider-key/env-var lookup, which doesn't + know about a KB's custom ``LLM_API_KEY``/gateway ``base_url``. Guarded by + the same ``model_fields`` check as ``max_concurrency`` above, so it + degrades gracefully against an older pinned PageIndex. """ kwargs: dict[str, Any] = { "if_add_node_text": True, @@ -177,9 +186,48 @@ def _build_index_config(config: dict[str, Any]) -> IndexConfig: "config: 'concurrency' is set but the installed PageIndex " "version does not support it yet — ignoring it." ) + if bundle is not None: + llm_params = { + key: value + for key, value in {"api_key": bundle.api_key, "base_url": bundle.base_url}.items() + if value + } + if llm_params: + if "llm_params" in IndexConfig.model_fields: + kwargs["llm_params"] = llm_params + else: + logger.warning( + "config: a custom LLM_API_KEY/base_url is set but the installed " + "PageIndex version doesn't support forwarding it (llm_params) yet " + "— PageIndex's own LLM calls will use their default credential " + "lookup instead." + ) return IndexConfig(**kwargs) +def _close_pageindex_client(client: PageIndexClient) -> None: + """Best-effort close of ``client``'s local SQLite connection(s). + + The pinned ``pageindex`` version has no public ``close()``/context-manager + API on ``PageIndexClient``/``Collection`` — only on the low-level + ``SQLiteStorage`` its local backend holds internally — so this reaches into + that private attribute directly. In *cloud* mode (``client`` built with a + ``PAGEINDEX_API_KEY``) there is no local backend/storage at all, so this is + a silent no-op. Never raises: called from both the success and the failure + path of :func:`index_long_document`, and closing must never mask a real + indexing error. Without this, a subsequent mutation rollback can't + unlink/rename ``pageindex.db`` on Windows while this process still holds it + open (#249). + """ + storage = getattr(getattr(client, "_backend", None), "_storage", None) + close = getattr(storage, "close", None) + if callable(close): + try: + close() + except Exception: + logger.debug("Failed to close PageIndex's local storage", exc_info=True) + + def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = None) -> IndexResult: """Index a long PDF document using PageIndex and write wiki pages. @@ -192,8 +240,9 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non model: str = config.get("model", "gpt-5.4") pageindex_api_key = os.environ.get("PAGEINDEX_API_KEY", "") + bundle = resolve_credential_bundle(kb_dir) - index_config = _build_index_config(config) + index_config = _build_index_config(config, bundle) client = PageIndexClient( api_key=pageindex_api_key or None, @@ -201,97 +250,105 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non storage_path=str(openkb_dir), index_config=index_config, ) - col = client.collection() - - # Add PDF (retry up to 3 times — PageIndex TOC accuracy is stochastic) - max_retries = 3 - doc_id = None - for attempt in range(1, max_retries + 1): - try: - doc_id = col.add(str(pdf_path)) - logger.info( - "PageIndex added %s → doc_id=%s (attempt %d)", pdf_path.name, doc_id, attempt - ) - break - except Exception as exc: - logger.warning( - "PageIndex attempt %d/%d failed for %s: %s", - attempt, - max_retries, - pdf_path.name, - exc, - ) - if attempt == max_retries: - raise RuntimeError( - f"Failed to index {pdf_path.name} after {max_retries} attempts: {exc}" - ) from exc - - # The PageIndex blob for doc_id is now durably on disk. The add mutation no - # longer eagerly snapshots .openkb/files — it registers the new blob via - # snapshot.track_new() only on a successful return — so if any step below - # fails, delete the document we just added. Otherwise the blob leaks as an - # orphan that pageindex.db (rolled back by the snapshot) no longer refs and - # no reaper reclaims. + # Closed in `finally` (both success and failure) so a subsequent mutation + # rollback can always unlink/rename pageindex.db on Windows — see #249. try: - # Fetch complete document (metadata + structure + text) - doc = col.get_document(doc_id, include_text=True) - indexed_doc_name: str = doc.get("doc_name", pdf_path.stem) - description: str = doc.get("doc_description", "") - structure: list = doc.get("structure", []) - - # Debug: print doc keys and page_count to diagnose get_page_content range - logger.info("Doc keys: %s", list(doc.keys())) - logger.info("page_count from doc: %s", doc.get("page_count", "NOT PRESENT")) - - tree = { - "doc_name": indexed_doc_name, - "doc_description": description, - "structure": structure, - } - - # Write wiki/sources/ — per-page content - sources_dir = kb_dir / "wiki" / "sources" - sources_dir.mkdir(parents=True, exist_ok=True) - images_dir = sources_dir / "images" / source_name + col = client.collection() - all_pages: list[dict[str, Any]] = [] - if pageindex_api_key: - # Cloud mode: fetch OCR'd markdown from PageIndex. get_page_content - # requires a page range, so pass "1-N". - page_count = _get_pdf_page_count(pdf_path) + # Add PDF (retry up to 3 times — PageIndex TOC accuracy is stochastic) + max_retries = 3 + doc_id = None + for attempt in range(1, max_retries + 1): try: - all_pages = _normalize_page_content(col.get_page_content(doc_id, f"1-{page_count}")) + doc_id = col.add(str(pdf_path)) + logger.info( + "PageIndex added %s → doc_id=%s (attempt %d)", pdf_path.name, doc_id, attempt + ) + break except Exception as exc: - logger.warning("Cloud get_page_content failed for %s: %s", pdf_path.name, exc) - - if not all_pages: - if pageindex_api_key: logger.warning( - "Cloud returned no pages for %s; falling back to local pymupdf", pdf_path.name + "PageIndex attempt %d/%d failed for %s: %s", + attempt, + max_retries, + pdf_path.name, + exc, + ) + if attempt == max_retries: + raise RuntimeError( + f"Failed to index {pdf_path.name} after {max_retries} attempts: {exc}" + ) from exc + + # The PageIndex blob for doc_id is now durably on disk. The add mutation no + # longer eagerly snapshots .openkb/files — it registers the new blob via + # snapshot.track_new() only on a successful return — so if any step below + # fails, delete the document we just added. Otherwise the blob leaks as an + # orphan that pageindex.db (rolled back by the snapshot) no longer refs and + # no reaper reclaims. + try: + # Fetch complete document (metadata + structure + text) + doc = col.get_document(doc_id, include_text=True) + indexed_doc_name: str = doc.get("doc_name", pdf_path.stem) + description: str = doc.get("doc_description", "") + structure: list = doc.get("structure", []) + + # Debug: print doc keys and page_count to diagnose get_page_content range + logger.info("Doc keys: %s", list(doc.keys())) + logger.info("page_count from doc: %s", doc.get("page_count", "NOT PRESENT")) + + tree = { + "doc_name": indexed_doc_name, + "doc_description": description, + "structure": structure, + } + + # Write wiki/sources/ — per-page content + sources_dir = kb_dir / "wiki" / "sources" + sources_dir.mkdir(parents=True, exist_ok=True) + images_dir = sources_dir / "images" / source_name + + all_pages: list[dict[str, Any]] = [] + if pageindex_api_key: + # Cloud mode: fetch OCR'd markdown from PageIndex. get_page_content + # requires a page range, so pass "1-N". + page_count = _get_pdf_page_count(pdf_path) + try: + all_pages = _normalize_page_content( + col.get_page_content(doc_id, f"1-{page_count}") + ) + except Exception as exc: + logger.warning("Cloud get_page_content failed for %s: %s", pdf_path.name, exc) + + if not all_pages: + if pageindex_api_key: + logger.warning( + "Cloud returned no pages for %s; falling back to local pymupdf", + pdf_path.name, + ) + all_pages = _normalize_page_content( + _convert_pdf_to_pages(pdf_path, source_name, images_dir) ) - all_pages = _normalize_page_content( - _convert_pdf_to_pages(pdf_path, source_name, images_dir) - ) - if not all_pages: - raise RuntimeError(f"No page content extracted for {pdf_path.name}") + if not all_pages: + raise RuntimeError(f"No page content extracted for {pdf_path.name}") - _write_long_doc_artifacts( - tree, all_pages, source_name, doc_id, kb_dir, description=description - ) - return IndexResult(doc_id=doc_id, description=description, tree=tree) - except BaseException: - # Best-effort: remove the blob this add created. A failure here (e.g. a - # second interrupt) only means the blob may stay orphaned — the original - # error still propagates so the caller (mutation coordinator) rolls back - # everything else it snapshotted. - try: - col.delete_document(doc_id) - except Exception: - logger.warning( - "PageIndex cleanup of %s failed after error; blob may be orphaned", doc_id + _write_long_doc_artifacts( + tree, all_pages, source_name, doc_id, kb_dir, description=description ) - raise + return IndexResult(doc_id=doc_id, description=description, tree=tree) + except BaseException: + # Best-effort: remove the blob this add created. A failure here (e.g. a + # second interrupt) only means the blob may stay orphaned — the original + # error still propagates so the caller (mutation coordinator) rolls back + # everything else it snapshotted. + try: + col.delete_document(doc_id) + except Exception: + logger.warning( + "PageIndex cleanup of %s failed after error; blob may be orphaned", doc_id + ) + raise + finally: + _close_pageindex_client(client) # PageIndex's get_page_content rejects a single page range covering more than diff --git a/tests/test_indexer.py b/tests/test_indexer.py index e0843fa89..f3a1fc35c 100644 --- a/tests/test_indexer.py +++ b/tests/test_indexer.py @@ -10,13 +10,15 @@ from openkb.indexer import ( IndexResult, _build_index_config, + _close_pageindex_client, _normalize_page_content, index_long_document, ) class _FakeIndexConfigWithConcurrency: - """Stand-in for a PageIndex ``IndexConfig`` that declares ``max_concurrency``. + """Stand-in for a PageIndex ``IndexConfig`` that declares ``max_concurrency`` + and ``llm_params``. Used instead of relying on whatever ``pageindex`` happens to be installed in this environment, so the forwarding tests are deterministic regardless of @@ -28,6 +30,7 @@ class _FakeIndexConfigWithConcurrency: "if_add_node_summary": None, "if_add_doc_description": None, "max_concurrency": None, + "llm_params": None, } def __init__(self, **kwargs): @@ -95,6 +98,67 @@ def test_no_warning_when_supported(self, monkeypatch, caplog): assert caplog.text == "" +class _FakeBundle: + def __init__(self, api_key=None, base_url=None): + self.api_key = api_key + self.base_url = base_url + + +class TestBuildIndexConfigLlmParams: + """``bundle``'s api_key/base_url must reach PageIndex's own LLM calls via + ``IndexConfig(llm_params=...)`` (#219) — without this they silently fall + back to LiteLLM's default provider-key/env-var lookup.""" + + def test_forwards_api_key_and_base_url_when_supported(self, monkeypatch): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + bundle = _FakeBundle(api_key="sk-test", base_url="https://gateway.example/v1") + cfg = _build_index_config({}, bundle) + assert cfg.llm_params == {"api_key": "sk-test", "base_url": "https://gateway.example/v1"} + + def test_no_bundle_means_no_llm_params(self, monkeypatch): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + cfg = _build_index_config({}, None) + assert not hasattr(cfg, "llm_params") + + def test_empty_bundle_means_no_llm_params(self, monkeypatch): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + cfg = _build_index_config({}, _FakeBundle()) + assert not hasattr(cfg, "llm_params") + + def test_partial_bundle_forwards_only_set_fields(self, monkeypatch): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + cfg = _build_index_config({}, _FakeBundle(api_key="sk-test")) + assert cfg.llm_params == {"api_key": "sk-test"} + + def test_does_not_forward_when_unsupported(self, monkeypatch, caplog): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithoutConcurrency) + bundle = _FakeBundle(api_key="sk-test") + with caplog.at_level(logging.WARNING, logger="openkb.indexer"): + cfg = _build_index_config({}, bundle) + assert not hasattr(cfg, "llm_params") + assert "llm_params" in caplog.text + + +class TestClosePageindexClient: + """Best-effort close of PageIndex's local SQLite connection(s) — see #249.""" + + def test_closes_local_backend_storage(self): + client = MagicMock() + _close_pageindex_client(client) + client._backend._storage.close.assert_called_once() + + def test_cloud_client_without_backend_storage_is_a_noop(self): + client = MagicMock(spec=[]) # no attributes at all, unlike a bare MagicMock + _close_pageindex_client(client) # must not raise + + def test_close_exception_is_swallowed(self, caplog): + client = MagicMock() + client._backend._storage.close.side_effect = RuntimeError("boom") + with caplog.at_level(logging.DEBUG, logger="openkb.indexer"): + _close_pageindex_client(client) # must not raise + assert "Failed to close" in caplog.text + + class TestNormalizePageContent: def test_normalizes_pageindex_dicts(self): pages = _normalize_page_content( @@ -184,6 +248,46 @@ def test_returns_index_result(self, kb_dir, sample_tree, tmp_path): assert result.description == sample_tree["doc_description"] assert result.tree is not None + def test_closes_pageindex_client_on_success(self, kb_dir, sample_tree, tmp_path): + """See #249: the local SQLite connection must be closed once indexing + succeeds so a later mutation step doesn't leave it open unnecessarily.""" + doc_id = "abc-123" + fake_col = self._make_fake_collection(doc_id, sample_tree) + + fake_client = MagicMock() + fake_client.collection.return_value = fake_col + + pdf_path = tmp_path / "sample.pdf" + pdf_path.write_bytes(b"%PDF-1.4 fake") + + with ( + patch("openkb.indexer.PageIndexClient", return_value=fake_client), + patch("openkb.images.convert_pdf_to_pages", return_value=self._fake_pages()), + ): + index_long_document(pdf_path, kb_dir) + + fake_client._backend._storage.close.assert_called_once() + + def test_closes_pageindex_client_on_failure(self, kb_dir, sample_tree, tmp_path): + """See #249: closing must also happen when indexing fails, so a + subsequent mutation rollback can unlink/rename pageindex.db on Windows + instead of hitting WinError 32 while this process still holds it open.""" + doc_id = "abc-123" + col = self._make_fake_collection(doc_id, sample_tree) + col.get_document.side_effect = RuntimeError("get_document blew up") + + fake_client = MagicMock() + fake_client.collection.return_value = col + + pdf_path = tmp_path / "sample.pdf" + pdf_path.write_bytes(b"%PDF-1.4 fake") + + with patch("openkb.indexer.PageIndexClient", return_value=fake_client): + with pytest.raises(RuntimeError, match="get_document blew up"): + index_long_document(pdf_path, kb_dir) + + fake_client._backend._storage.close.assert_called_once() + def test_deletes_pageindex_doc_when_a_post_add_step_fails(self, kb_dir, sample_tree, tmp_path): """The PageIndex blob is durably written by col.add(), but .openkb/files is no longer in the add mutation's eager snapshot — track_new only registers @@ -287,6 +391,32 @@ def test_localclient_called_with_index_config(self, kb_dir, sample_tree, tmp_pat assert ic.if_add_node_summary is True assert ic.if_add_doc_description is True + def test_credential_bundle_flows_into_index_config(self, kb_dir, sample_tree, tmp_path): + """See #219: a KB's resolved LLM_API_KEY/base_url must reach PageIndex's + own indexing calls via IndexConfig(llm_params=...), the same way it + reaches compiler.py's own LLM calls — not just the isolated + _build_index_config unit tests exercised directly with a fake bundle.""" + doc_id = "cred-123" + fake_col = self._make_fake_collection(doc_id, sample_tree) + + fake_client = MagicMock() + fake_client.collection.return_value = fake_col + fake_bundle = _FakeBundle(api_key="sk-test", base_url="https://gateway.example/v1") + + pdf_path = tmp_path / "report.pdf" + pdf_path.write_bytes(b"%PDF-1.4 fake") + + with ( + patch("openkb.indexer.PageIndexClient", return_value=fake_client) as mock_cls, + patch("openkb.indexer.resolve_credential_bundle", return_value=fake_bundle), + patch("openkb.images.convert_pdf_to_pages", return_value=self._fake_pages()), + ): + index_long_document(pdf_path, kb_dir) + + _, kwargs = mock_cls.call_args + ic = kwargs.get("index_config") + assert ic.llm_params == {"api_key": "sk-test", "base_url": "https://gateway.example/v1"} + def test_concurrency_flows_from_kb_config(self, kb_dir, sample_tree, tmp_path): """The KB's real config.yaml, loaded by index_long_document itself, must reach the IndexConfig passed to PageIndexClient — not just the isolated From 86a0539be5cdfd4deaae425435958a2acba57edf Mon Sep 17 00:00:00 2001 From: Sebastian Braun Date: Tue, 8 Sep 2026 10:02:59 +0200 Subject: [PATCH 2/2] fix(indexer): forward extra_headers/timeout to PageIndex's llm_params Header-only gateway auth (litellm.extra_headers, e.g. a proxy Bearer token with no LLM_API_KEY) never reached PageIndex's own LLM calls, only api_key/base_url did (see #219) - PageIndex's internal indexing calls had no credentials at all in that setup and failed with AuthenticationError. --- openkb/indexer.py | 28 ++++++++++++++++++---------- tests/test_indexer.py | 40 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/openkb/indexer.py b/openkb/indexer.py index 591d93d6b..1d93418a1 100644 --- a/openkb/indexer.py +++ b/openkb/indexer.py @@ -163,14 +163,17 @@ def _build_index_config(config: dict[str, Any], bundle=None) -> IndexConfig: working against a pinned PageIndex that predates it (``IndexConfig`` forbids unknown kwargs). - ``bundle``'s ``api_key``/``base_url`` (the same credentials ``compiler.py``'s - own LLM calls use — see :func:`openkb.config.resolve_credential_bundle`) are - forwarded as PageIndex's own per-call ``llm_params`` (see #219): without - this, PageIndex's internal indexing calls (TOC/tree/summary generation) - fall back to LiteLLM's default provider-key/env-var lookup, which doesn't - know about a KB's custom ``LLM_API_KEY``/gateway ``base_url``. Guarded by - the same ``model_fields`` check as ``max_concurrency`` above, so it - degrades gracefully against an older pinned PageIndex. + ``bundle``'s ``api_key``/``base_url``/``extra_headers``/``timeout`` (the + same credentials ``compiler.py``'s own LLM calls use — see + :func:`openkb.config.resolve_credential_bundle`) are forwarded as + PageIndex's own per-call ``llm_params`` (see #219): without this, + PageIndex's internal indexing calls (TOC/tree/summary generation) fall + back to LiteLLM's default provider-key/env-var lookup, which doesn't know + about a KB's custom ``LLM_API_KEY``/gateway ``base_url`` — or, for + gateways authenticated purely via a header (e.g. an ``Authorization: + Bearer`` proxy token in ``litellm.extra_headers``), has no credentials at + all. Guarded by the same ``model_fields`` check as ``max_concurrency`` + above, so it degrades gracefully against an older pinned PageIndex. """ kwargs: dict[str, Any] = { "if_add_node_text": True, @@ -189,8 +192,13 @@ def _build_index_config(config: dict[str, Any], bundle=None) -> IndexConfig: if bundle is not None: llm_params = { key: value - for key, value in {"api_key": bundle.api_key, "base_url": bundle.base_url}.items() - if value + for key, value in { + "api_key": bundle.api_key, + "base_url": bundle.base_url, + "extra_headers": bundle.extra_headers, + "timeout": bundle.timeout, + }.items() + if value or (key == "timeout" and value is not None) } if llm_params: if "llm_params" in IndexConfig.model_fields: diff --git a/tests/test_indexer.py b/tests/test_indexer.py index f3a1fc35c..0b4c4e4b2 100644 --- a/tests/test_indexer.py +++ b/tests/test_indexer.py @@ -99,15 +99,18 @@ def test_no_warning_when_supported(self, monkeypatch, caplog): class _FakeBundle: - def __init__(self, api_key=None, base_url=None): + def __init__(self, api_key=None, base_url=None, extra_headers=None, timeout=None): self.api_key = api_key self.base_url = base_url + self.extra_headers = extra_headers or {} + self.timeout = timeout class TestBuildIndexConfigLlmParams: - """``bundle``'s api_key/base_url must reach PageIndex's own LLM calls via - ``IndexConfig(llm_params=...)`` (#219) — without this they silently fall - back to LiteLLM's default provider-key/env-var lookup.""" + """``bundle``'s api_key/base_url/extra_headers/timeout must reach PageIndex's + own LLM calls via ``IndexConfig(llm_params=...)`` (#219) — without this they + silently fall back to LiteLLM's default provider-key/env-var lookup (or, for + header-only gateway auth, have no credentials at all).""" def test_forwards_api_key_and_base_url_when_supported(self, monkeypatch): monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) @@ -138,6 +141,35 @@ def test_does_not_forward_when_unsupported(self, monkeypatch, caplog): assert not hasattr(cfg, "llm_params") assert "llm_params" in caplog.text + def test_forwards_extra_headers_and_timeout_when_supported(self, monkeypatch): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + bundle = _FakeBundle(extra_headers={"Authorization": "Bearer proxy-token"}, timeout=30.0) + cfg = _build_index_config({}, bundle) + assert cfg.llm_params == { + "extra_headers": {"Authorization": "Bearer proxy-token"}, + "timeout": 30.0, + } + + def test_empty_extra_headers_is_not_forwarded(self, monkeypatch): + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + cfg = _build_index_config({}, _FakeBundle(extra_headers={})) + assert not hasattr(cfg, "llm_params") + + def test_zero_timeout_is_forwarded(self, monkeypatch): + # timeout=0 is falsy but a deliberately-set value — must not be filtered + # out the same way an unset (None) timeout is. + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + cfg = _build_index_config({}, _FakeBundle(timeout=0)) + assert cfg.llm_params == {"timeout": 0} + + def test_header_only_gateway_auth_is_forwarded_without_api_key(self, monkeypatch): + # Regression: proxy/gateway setups that authenticate purely via a + # header (no LLM_API_KEY) must still reach PageIndex's LLM calls. + monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency) + bundle = _FakeBundle(extra_headers={"Authorization": "Bearer proxy-token"}) + cfg = _build_index_config({}, bundle) + assert cfg.llm_params == {"extra_headers": {"Authorization": "Bearer proxy-token"}} + class TestClosePageindexClient: """Best-effort close of PageIndex's local SQLite connection(s) — see #249."""