-
Notifications
You must be signed in to change notification settings - Fork 540
feat(desktop): AI provider custom headers support (LLM, STT, batch) #3878
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ac32995
4479d8f
6e5be39
2437742
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -30,6 +30,7 @@ type LLMConnectionInfo = { | |||||||||||||||||||||||
| modelId: string; | ||||||||||||||||||||||||
| baseUrl: string; | ||||||||||||||||||||||||
| apiKey: string; | ||||||||||||||||||||||||
| customHeaders: Record<string, string>; | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export type LLMConnectionStatus = | ||||||||||||||||||||||||
|
|
@@ -142,6 +143,7 @@ const resolveLLMConnection = (params: { | |||||||||||||||||||||||
| providerDefinition.baseUrl?.trim() || | ||||||||||||||||||||||||
| ""; | ||||||||||||||||||||||||
| const apiKey = providerConfig?.api_key?.trim() || ""; | ||||||||||||||||||||||||
| const customHeaders = parseCustomHeaders(providerConfig?.custom_headers); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const context: ProviderEligibilityContext = { | ||||||||||||||||||||||||
| isAuthenticated: !!session, | ||||||||||||||||||||||||
|
|
@@ -188,13 +190,14 @@ const resolveLLMConnection = (params: { | |||||||||||||||||||||||
| modelId, | ||||||||||||||||||||||||
| baseUrl: baseUrl ?? new URL("/llm", env.VITE_AI_URL).toString(), | ||||||||||||||||||||||||
| apiKey: session.access_token, | ||||||||||||||||||||||||
| customHeaders, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| status: { status: "success", providerId, isHosted: true }, | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||
| conn: { providerId, modelId, baseUrl, apiKey }, | ||||||||||||||||||||||||
| conn: { providerId, modelId, baseUrl, apiKey, customHeaders }, | ||||||||||||||||||||||||
| status: { status: "success", providerId, isHosted: false }, | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
@@ -226,13 +229,26 @@ const wrapWithThinkingMiddleware = ( | |||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function parseCustomHeaders(raw: string | undefined): Record<string, string> { | ||||||||||||||||||||||||
| if (!raw?.trim()) return {}; | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| return JSON.parse(raw) as Record<string, string>; | ||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||
| return {}; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | ||||||||||||||||||||||||
| const h = | ||||||||||||||||||||||||
| Object.keys(conn.customHeaders).length > 0 ? conn.customHeaders : undefined; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| switch (conn.providerId) { | ||||||||||||||||||||||||
| case "hyprnote": { | ||||||||||||||||||||||||
| const provider = createOpenRouter({ | ||||||||||||||||||||||||
| fetch: tracedFetch, | ||||||||||||||||||||||||
| baseURL: conn.baseUrl, | ||||||||||||||||||||||||
| apiKey: conn.apiKey, | ||||||||||||||||||||||||
| headers: h, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| return wrapWithThinkingMiddleware(provider.chat(conn.modelId)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -244,6 +260,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | |||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||
| "anthropic-version": "2023-06-01", | ||||||||||||||||||||||||
| "anthropic-dangerous-direct-browser-access": "true", | ||||||||||||||||||||||||
| ...conn.customHeaders, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
|
Comment on lines
260
to
264
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Custom headers can override required Anthropic default headers via object spread In the Anthropic provider case in Root Cause and ImpactAt 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 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.
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| return wrapWithThinkingMiddleware(provider(conn.modelId)); | ||||||||||||||||||||||||
|
|
@@ -254,6 +271,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | |||||||||||||||||||||||
| fetch: tauriFetch, | ||||||||||||||||||||||||
| baseURL: conn.baseUrl, | ||||||||||||||||||||||||
| apiKey: conn.apiKey, | ||||||||||||||||||||||||
| headers: h, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| return wrapWithThinkingMiddleware(provider(conn.modelId)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -262,6 +280,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | |||||||||||||||||||||||
| const provider = createOpenRouter({ | ||||||||||||||||||||||||
| fetch: tauriFetch, | ||||||||||||||||||||||||
| apiKey: conn.apiKey, | ||||||||||||||||||||||||
| headers: h, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| return wrapWithThinkingMiddleware(provider.chat(conn.modelId)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -271,6 +290,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | |||||||||||||||||||||||
| fetch: tauriFetch, | ||||||||||||||||||||||||
| baseURL: conn.baseUrl, | ||||||||||||||||||||||||
| apiKey: conn.apiKey, | ||||||||||||||||||||||||
| headers: h, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| return wrapWithThinkingMiddleware(provider(conn.modelId)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -280,6 +300,9 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | |||||||||||||||||||||||
| const ollamaFetch: typeof fetch = async (input, init) => { | ||||||||||||||||||||||||
| const headers = new Headers(init?.headers); | ||||||||||||||||||||||||
| headers.set("Origin", ollamaOrigin); | ||||||||||||||||||||||||
| for (const [k, v] of Object.entries(conn.customHeaders)) { | ||||||||||||||||||||||||
| headers.set(k, v); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return tauriFetch(input as RequestInfo | URL, { | ||||||||||||||||||||||||
| ...init, | ||||||||||||||||||||||||
| headers, | ||||||||||||||||||||||||
|
|
@@ -298,6 +321,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { | |||||||||||||||||||||||
| fetch: tauriFetch, | ||||||||||||||||||||||||
| name: conn.providerId, | ||||||||||||||||||||||||
| baseURL: conn.baseUrl, | ||||||||||||||||||||||||
| headers: h, | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| if (conn.apiKey) { | ||||||||||||||||||||||||
| config.apiKey = conn.apiKey; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,7 @@ export const useSTTConnection = () => { | |
| model: current_stt_model, | ||
| baseUrl: server.url, | ||
| apiKey: "", | ||
| customHeaders: undefined as string | undefined, | ||
| }, | ||
| }; | ||
| } | ||
|
|
@@ -86,6 +87,7 @@ export const useSTTConnection = () => { | |
|
|
||
| const baseUrl = providerConfig?.base_url?.trim(); | ||
| const apiKey = providerConfig?.api_key?.trim(); | ||
| const customHeadersRaw = providerConfig?.custom_headers?.trim(); | ||
|
|
||
| const connection = useMemo(() => { | ||
| if (!current_stt_provider || !current_stt_model) { | ||
|
|
@@ -106,6 +108,7 @@ export const useSTTConnection = () => { | |
| model: current_stt_model, | ||
| baseUrl: baseUrl ?? new URL("/stt", env.VITE_AI_URL).toString(), | ||
| apiKey: auth.session.access_token, | ||
| customHeaders: customHeadersRaw, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -118,6 +121,7 @@ export const useSTTConnection = () => { | |
| model: current_stt_model, | ||
| baseUrl, | ||
| apiKey, | ||
| customHeaders: customHeadersRaw, | ||
| }; | ||
| }, [ | ||
| current_stt_provider, | ||
|
Comment on lines
126
to
127
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Missing In Root Cause and ImpactAt line 89, }, [
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. |
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -3,8 +3,17 @@ import type { Content } from "tinybase/with-schemas"; | |||||
| import type { Schemas, Store } from "../../store/settings"; | ||||||
| import { SETTINGS_MAPPING } from "../../store/settings"; | ||||||
|
|
||||||
| type ProviderData = { base_url: string; api_key: string }; | ||||||
| type ProviderRow = { type: "llm" | "stt"; base_url: string; api_key: string }; | ||||||
| type ProviderData = { | ||||||
| base_url: string; | ||||||
| api_key: string; | ||||||
| custom_headers?: string; | ||||||
| }; | ||||||
| type ProviderRow = { | ||||||
| type: "llm" | "stt"; | ||||||
| base_url: string; | ||||||
| api_key: string; | ||||||
| custom_headers: string; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type mismatch: Fix by making it optional: custom_headers?: string;
Suggested change
Spotted by Graphite Agent |
||||||
| }; | ||||||
|
|
||||||
| const JSON_ARRAY_FIELDS = new Set([ | ||||||
| "spoken_languages", | ||||||
|
|
@@ -106,7 +115,10 @@ function settingsToProviderRows( | |||||
| type: providerType, | ||||||
| base_url: data.base_url ?? "", | ||||||
| api_key: data.api_key ?? "", | ||||||
| }; | ||||||
| ...(data.custom_headers | ||||||
| ? { custom_headers: data.custom_headers } | ||||||
| : {}), | ||||||
| } as ProviderRow; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
@@ -143,9 +155,13 @@ function providerRowsToSettings(rows: Record<string, ProviderRow>): { | |||||
| }; | ||||||
|
|
||||||
| for (const [rowId, row] of Object.entries(rows)) { | ||||||
| const { type, base_url, api_key } = row; | ||||||
| const { type, base_url, api_key, custom_headers } = row; | ||||||
| if (type === "llm" || type === "stt") { | ||||||
| result[type][rowId] = { base_url, api_key }; | ||||||
| result[type][rowId] = { | ||||||
| base_url, | ||||||
| api_key, | ||||||
| ...(custom_headers ? { custom_headers } : {}), | ||||||
| }; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 "Add header" button does nothing because new empty-key headers are immediately filtered out during serialization
When the user clicks "Add header", the
addHeaderfunction appends{ key: "", value: "" }to the headers array and callsupdate(), which callsserializeHeaders(). However,serializeHeadersat line 284 filters out all headers whereh.key.trim()is falsy. Since the newly added header has an empty key, it is immediately removed during serialization. TheonChangecallback 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 removedThis 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
Was this helpful? React with 👍 or 👎 to provide feedback.