@@ -42,7 +42,11 @@ def filter(self, record: logging.LogRecord) -> bool:
4242from dotenv import load_dotenv
4343
4444from openkb .agent .compiler import compile_long_doc
45- from openkb .config import DEFAULT_CONFIG , load_config , save_config , load_global_config , register_kb
45+ from openkb .config import (
46+ DEFAULT_CONFIG , load_config , save_config , load_global_config , register_kb ,
47+ resolve_extra_headers , set_extra_headers , resolve_timeout , set_timeout ,
48+ resolve_litellm_settings ,
49+ )
4650from openkb .converter import _registry_path , convert_document
4751from openkb .indexer import import_cloud_document
4852from openkb .locks import atomic_write_json , atomic_write_text , kb_ingest_lock , kb_read_lock
@@ -55,13 +59,20 @@ def filter(self, record: logging.LogRecord) -> bool:
5559
5660load_dotenv () # load from cwd (covers running inside the KB dir)
5761
62+ logger = logging .getLogger (__name__ )
63+
5864
5965_KNOWN_PROVIDER_KEYS = (
6066 "OPENAI_API_KEY" , "ANTHROPIC_API_KEY" , "GEMINI_API_KEY" ,
6167 "DEEPSEEK_API_KEY" , "MISTRAL_API_KEY" , "MOONSHOT_API_KEY" ,
6268 "ZHIPUAI_API_KEY" , "DASHSCOPE_API_KEY" ,
6369)
6470
71+ # Providers that authenticate via OAuth device flow (subscription login
72+ # handled by LiteLLM itself) — no API key env var is needed, so the
73+ # missing-key warning would be a false alarm for them.
74+ _OAUTH_PROVIDERS = {"chatgpt" , "github_copilot" }
75+
6576
6677def _extract_provider (model : str ) -> str | None :
6778 """Extract the LiteLLM provider name from a model string.
@@ -77,6 +88,31 @@ def _extract_provider(model: str) -> str | None:
7788 return "openai"
7889
7990
91+ def _apply_litellm_settings (settings : dict ) -> None :
92+ """Set each ``litellm:`` key verbatim onto the litellm module (process-wide
93+ globals, so they reach every LiteLLM call). Skips with a warning a key the
94+ installed litellm doesn't define, or one that is a litellm function (e.g.
95+ ``completion``) since overwriting it would break later calls. Applied, never
96+ reset — the values persist for the life of the process.
97+ """
98+ for key , value in settings .items ():
99+ if not hasattr (litellm , key ):
100+ logger .warning (
101+ "config: LiteLLM has no setting %r — ignoring it "
102+ "(check the spelling or your installed litellm version)." ,
103+ key ,
104+ )
105+ continue
106+ if callable (getattr (litellm , key )):
107+ logger .warning (
108+ "config: 'litellm.%s' is a LiteLLM function, not a setting — "
109+ "refusing to overwrite it from the litellm: config block." ,
110+ key ,
111+ )
112+ continue
113+ setattr (litellm , key , value )
114+
115+
80116def _setup_llm_key (kb_dir : Path | None = None ) -> None :
81117 """Set LiteLLM API key from LLM_API_KEY env var if present.
82118
@@ -102,23 +138,45 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
102138
103139 api_key = os .environ .get ("LLM_API_KEY" , "" )
104140
105- # Try to resolve the active provider from the KB config
141+ # Try to resolve the active provider, extra headers, and request timeout
142+ # from the KB config
106143 provider : str | None = None
144+ extra_headers : dict [str , str ] = {}
145+ timeout : float | None = None
146+ litellm_settings : dict = {}
107147 if kb_dir is not None :
108148 config_path = kb_dir / ".openkb" / "config.yaml"
109149 if config_path .exists ():
110150 config = load_config (config_path )
111151 model = config .get ("model" , "" )
112152 provider = _extract_provider (str (model ))
153+ extra_headers = resolve_extra_headers (config )
154+ timeout = resolve_timeout (config )
155+ litellm_settings = resolve_litellm_settings (config )
156+ # `timeout` / `extra_headers` in the block route to the per-call
157+ # stashes (replacing the legacy top-level keys); the rest are globals.
158+ if "extra_headers" in litellm_settings :
159+ extra_headers = resolve_extra_headers (
160+ {"extra_headers" : litellm_settings .pop ("extra_headers" )}
161+ )
162+ if "timeout" in litellm_settings :
163+ timeout = resolve_timeout (
164+ {"timeout" : litellm_settings .pop ("timeout" )}
165+ )
166+ set_extra_headers (extra_headers )
167+ set_timeout (timeout )
168+ _apply_litellm_settings (litellm_settings )
113169
114170 if not api_key :
115- # Check if any provider key is already set
171+ # Check if any provider key is already set. OAuth-based providers
172+ # (ChatGPT subscription, GitHub Copilot) don't use API keys at all,
173+ # so the warning is skipped for them.
116174 check_keys = (
117175 (f"{ provider .upper ()} _API_KEY" ,) if provider
118176 else _KNOWN_PROVIDER_KEYS
119177 )
120178 has_key = any (os .environ .get (k ) for k in check_keys )
121- if not has_key :
179+ if not has_key and provider not in _OAUTH_PROVIDERS :
122180 click .echo (
123181 "Warning: No LLM API key found. Set one of:\n "
124182 f" 1. { kb_dir / '.env' if kb_dir else '<kb_dir>/.env' } — LLM_API_KEY=sk-...\n "
@@ -289,7 +347,6 @@ def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", "
289347 from openkb .agent .compiler import compile_long_doc , compile_short_doc
290348 from openkb .state import HashRegistry
291349
292- logger = logging .getLogger (__name__ )
293350 openkb_dir = kb_dir / ".openkb"
294351 config = load_config (openkb_dir / "config.yaml" )
295352 _setup_llm_key (kb_dir )
@@ -1581,6 +1638,36 @@ def lint(ctx, fix):
15811638 asyncio .run (run_lint (kb_dir ))
15821639
15831640
1641+ @cli .command ()
1642+ @click .option ("--open/--no-open" , "open_browser" , default = True ,
1643+ help = "Open the graph in your browser after generating (default: on; --no-open for headless)." )
1644+ @click .pass_context
1645+ @_with_kb_lock (exclusive = False )
1646+ def visualize (ctx , open_browser ):
1647+ """Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page."""
1648+ kb_dir = _find_kb_dir (ctx .obj .get ("kb_dir_override" ))
1649+ if kb_dir is None :
1650+ click .echo ("No knowledge base found. Run `openkb init` first." )
1651+ return
1652+ from openkb import visualize as viz
1653+ graph = viz .build_graph (kb_dir / "wiki" )
1654+ if not graph ["nodes" ]:
1655+ click .echo ("No wiki pages to visualize yet. Run `openkb add` first." )
1656+ return
1657+ out = kb_dir / "output" / "visualize" / "graph.html"
1658+ out .parent .mkdir (parents = True , exist_ok = True )
1659+ out .write_text (viz .render_html (graph ), encoding = "utf-8" )
1660+ click .echo (f"Graph written to { out } ({ len (graph ['nodes' ])} nodes, { len (graph ['edges' ])} edges)" )
1661+ if open_browser :
1662+ import webbrowser
1663+ try :
1664+ opened = webbrowser .open (out .resolve ().as_uri ()) # resolve() so a relative --kb-dir still yields a valid file URI
1665+ except Exception :
1666+ opened = False
1667+ if not opened :
1668+ click .echo ("(couldn't launch a browser — open the file above manually, or use --no-open)" )
1669+
1670+
15841671def print_list (kb_dir : Path ) -> None :
15851672 """Print all documents in the knowledge base. Usable from CLI and chat REPL."""
15861673 openkb_dir = kb_dir / ".openkb"
@@ -2348,8 +2435,10 @@ def deck():
23482435 "--skill" , "skill_name" ,
23492436 metavar = "SKILL_NAME" ,
23502437 default = None ,
2438+ # NOTE: 'openkb-deck-neon' below must stay in sync with
2439+ # DEFAULT_DECK_SKILL in openkb/deck/creator.py.
23512440 help = (
2352- "Which deck skill to use. Defaults to 'openkb-deck-editorial ' "
2441+ "Which deck skill to use. Defaults to 'openkb-deck-neon ' "
23532442 "(the built-in). Pass e.g. 'deck-guizang-editorial' to route to "
23542443 "a third-party skill installed under ~/.openkb/skills/."
23552444 ),
@@ -2428,7 +2517,8 @@ def deck_new(ctx, name, intent, yes_flag, critique_flag, skill_name):
24282517
24292518 # Run the generator.
24302519 from openkb .skill .generator import Generator
2431- skill_label = skill_name if skill_name else "openkb-deck-editorial (default)"
2520+ from openkb .deck .creator import DEFAULT_DECK_SKILL
2521+ skill_label = skill_name if skill_name else f"{ DEFAULT_DECK_SKILL } (default)"
24322522 click .echo (f"Generating deck '{ name } ' via skill { skill_label } ..." )
24332523 gen = Generator (
24342524 target_type = "deck" ,
0 commit comments