Repository navigation
feat(llm): forward a custom provider's extra_headers as default_headers (#3680) - #4783
jaimemartinez wants to merge 1 commit into
Conversation
…rs (Graphify-Labs#3680) providers.json can already override request body fields with `extra_body`, but not headers, and some OpenAI-compatible gateways require one: OpenCode Go rejects any request without `x-opencode-session` (400 MissingSessionID). The only workaround was the SDK-global OPENAI_CUSTOM_HEADERS env var. Accept an optional `extra_headers` object on a provider entry and pass it to the OpenAI client as `default_headers` at both construction sites, extraction (`_call_openai_compat`) and `_call_llm` (labeling and the --dedup-llm tiebreaker). Only string-to-string entries are kept, and an Authorization entry is dropped with a warning, so the API key still always comes from the provider's env_key. Without the field, the client is built exactly as before.
|
Thanks for the pull request, @jaimemartinez. A maintainer will review it soon. Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions. A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic. |
There was a problem hiding this comment.
Graphify reviewed this change.
No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.
Not checked: tests were not run; formal verification proved 0 of 3 changed function(s) (3 not verified).
Formal verification. PR-changed functions: 0/3 verified (0 proven, 0 may-equivalent, 0 distinguished) · 3 not verified (2 vacuous, 1 error).
Not verified on this run: \_call\_llm (vacuous: never exercised), \_call\_openai\_compat (error), extract\_files\_direct (vacuous: never exercised).
Graphify review — findings
Adds support for an extra_headers map on custom providers in providers.json, so OpenAI-compatible gateways that require their own request headers (e.g. OpenCode Go's x-opencode-session) work. The headers are validated and passed to the OpenAI client as default_headers on both the extraction path and _call_llm. Non-string entries are dropped with a stderr warning, and any Authorization entry is ignored so the key always comes from env_key; if nothing usable is left, no default_headers are set and behaviour is unchanged.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1142 functions depend on the 366 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
_call_llm()— 21 callers, 23 callees - worse:
extract_files_direct()— 20 callers, 22 callees - worse:
_call_openai_compat()— 36 callers, 12 callees
Verification — 1142 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 674 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
58 of 465 test file(s) selected (12%) via static blast radius.
tests/test_backend_env_isolation.py— impacttests/test_backend_extras.py— impacttests/test_backend_opt_in.py— impacttests/test_build.py— impacttests/test_build_located_semantic_identity.py— impacttests/test_build_merge_dedup_scope.py— impacttests/test_build_merge_hyperedges_and_prune.py— impacttests/test_build_merge_shrink_guard.py— impacttests/test_c_include_resolution.py— impacttests/test_carried_hyperedge_remap.py— impacttests/test_charmap_encoding.py— impacttests/test_chunking.py— impacttests/test_claude_cli_backend.py— impacttests/test_corrupt_graph_json.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_dedup.py— impacttests/test_dedup_remaps_hyperedges.py— impacttests/test_dedup_shrink_refuses_force_write.py— impacttests/test_dedup_survivor_richness.py— impacttests/test_evidence_binding.py— impacttests/test_ext_tagged_id_clash.py— impacttests/test_file_slice.py— impacttests/test_file_truncation_warning.py— impacttests/test_global_graph.py— impacttests/test_go_qualified_resolution.py— impacttests/test_hyperedge_member_shapes.py— impacttests/test_image_vision.py— impacttests/test_injection_sentinel_coverage.py— impacttests/test_issue_3472_source_file_collision.py— impacttests/test_js_member_collisions.py— impacttests/test_label_retry.py— impacttests/test_labeling.py— impacttests/test_llm_backends.py— impact, changed-testtests/test_llm_null_token_counts.py— impacttests/test_llm_parser.py— impacttests/test_llm_parser_reasoning.py— impacttests/test_merge_sweeps_reextract_orphans.py— impacttests/test_no_dedup_flag.py— impacttests/test_non_string_node_ids.py— impacttests/test_ollama.py— impacttests/test_ollama_ctx_sizing.py— impacttests/test_ollama_live.py— impacttests/test_ollama_native_chat.py— impacttests/test_ollama_retry_cap.py— impacttests/test_oversized_document_slicing.py— impacttests/test_partial_cache.py— impacttests/test_pdf_slicing.py— impacttests/test_pdf_token_estimate.py— impacttests/test_provider_registry.py— impacttests/test_prs.py— impact- … and 8 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
CHANGELOG.md§ 0.9.84 (2026-10-10) (lines 276-289): references changed symbols__init__CHANGELOG.md§ 0.9.77 (2026-10-05) (lines 370-389): references changed symbols__init__CHANGELOG.md§ 0.9.58 (2026-09-10) (lines 622-639): references changed symbols__init__CHANGELOG.md§ 0.9.44 (2026-08-15) (lines 819-831): references changed symbolscreateCHANGELOG.md§ 0.9.42 (2026-08-13) (lines 841-859): references changed symbols__init__CHANGELOG.md§ 0.9.23 (2026-07-21) (lines 1054-1063): references changed symbols__init__CHANGELOG.md§ 0.9.22 (2026-07-20) (lines 1064-1073): references changed symbols__init__CHANGELOG.md§ 0.9.4 (2026-07-01) (lines 1365-1382): references changed symbolscreateCHANGELOG.md§ 0.8.51 (2026-06-28) (lines 1426-1438): references changed symbolscreateREADME.md§ See it in action (lines 80-110): references changed symbols__init__
…and 10 more.
Formal verification
Could not verify: Could not verify \_call\_llm.
The verifier did not have enough to check \_call\_llm, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
Verification did not run: Verification did not run for \_call\_openai\_compat.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=124): timeout after 30s
Could not verify: Could not verify extract\_files\_direct.
The verifier did not have enough to check extract\_files\_direct, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 133 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
· 3 more finding(s) on lines outside this diff (see the check run).
What does this PR do?
Implements #3680 as proposed there: a custom provider in
providers.jsoncan set an optionalextra_headersobject, which is passed to the OpenAI client asdefault_headers. This is the header counterpart of the existingextra_body.The use case is OpenCode Go (
https://opencode.ai/zen/go/v1), which rejects any request withoutx-opencode-session(400 MissingSessionID). Today the only workaround is the SDK-globalOPENAI_CUSTOM_HEADERSenv var.{ "opencode-go": { "base_url": "https://opencode.ai/zen/go/v1", "default_model": "deepseek-v4-flash", "env_key": "OPENCODE_API_KEY", "extra_headers": { "x-opencode-session": "<uuid>", "User-Agent": "my-agent/1.0" } } }Changes in
graphify/llm.py:_provider_extra_headers(raw, name)validates the field:Authorizationentry (case-insensitive) with a warning, so the API key always comes from the provider'senv_keyand the config can't swap in a different credential (the issue's "ignore/drop anauthorizationentry");Nonewhen nothing usable remains.default_headerswhen the provider defines usable headers:_call_openai_compat(extraction) takes a newextra_headerskeyword argument, fed fromcfg["extra_headers"]inextract_files_direct;_call_llm(community labeling and the--dedup-llmtiebreaker).default_headerskeyword is passed.Trust model unchanged: a project-local
./.graphify/providers.jsonis still only loaded withGRAPHIFY_ALLOW_LOCAL_PROVIDERS=1, soextra_headerscomes from the same trusted config asbase_urlandextra_body.Type of change
Verification & Invariants
Invariants:
a provider without
extra_headersgets a byte-identical client construction;extra_headerscan never replace theAuthorizationheader derived fromenv_key.Read the CONTRIBUTING.md guide.
Reproduced the issue and identified the invariant.
Made the smallest fix necessary.
Added a regression test (if bug fix) or isolated boundary test.
Kept the PR description synchronized with the final implementation.
Documented any limitations / unsupported cases explicitly.
New tests in
tests/test_llm_backends.pyuse a fake OpenAI client that records its constructor kwargs:_call_openai_compatforwardsextra_headersasdefault_headers, and sends none without them;extract_files_directforwards a registered provider'sextra_headers;_call_llmforwards them;Authorizationand non-string entries are dropped with warnings, while the remaining headers are kept;None,{}, a string, onlyAuthorization) send nodefault_headers.The 4 tests that assert headers are forwarded fail on unmodified
v8. The 5 boundary tests also pass there, by design.Limitations:
claude-clipaths don't takeextra_headers, since custom providers are OpenAI-compatible only.graphify prs --triagebuilds its own client for built-in backends only, so it's unchanged.x-opencode-sessionstays a value the user sets inproviders.json, as in the issue's example.How was this tested?
Windows 11, Python 3.10,
uv sync, onv8at dac0634.I did not call the real OpenCode Go API (no key).
Graphify-specific checklist
uv run python -m tools.skillgen --bless) when changing their source fragments. (not applicable: no skill fragments touched)Authorizationcan't be overridden; project-local providers stay opt-in)