Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 108 additions & 9 deletions apps/desktop/src/components/settings/ai/shared/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function NonHyprProviderCard({
type: providerType,
base_url: config.baseUrl ?? "",
api_key: "",
custom_headers: "",
} satisfies AIProvider),
listeners: {
onChange: ({ formApi }) => {
Expand Down Expand Up @@ -186,18 +187,26 @@ export function NonHyprProviderCard({
)}
</form.Field>
)}
{!showBaseUrl && config.baseUrl && (
<details className="flex flex-col gap-4 pt-2">
<summary className="text-xs cursor-pointer text-neutral-600 hover:text-neutral-900 hover:underline">
Advanced
</summary>
<div className="mt-4">
<details className="flex flex-col gap-4 pt-2">
<summary className="text-xs cursor-pointer text-neutral-600 hover:text-neutral-900 hover:underline">
Advanced
</summary>
<div className="mt-4 flex flex-col gap-4">
{!showBaseUrl && config.baseUrl && (
<form.Field name="base_url">
{(field) => <FormField field={field} label="Base URL" />}
</form.Field>
</div>
</details>
)}
)}
<form.Field name="custom_headers">
{(field) => (
<CustomHeadersField
value={String(field.state.value ?? "")}
onChange={(v) => field.handleChange(v)}
/>
)}
</form.Field>
</div>
</details>
</form>
</AccordionContent>
</AccordionItem>
Expand Down Expand Up @@ -259,6 +268,96 @@ function useProvider(id: string) {
return [data, setProvider] as const;
}

function parseHeaders(value: string): Array<{ key: string; value: string }> {
if (!value.trim()) return [];
try {
const parsed = JSON.parse(value) as Record<string, string>;
return Object.entries(parsed).map(([k, v]) => ({ key: k, value: v }));
} catch {
return [];
}
}

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);
}
Comment on lines +281 to +291
Copy link
Contributor Author

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 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:

  1. addHeader()update([...headers, { key: "", value: "" }])
  2. updateonChange(serializeHeaders(newHeaders))
  3. serializeHeaders filters: headers.filter((h) => h.key.trim()) — the new { key: "", value: "" } entry is removed
  4. 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


function CustomHeadersField({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const headers = parseHeaders(value);

const update = (newHeaders: Array<{ key: string; value: string }>) => {
onChange(serializeHeaders(newHeaders));
};

const addHeader = () => {
update([...headers, { key: "", value: "" }]);
};

const removeHeader = (index: number) => {
update(headers.filter((_, i) => i !== index));
};

const updateHeader = (index: number, field: "key" | "value", val: string) => {
const updated = headers.map((h, i) =>
i === index ? { ...h, [field]: val } : h,
);
update(updated);
};

return (
<div className="flex flex-col gap-2">
<label className="block text-xs font-medium">Custom Headers</label>
{headers.map((header, index) => (
<div key={index} className="flex gap-2 items-center">
<InputGroup className="bg-white flex-1">
<InputGroupInput
placeholder="Header name"
value={header.key}
onChange={(e) => updateHeader(index, "key", e.target.value)}
/>
</InputGroup>
<InputGroup className="bg-white flex-1">
<InputGroupInput
placeholder="Header value"
value={header.value}
onChange={(e) => updateHeader(index, "value", e.target.value)}
/>
</InputGroup>
<button
type="button"
onClick={() => removeHeader(index)}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<Icon icon="mdi:close" width={16} />
</button>
</div>
))}
<button
type="button"
onClick={addHeader}
className="text-xs text-neutral-500 hover:text-neutral-700 flex items-center gap-1 w-fit"
>
<Icon icon="mdi:plus" width={14} />
<span>Add header</span>
</button>
</div>
);
}

function FormField({
field,
label,
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/hooks/useLLMConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type LLMConnectionInfo = {
modelId: string;
baseUrl: string;
apiKey: string;
customHeaders: Record<string, string>;
};

export type LLMConnectionStatus =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
};
};
Expand Down Expand Up @@ -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));
}
Expand All @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 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.

Suggested change
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",
},
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

});
return wrapWithThinkingMiddleware(provider(conn.modelId));
Expand All @@ -254,6 +271,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => {
fetch: tauriFetch,
baseURL: conn.baseUrl,
apiKey: conn.apiKey,
headers: h,
});
return wrapWithThinkingMiddleware(provider(conn.modelId));
}
Expand All @@ -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));
}
Expand All @@ -271,6 +290,7 @@ const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => {
fetch: tauriFetch,
baseURL: conn.baseUrl,
apiKey: conn.apiKey,
headers: h,
});
return wrapWithThinkingMiddleware(provider(conn.modelId));
}
Expand All @@ -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,
Expand All @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/hooks/useRunBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ export const useRunBatch = (sessionId: string) => {
]);
});

const customHeadersObj = (() => {
try {
return conn.customHeaders
? JSON.parse(conn.customHeaders)
: undefined;
} catch {
return undefined;
}
})();

const params: BatchParams = {
session_id: sessionId,
provider,
Expand All @@ -173,6 +183,9 @@ export const useRunBatch = (sessionId: string) => {
api_key: options?.apiKey ?? conn.apiKey,
keywords: options?.keywords ?? keywords ?? [],
languages: options?.languages ?? languages ?? [],
...(customHeadersObj
? { custom_headers: customHeadersObj as Record<string, string> }
: {}),
};

await runBatch(params, { handlePersist: persist, sessionId });
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/hooks/useSTTConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const useSTTConnection = () => {
model: current_stt_model,
baseUrl: server.url,
apiKey: "",
customHeaders: undefined as string | undefined,
},
};
}
Expand All @@ -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) {
Expand All @@ -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,
};
}

Expand All @@ -118,6 +121,7 @@ export const useSTTConnection = () => {
model: current_stt_model,
baseUrl,
apiKey,
customHeaders: customHeadersRaw,
};
}, [
current_stt_provider,
Comment on lines 126 to 127
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🔴 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)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/hooks/useStartListening.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ export function useStartListening(sessionId: string) {
});
};

const customHeadersObj = (() => {
try {
return conn.customHeaders ? JSON.parse(conn.customHeaders) : undefined;
} catch {
return undefined;
}
})();

start(
{
session_id: sessionId,
Expand All @@ -129,6 +137,9 @@ export function useStartListening(sessionId: string) {
base_url: conn.baseUrl,
api_key: conn.apiKey,
keywords,
...(customHeadersObj
? { custom_headers: customHeadersObj as Record<string, string> }
: {}),
},
{
handlePersist,
Expand Down
26 changes: 21 additions & 5 deletions apps/desktop/src/store/tinybase/persister/settings/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

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

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;
Suggested change
custom_headers: string;
custom_headers?: string;

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

};

const JSON_ARRAY_FIELDS = new Set([
"spoken_languages",
Expand Down Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -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 } : {}),
};
}
}

Expand Down
Loading
Loading