Skip to content

Commit 06a65c5

Browse files
fix: support ChatGPT-subscription & GitHub Copilot providers (litellm 1.87.2 + extra_headers) (#98)
* fix(deps): pin all dependencies to exact versions; bump litellm to 1.87.2 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 in the chat path (BerriAI/litellm#20113). All direct dependencies are now pinned exactly as a supply-chain precaution (cf. the litellm package-poisoning incident) — the README already claimed litellm was pinned to a safe version, but pyproject carried no constraint at all. Versions match what uv.lock already resolved, so installed behavior is unchanged apart from litellm 1.85.0 -> 1.87.2. Refs #94 * feat(llm): forward config extra_headers to all LLM calls; no key warning for OAuth providers Issue #93: users on GitHub Copilot (and similar providers) need custom HTTP headers (Editor-Version etc.) on every LLM request, and adding extra_headers to config.yaml silently did nothing — the key was loaded into the config dict but never forwarded. config.resolve_extra_headers() validates/stringifies the mapping, and cli._setup_llm_key (called by every LLM-using command) stashes it via config.set_extra_headers(). The two call funnels read the stash: - compiler._llm_call/_llm_call_async pass it as litellm extra_headers (explicit per-call kwargs still win), - every agents-SDK Agent gets ModelSettings(extra_headers=...), which LitellmModel forwards to litellm.acompletion; build_chat_agent and skill_runner inherit it via base.clone(). Issue #94: OAuth/subscription providers (chatgpt/*, github_copilot/*) authenticate via device flow and need no API key, so the 'No LLM API key found' warning is skipped for them. Closes #93 Refs #94
1 parent 261fab4 commit 06a65c5

16 files changed

Lines changed: 368 additions & 36 deletions

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
273273
274274
`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`.
275275

276+
`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:
277+
278+
```yaml
279+
extra_headers:
280+
Editor-Version: vscode/1.95.0
281+
Copilot-Integration-Id: vscode-chat
282+
```
283+
284+
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.
285+
276286
Model names use `provider/model` LiteLLM [format](https://docs.litellm.ai/docs/providers) (OpenAI models can omit the prefix):
277287

278288
| Provider | Model example |

config.yaml.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ 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+
512
# Optional: override the entity-type vocabulary used for entity pages.
613
# Omit this key to use the default 7 types
714
# (person, organization, place, product, work, event, other).

openkb/agent/compiler.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import litellm
3030
import yaml
3131

32-
from openkb.config import DEFAULT_ENTITY_TYPES, resolve_entity_types
32+
from openkb.config import DEFAULT_ENTITY_TYPES, get_extra_headers, resolve_entity_types
3333
from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks
3434
from openkb.schema import INDEX_SEED, get_agents_md
3535

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

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

343346
async def _llm_call_async(model: str, messages: list[dict], step_name: str, **kwargs) -> str:
344347
"""Async LLM call with timing output and debug logging."""
348+
extra_headers = get_extra_headers()
349+
if extra_headers:
350+
kwargs.setdefault("extra_headers", extra_headers)
345351
logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages))
346352
if kwargs:
347353
logger.debug("LLM kwargs [%s]: %s", step_name, kwargs)

openkb/agent/linter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from pathlib import Path
55

66
from agents import Agent, Runner, function_tool
7+
from agents.model_settings import ModelSettings
78

89
from openkb.agent.tools import list_wiki_files, read_wiki_file
10+
from openkb.config import get_extra_headers
911

1012
MAX_TURNS = 50
1113
from openkb.schema import get_agents_md
@@ -79,6 +81,7 @@ def read_file(path: str) -> str:
7981
instructions=instructions,
8082
tools=[list_files, read_file],
8183
model=f"litellm/{model}",
84+
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
8285
)
8386

8487

openkb/agent/query.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from agents import Agent, Runner, function_tool
77

88
from agents import ToolOutputImage, ToolOutputText
9+
from openkb.config import get_extra_headers
910
from openkb.agent.tools import (
1011
get_wiki_page_content,
1112
read_wiki_file,
@@ -94,7 +95,10 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
9495
instructions=instructions,
9596
tools=[read_file, get_page_content, get_image],
9697
model=f"litellm/{model}",
97-
model_settings=ModelSettings(parallel_tool_calls=False),
98+
model_settings=ModelSettings(
99+
parallel_tool_calls=False,
100+
extra_headers=get_extra_headers() or None,
101+
),
98102
)
99103

100104

openkb/cli.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ def filter(self, record: logging.LogRecord) -> bool:
4141
litellm.suppress_debug_info = True
4242
from dotenv import load_dotenv
4343

44-
from openkb.config import DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb
44+
from openkb.config import (
45+
DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb,
46+
resolve_extra_headers, set_extra_headers,
47+
)
4548
from openkb.converter import _registry_path, convert_document
4649
from openkb.locks import atomic_write_json, atomic_write_text, kb_ingest_lock, kb_read_lock
4750
from openkb.log import append_log
@@ -60,6 +63,11 @@ def filter(self, record: logging.LogRecord) -> bool:
6063
"ZHIPUAI_API_KEY", "DASHSCOPE_API_KEY",
6164
)
6265

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

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

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

103-
# Try to resolve the active provider from the KB config
111+
# Try to resolve the active provider and extra headers from the KB config
104112
provider: str | None = None
113+
extra_headers: dict[str, str] = {}
105114
if kb_dir is not None:
106115
config_path = kb_dir / ".openkb" / "config.yaml"
107116
if config_path.exists():
108117
config = load_config(config_path)
109118
model = config.get("model", "")
110119
provider = _extract_provider(str(model))
120+
extra_headers = resolve_extra_headers(config)
121+
set_extra_headers(extra_headers)
111122

112123
if not api_key:
113-
# Check if any provider key is already set
124+
# Check if any provider key is already set. OAuth-based providers
125+
# (ChatGPT subscription, GitHub Copilot) don't use API keys at all,
126+
# so the warning is skipped for them.
114127
check_keys = (
115128
(f"{provider.upper()}_API_KEY",) if provider
116129
else _KNOWN_PROVIDER_KEYS
117130
)
118131
has_key = any(os.environ.get(k) for k in check_keys)
119-
if not has_key:
132+
if not has_key and provider not in _OAUTH_PROVIDERS:
120133
click.echo(
121134
"Warning: No LLM API key found. Set one of:\n"
122135
f" 1. {kb_dir / '.env' if kb_dir else '<kb_dir>/.env'} — LLM_API_KEY=sk-...\n"

openkb/config.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,66 @@ def resolve_entity_types(config: dict) -> list[str]:
9696
return cleaned
9797

9898

99+
def resolve_extra_headers(config: dict) -> dict[str, str]:
100+
"""Resolve the optional ``extra_headers:`` config key into a str→str dict.
101+
102+
Some LiteLLM providers need extra HTTP headers on every request (e.g.
103+
GitHub Copilot's ``Editor-Version`` IDE-auth headers). Users opt in via
104+
an ``extra_headers:`` mapping in config.yaml; the result is forwarded to
105+
LiteLLM's ``extra_headers`` parameter on all LLM calls.
106+
107+
Values are stringified (YAML may parse version-like values as numbers).
108+
Entries with a non-string/empty key or a non-scalar value are skipped.
109+
A non-mapping ``extra_headers`` is ignored entirely. Warnings are logged
110+
only when the key was present but malformed.
111+
"""
112+
raw = config.get("extra_headers")
113+
if raw is None:
114+
return {}
115+
if not isinstance(raw, dict):
116+
logger.warning(
117+
"config: 'extra_headers' must be a mapping of header name to "
118+
"value, got %s — ignoring it.",
119+
type(raw).__name__,
120+
)
121+
return {}
122+
headers: dict[str, str] = {}
123+
for key, value in raw.items():
124+
if not isinstance(key, str) or not key.strip():
125+
logger.warning(
126+
"config: skipping 'extra_headers' entry with non-string "
127+
"or empty key: %r", key,
128+
)
129+
continue
130+
if value is None or not isinstance(value, (str, int, float, bool)):
131+
logger.warning(
132+
"config: skipping 'extra_headers' entry %r with "
133+
"non-scalar value: %r", key, value,
134+
)
135+
continue
136+
headers[key.strip()] = str(value)
137+
return headers
138+
139+
140+
# Process-wide extra headers for LLM requests, resolved from the active KB's
141+
# config by the CLI entry points (cli._setup_llm_key). LLM call sites read it
142+
# via get_extra_headers() so the value doesn't have to be threaded through
143+
# every compile/agent call chain — mirroring how the API key is applied
144+
# globally via litellm.api_key / provider env vars.
145+
_runtime_extra_headers: dict[str, str] = {}
146+
147+
148+
def set_extra_headers(headers: dict[str, str]) -> None:
149+
"""Set the process-wide extra headers for LLM requests."""
150+
global _runtime_extra_headers
151+
_runtime_extra_headers = dict(headers)
152+
153+
154+
def get_extra_headers() -> dict[str, str]:
155+
"""Return a copy of the process-wide extra headers for LLM requests."""
156+
return dict(_runtime_extra_headers)
157+
158+
99159
def load_config(config_path: Path) -> dict[str, Any]:
100160
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.
101161

openkb/skill/creator.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from agents import Agent, Runner, ToolOutputImage, ToolOutputText, function_tool
2020
from agents.model_settings import ModelSettings
2121

22+
from openkb.config import get_extra_headers
2223
from openkb.skill import skill_dir
2324
from openkb.skill.tools import (
2425
get_skill_page_content as _get_page_content_impl,
@@ -150,7 +151,10 @@ def done(summary: str) -> str:
150151
# compile. Writes serialise naturally because each
151152
# `write_skill_file` depends on accumulated reads; the model has
152153
# no reason to issue parallel writes to the same path.
153-
model_settings=ModelSettings(parallel_tool_calls=True),
154+
model_settings=ModelSettings(
155+
parallel_tool_calls=True,
156+
extra_headers=get_extra_headers() or None,
157+
),
154158
)
155159

156160

openkb/skill/evaluator.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@
4444

4545
from agents import Agent, Runner
4646
from agents.exceptions import MaxTurnsExceeded
47+
from agents.model_settings import ModelSettings
4748

49+
from openkb.config import get_extra_headers
4850
from openkb.skill import extract_body, extract_frontmatter
4951

5052

@@ -241,6 +243,7 @@ async def generate_eval_set(
241243
name="eval-set-generator",
242244
instructions=instructions,
243245
model=f"litellm/{model}",
246+
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
244247
)
245248
try:
246249
result = await Runner.run(agent, "Generate the eval set now.", max_turns=3)
@@ -299,6 +302,7 @@ async def grade_one(
299302
name="trigger-grader",
300303
instructions=instructions,
301304
model=f"litellm/{model}",
305+
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
302306
)
303307
try:
304308
result = await Runner.run(agent, f"Question: {question}", max_turns=2)
@@ -347,6 +351,7 @@ async def grade_coverage(
347351
name="coverage-grader",
348352
instructions=instructions,
349353
model=f"litellm/{model}",
354+
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
350355
)
351356
try:
352357
result = await Runner.run(agent, f"Question: {question}", max_turns=2)

pyproject.toml

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,25 @@ classifiers = [
2525
"Topic :: Scientific/Engineering :: Artificial Intelligence",
2626
]
2727
keywords = ["ai", "rag", "retrieval", "knowledge-base", "llm", "pageindex", "agents", "document"]
28+
# All dependencies are pinned exactly (supply-chain caution — e.g. the
29+
# litellm package-poisoning incident). Bump deliberately after vetting
30+
# each release.
31+
# litellm 1.87.2 fixes the chatgpt/* (ChatGPT subscription) provider
32+
# returning empty Responses output (BerriAI/litellm#25429) and
33+
# auto-injects GitHub Copilot IDE-auth headers.
2834
dependencies = [
2935
"pageindex==0.3.0.dev1",
30-
"markitdown[docx,pptx,xlsx,xls]>=0.1.5",
31-
"trafilatura>=2.0",
32-
"click>=8.0",
33-
"watchdog>=3.0",
34-
"litellm",
35-
"openai-agents",
36-
"pyyaml",
37-
"python-dotenv",
38-
"json-repair",
39-
"prompt_toolkit>=3.0",
40-
"rich>=13.0",
36+
"markitdown[docx,pptx,xlsx,xls]==0.1.5",
37+
"trafilatura==2.0.0",
38+
"click==8.4.0",
39+
"watchdog==6.0.0",
40+
"litellm==1.87.2",
41+
"openai-agents==0.17.3",
42+
"pyyaml==6.0.3",
43+
"python-dotenv==1.2.2",
44+
"json-repair==0.59.10",
45+
"prompt_toolkit==3.0.52",
46+
"rich==15.0.0",
4147
]
4248

4349
[project.urls]
@@ -52,7 +58,7 @@ openkb = "openkb.cli:cli"
5258
testpaths = ["tests"]
5359

5460
[project.optional-dependencies]
55-
dev = ["pytest", "pytest-asyncio"]
61+
dev = ["pytest==9.0.3", "pytest-asyncio==1.3.0"]
5662

5763
[tool.hatch.version]
5864
source = "vcs"

0 commit comments

Comments
 (0)