Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex

`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`.

`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:

```yaml
extra_headers:
Editor-Version: vscode/1.95.0
Copilot-Integration-Id: vscode-chat
```

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.

Model names use `provider/model` LiteLLM [format](https://docs.litellm.ai/docs/providers) (OpenAI models can omit the prefix):

| Provider | Model example |
Expand Down
7 changes: 7 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider)
language: en # Wiki output language
pageindex_threshold: 20 # PDF pages threshold for PageIndex

# Optional: extra HTTP headers sent with every LLM request (forwarded to
# LiteLLM's extra_headers). Some providers need these — e.g. GitHub Copilot
# IDE-auth headers on older litellm versions:
# extra_headers:
# Editor-Version: vscode/1.95.0
# Copilot-Integration-Id: vscode-chat

# Optional: override the entity-type vocabulary used for entity pages.
# Omit this key to use the default 7 types
# (person, organization, place, product, work, event, other).
Expand Down
8 changes: 7 additions & 1 deletion openkb/agent/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import litellm
import yaml

from openkb.config import DEFAULT_ENTITY_TYPES, resolve_entity_types
from openkb.config import DEFAULT_ENTITY_TYPES, get_extra_headers, resolve_entity_types
from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks
from openkb.schema import INDEX_SEED, get_agents_md

Expand Down Expand Up @@ -323,6 +323,9 @@ def _fmt_messages(messages: list[dict], max_content: int = 200) -> str:

def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str:
"""Single LLM call with animated progress and debug logging."""
extra_headers = get_extra_headers()
if extra_headers:
kwargs.setdefault("extra_headers", extra_headers)
logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages))
if kwargs:
logger.debug("LLM kwargs [%s]: %s", step_name, kwargs)
Expand All @@ -342,6 +345,9 @@ def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str

async def _llm_call_async(model: str, messages: list[dict], step_name: str, **kwargs) -> str:
"""Async LLM call with timing output and debug logging."""
extra_headers = get_extra_headers()
if extra_headers:
kwargs.setdefault("extra_headers", extra_headers)
logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages))
if kwargs:
logger.debug("LLM kwargs [%s]: %s", step_name, kwargs)
Expand Down
3 changes: 3 additions & 0 deletions openkb/agent/linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from pathlib import Path

from agents import Agent, Runner, function_tool
from agents.model_settings import ModelSettings

from openkb.agent.tools import list_wiki_files, read_wiki_file
from openkb.config import get_extra_headers

MAX_TURNS = 50
from openkb.schema import get_agents_md
Expand Down Expand Up @@ -79,6 +81,7 @@ def read_file(path: str) -> str:
instructions=instructions,
tools=[list_files, read_file],
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
)


Expand Down
6 changes: 5 additions & 1 deletion openkb/agent/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from agents import Agent, Runner, function_tool

from agents import ToolOutputImage, ToolOutputText
from openkb.config import get_extra_headers
from openkb.agent.tools import (
get_wiki_page_content,
read_wiki_file,
Expand Down Expand Up @@ -94,7 +95,10 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
instructions=instructions,
tools=[read_file, get_page_content, get_image],
model=f"litellm/{model}",
model_settings=ModelSettings(parallel_tool_calls=False),
model_settings=ModelSettings(
parallel_tool_calls=False,
extra_headers=get_extra_headers() or None,
),
)


Expand Down
21 changes: 17 additions & 4 deletions openkb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ def filter(self, record: logging.LogRecord) -> bool:
litellm.suppress_debug_info = True
from dotenv import load_dotenv

from openkb.config import DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb
from openkb.config import (
DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb,
resolve_extra_headers, set_extra_headers,
)
from openkb.converter import _registry_path, convert_document
from openkb.locks import atomic_write_json, atomic_write_text, kb_ingest_lock, kb_read_lock
from openkb.log import append_log
Expand All @@ -60,6 +63,11 @@ def filter(self, record: logging.LogRecord) -> bool:
"ZHIPUAI_API_KEY", "DASHSCOPE_API_KEY",
)

# Providers that authenticate via OAuth device flow (subscription login
# handled by LiteLLM itself) — no API key env var is needed, so the
# missing-key warning would be a false alarm for them.
_OAUTH_PROVIDERS = {"chatgpt", "github_copilot"}


def _extract_provider(model: str) -> str | None:
"""Extract the LiteLLM provider name from a model string.
Expand Down Expand Up @@ -100,23 +108,28 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:

api_key = os.environ.get("LLM_API_KEY", "")

# Try to resolve the active provider from the KB config
# Try to resolve the active provider and extra headers from the KB config
provider: str | None = None
extra_headers: dict[str, str] = {}
if kb_dir is not None:
config_path = kb_dir / ".openkb" / "config.yaml"
if config_path.exists():
config = load_config(config_path)
model = config.get("model", "")
provider = _extract_provider(str(model))
extra_headers = resolve_extra_headers(config)
set_extra_headers(extra_headers)

if not api_key:
# Check if any provider key is already set
# Check if any provider key is already set. OAuth-based providers
# (ChatGPT subscription, GitHub Copilot) don't use API keys at all,
# so the warning is skipped for them.
check_keys = (
(f"{provider.upper()}_API_KEY",) if provider
else _KNOWN_PROVIDER_KEYS
)
has_key = any(os.environ.get(k) for k in check_keys)
if not has_key:
if not has_key and provider not in _OAUTH_PROVIDERS:
click.echo(
"Warning: No LLM API key found. Set one of:\n"
f" 1. {kb_dir / '.env' if kb_dir else '<kb_dir>/.env'} — LLM_API_KEY=sk-...\n"
Expand Down
60 changes: 60 additions & 0 deletions openkb/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,66 @@ def resolve_entity_types(config: dict) -> list[str]:
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


# 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)


def load_config(config_path: Path) -> dict[str, Any]:
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.

Expand Down
6 changes: 5 additions & 1 deletion openkb/skill/creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from agents import Agent, Runner, ToolOutputImage, ToolOutputText, function_tool
from agents.model_settings import ModelSettings

from openkb.config import get_extra_headers
from openkb.skill import skill_dir
from openkb.skill.tools import (
get_skill_page_content as _get_page_content_impl,
Expand Down Expand Up @@ -150,7 +151,10 @@ def done(summary: str) -> str:
# compile. Writes serialise naturally because each
# `write_skill_file` depends on accumulated reads; the model has
# no reason to issue parallel writes to the same path.
model_settings=ModelSettings(parallel_tool_calls=True),
model_settings=ModelSettings(
parallel_tool_calls=True,
extra_headers=get_extra_headers() or None,
),
)


Expand Down
5 changes: 5 additions & 0 deletions openkb/skill/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@

from agents import Agent, Runner
from agents.exceptions import MaxTurnsExceeded
from agents.model_settings import ModelSettings

from openkb.config import get_extra_headers
from openkb.skill import extract_body, extract_frontmatter


Expand Down Expand Up @@ -241,6 +243,7 @@ async def generate_eval_set(
name="eval-set-generator",
instructions=instructions,
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
)
try:
result = await Runner.run(agent, "Generate the eval set now.", max_turns=3)
Expand Down Expand Up @@ -299,6 +302,7 @@ async def grade_one(
name="trigger-grader",
instructions=instructions,
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
)
try:
result = await Runner.run(agent, f"Question: {question}", max_turns=2)
Expand Down Expand Up @@ -347,6 +351,7 @@ async def grade_coverage(
name="coverage-grader",
instructions=instructions,
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
)
try:
result = await Runner.run(agent, f"Question: {question}", max_turns=2)
Expand Down
30 changes: 18 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,25 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
keywords = ["ai", "rag", "retrieval", "knowledge-base", "llm", "pageindex", "agents", "document"]
# All dependencies are pinned exactly (supply-chain caution — e.g. the
# litellm package-poisoning incident). Bump deliberately after vetting
# each release.
# litellm 1.87.2 fixes the chatgpt/* (ChatGPT subscription) provider
# returning empty Responses output (BerriAI/litellm#25429) and
# auto-injects GitHub Copilot IDE-auth headers.
dependencies = [
"pageindex==0.3.0.dev1",
"markitdown[docx,pptx,xlsx,xls]>=0.1.5",
"trafilatura>=2.0",
"click>=8.0",
"watchdog>=3.0",
"litellm",
"openai-agents",
"pyyaml",
"python-dotenv",
"json-repair",
"prompt_toolkit>=3.0",
"rich>=13.0",
"markitdown[docx,pptx,xlsx,xls]==0.1.5",
"trafilatura==2.0.0",
"click==8.4.0",
"watchdog==6.0.0",
"litellm==1.87.2",
"openai-agents==0.17.3",
"pyyaml==6.0.3",
"python-dotenv==1.2.2",
"json-repair==0.59.10",
"prompt_toolkit==3.0.52",
"rich==15.0.0",
]

[project.urls]
Expand All @@ -52,7 +58,7 @@ openkb = "openkb.cli:cli"
testpaths = ["tests"]

[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio"]
dev = ["pytest==9.0.3", "pytest-asyncio==1.3.0"]

[tool.hatch.version]
source = "vcs"
Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
import pytest


@pytest.fixture(autouse=True)
def _reset_extra_headers():
"""Keep the process-wide LLM extra-headers stash from leaking across tests."""
from openkb.config import set_extra_headers
yield
set_extra_headers({})


@pytest.fixture
def kb_dir(tmp_path):
"""Create a minimal knowledge base directory structure for testing."""
Expand Down
Loading