Skip to content

feat(llm): forward a custom provider's extra_headers as default_headers (#3680) - #4783

Open
jaimemartinez wants to merge 1 commit into
Graphify-Labs:v8from
jaimemartinez:feat/provider-extra-headers
Open

jaimemartinez wants to merge 1 commit into
Graphify-Labs:v8from
jaimemartinez:feat/provider-extra-headers

Conversation

@jaimemartinez

Copy link
Copy Markdown
Contributor

What does this PR do?

Implements #3680 as proposed there: a custom provider in providers.json can set an optional extra_headers object, which is passed to the OpenAI client as default_headers. This is the header counterpart of the existing extra_body.

The use case is OpenCode Go (https://opencode.ai/zen/go/v1), which rejects any request without x-opencode-session (400 MissingSessionID). Today the only workaround is the SDK-global OPENAI_CUSTOM_HEADERS env 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:
    • it keeps only string-to-string entries;
    • it drops an Authorization entry (case-insensitive) with a warning, so the API key always comes from the provider's env_key and the config can't swap in a different credential (the issue's "ignore/drop an authorization entry");
    • a non-object value is ignored with a warning;
    • it returns None when nothing usable remains.
  • Both client construction sites named in the issue pass default_headers when the provider defines usable headers:
    • _call_openai_compat (extraction) takes a new extra_headers keyword argument, fed from cfg["extra_headers"] in extract_files_direct;
    • _call_llm (community labeling and the --dedup-llm tiebreaker).
  • Without the field, the client is constructed exactly as before: no default_headers keyword is passed.

Trust model unchanged: a project-local ./.graphify/providers.json is still only loaded with GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1, so extra_headers comes from the same trusted config as base_url and extra_body.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

Invariants:

  • a provider without extra_headers gets a byte-identical client construction;

  • extra_headers can never replace the Authorization header derived from env_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.py use a fake OpenAI client that records its constructor kwargs:

  • _call_openai_compat forwards extra_headers as default_headers, and sends none without them;
  • extract_files_direct forwards a registered provider's extra_headers;
  • _call_llm forwards them;
  • Authorization and non-string entries are dropped with warnings, while the remaining headers are kept;
  • unusable values (None, {}, a string, only Authorization) send no default_headers.

The 4 tests that assert headers are forwarded fail on unmodified v8. The 5 boundary tests also pass there, by design.

Limitations:

  • The Anthropic, Bedrock, Azure and claude-cli paths don't take extra_headers, since custom providers are OpenAI-compatible only.
  • graphify prs --triage builds its own client for built-in backends only, so it's unchanged.
  • Header values are static strings. A per-conversation x-opencode-session stays a value the user sets in providers.json, as in the issue's example.

How was this tested?

Windows 11, Python 3.10, uv sync, on v8 at dac0634.

uv run pytest tests/test_llm_backends.py -k extra_headers -q   # 9 passed (4 fail without the change)
uv run pytest tests/test_llm_backends.py tests/test_ollama_native_chat.py tests/test_llm*.py -q   # 193 passed
uv run ruff check graphify/llm.py tests/test_llm_backends.py    # All checks passed
uv run pyright graphify/llm.py tests/test_llm_backends.py       # 41 errors, the same 41 as on unmodified v8

I did not call the real OpenCode Go API (no key).

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (not applicable: no skill fragments touched)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python). (Authorization can't be overridden; project-local providers stay opt-in)
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…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.
@github-actions

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Oct 26, 2026

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — impact
  • tests/test_backend_extras.py — impact
  • tests/test_backend_opt_in.py — impact
  • tests/test_build.py — impact
  • tests/test_build_located_semantic_identity.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_c_include_resolution.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_claude_cli_backend.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_dedup_shrink_refuses_force_write.py — impact
  • tests/test_dedup_survivor_richness.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_ext_tagged_id_clash.py — impact
  • tests/test_file_slice.py — impact
  • tests/test_file_truncation_warning.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_hyperedge_member_shapes.py — impact
  • tests/test_image_vision.py — impact
  • tests/test_injection_sentinel_coverage.py — impact
  • tests/test_issue_3472_source_file_collision.py — impact
  • tests/test_js_member_collisions.py — impact
  • tests/test_label_retry.py — impact
  • tests/test_labeling.py — impact
  • tests/test_llm_backends.py — impact, changed-test
  • tests/test_llm_null_token_counts.py — impact
  • tests/test_llm_parser.py — impact
  • tests/test_llm_parser_reasoning.py — impact
  • tests/test_merge_sweeps_reextract_orphans.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_non_string_node_ids.py — impact
  • tests/test_ollama.py — impact
  • tests/test_ollama_ctx_sizing.py — impact
  • tests/test_ollama_live.py — impact
  • tests/test_ollama_native_chat.py — impact
  • tests/test_ollama_retry_cap.py — impact
  • tests/test_oversized_document_slicing.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_pdf_slicing.py — impact
  • tests/test_pdf_token_estimate.py — impact
  • tests/test_provider_registry.py — impact
  • tests/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)

…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).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant