feat(desktop): AI provider custom headers support (LLM, STT, batch)#3878
feat(desktop): AI provider custom headers support (LLM, STT, batch)#3878devin-ai-integration[bot] wants to merge 4 commits intomainfrom
Conversation
…atch)\n\n- Store custom_headers in TinyBase + Zod schema\n- Settings UI: add custom headers editor (JSON key/value)\n- LLM: pass headers to providers (OpenAI, Anthropic, Google, OpenRouter, Ollama, OpenAI-compatible)\n- STT live: plumb headers TS→Rust; apply via ListenClientBuilder.extra_header()\n- Batch: plumb headers; apply for streaming batch; prepare for non-streaming\n\nCloses #3875 Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
✅ Deploy Preview for hyprnote canceled.
|
✅ Deploy Preview for hyprnote-storybook canceled.
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| function serializeHeaders( | ||
| headers: Array<{ key: string; value: string }>, | ||
| ): string { | ||
| const filtered = headers.filter((h) => h.key.trim()); | ||
| if (filtered.length === 0) return ""; | ||
| const obj: Record<string, string> = {}; | ||
| for (const h of filtered) { | ||
| obj[h.key] = h.value; | ||
| } | ||
| return JSON.stringify(obj); | ||
| } |
There was a problem hiding this comment.
🔴 "Add header" button does nothing because new empty-key headers are immediately filtered out during serialization
When the user clicks "Add header", the addHeader function appends { key: "", value: "" } to the headers array and calls update(), which calls serializeHeaders(). However, serializeHeaders at line 284 filters out all headers where h.key.trim() is falsy. Since the newly added header has an empty key, it is immediately removed during serialization. The onChange callback receives the same serialized string as before, so the form value doesn't change and no new input row appears in the UI.
Root Cause and Impact
The flow is:
addHeader()→update([...headers, { key: "", value: "" }])update→onChange(serializeHeaders(newHeaders))serializeHeadersfilters:headers.filter((h) => h.key.trim())— the new{ key: "", value: "" }entry is removed- The serialized output is identical to before → form value unchanged → no new row renders
This means the Custom Headers editor is completely non-functional — users cannot add any headers at all.
Impact: The entire custom headers UI feature is broken. Users see an "Add header" button that does nothing when clicked.
Prompt for agents
The CustomHeadersField component needs to manage its own local state for the header rows, rather than deriving them purely from the serialized JSON string. The issue is that serializeHeaders filters out empty-key headers, but the UI needs to show empty rows so the user can type into them.
Option 1 (recommended): Use React useState for the headers array inside CustomHeadersField. Initialize from the parsed value, but keep the local state for intermediate editing. Only call onChange (which triggers serialization) when the user actually types a non-empty key. This means addHeader should only update local state, not call onChange.
Option 2: Change serializeHeaders to not filter out empty keys, and instead only filter on save/submit. But this would store invalid JSON with empty keys.
Option 3: Change the component to use local state via useState, sync from props on mount, and call onChange on blur or after a debounce rather than on every keystroke.
The key fix is in the CustomHeadersField component at apps/desktop/src/components/settings/ai/shared/index.tsx around lines 293-358. The addHeader, removeHeader, and updateHeader functions need to work with local state, and only propagate to the form (via onChange) when the data is meaningful.
Was this helpful? React with 👍 or 👎 to provide feedback.
| }, [ | ||
| current_stt_provider, |
There was a problem hiding this comment.
🔴 Missing customHeadersRaw in useMemo dependency array causes stale custom headers in STT connection
In useSTTConnection.ts, the customHeadersRaw variable is used inside the useMemo callback (lines 110 and 123) but is not included in the dependency array (lines 125-135). This means when a user updates custom headers in settings, the memoized connection object will retain the old/stale customHeaders value until some other dependency (like baseUrl, apiKey, etc.) changes.
Root Cause and Impact
At line 89, customHeadersRaw is derived from providerConfig?.custom_headers?.trim(). It's referenced at lines 110 and 123 inside the useMemo, but the dependency array at lines 125-135 does not include customHeadersRaw:
}, [
current_stt_provider,
current_stt_model,
isLocalModel,
isCloudModel,
local.data,
baseUrl,
apiKey,
auth,
billing.isPro,
]);Impact: After changing custom headers in the STT provider settings, the live STT and batch STT connections will continue using the old headers until the user also changes another setting (like base URL or API key) that triggers the memo to recompute. This makes the custom headers feature appear broken for STT.
(Refers to lines 125-135)
Was this helpful? React with 👍 or 👎 to provide feedback.
| headers: { | ||
| "anthropic-version": "2023-06-01", | ||
| "anthropic-dangerous-direct-browser-access": "true", | ||
| ...conn.customHeaders, | ||
| }, |
There was a problem hiding this comment.
🟡 Custom headers can override required Anthropic default headers via object spread
In the Anthropic provider case in createLanguageModel, custom headers are spread after the required default headers using ...conn.customHeaders. If a user sets a custom header with key anthropic-version or anthropic-dangerous-direct-browser-access, it will override the required defaults, potentially breaking Anthropic API calls.
Root Cause and Impact
At useLLMConnection.ts:260-264:
headers: {
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
...conn.customHeaders, // can override the above
},The spread operator places custom headers after the defaults, so any matching keys in conn.customHeaders will take precedence. For example, if a user accidentally or intentionally sets anthropic-version to a wrong value, the Anthropic API call would fail with an API version error.
Impact: Users who set custom headers with names matching Anthropic's required headers will break their Anthropic integration. The fix should spread custom headers first, then apply the required defaults, or filter out the protected header names from custom headers.
| headers: { | |
| "anthropic-version": "2023-06-01", | |
| "anthropic-dangerous-direct-browser-access": "true", | |
| ...conn.customHeaders, | |
| }, | |
| headers: { | |
| ...conn.customHeaders, | |
| "anthropic-version": "2023-06-01", | |
| "anthropic-dangerous-direct-browser-access": "true", | |
| }, | |
Was this helpful? React with 👍 or 👎 to provide feedback.
…stency Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
… compatibility Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
| type: "llm" | "stt"; | ||
| base_url: string; | ||
| api_key: string; | ||
| custom_headers: string; |
There was a problem hiding this comment.
Type mismatch: custom_headers is defined as required (non-optional) but lines 118-119 and 161 conditionally include it. This creates a type safety issue where ProviderRow objects may be missing the required field.
Fix by making it optional:
custom_headers?: string;| custom_headers: string; | |
| custom_headers?: string; |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
Summary
Implements optional custom HTTP headers for AI providers end-to-end:
Generated TS bindings were updated in repo (plugins/listener/js/bindings.gen.ts, plugins/listener2/js/bindings.gen.ts) to keep TS types in sync until Specta regenerates them in CI.
Linked issue: #3875
Review & Testing Checklist for Human (5 items)
Recommended test plan:
Notes