diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 17f1ba71..fd92413b 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -186,6 +186,78 @@ describe('stripGeneratedImageMetadataLines', () => { }) }) +describe('chatStore tool settlement', () => { + beforeEach(() => { + sendMock.mockReset() + updateTabStatusMock.mockReset() + notifyDesktopMock.mockReset() + localStorage.clear() + useChatStore.setState({ + ...initialState, + sessions: { + [TEST_SESSION_ID]: makeSession(), + }, + }) + }) + + it('marks sibling pending tool calls stopped when a parallel tool result fails and the turn completes', () => { + const store = useChatStore.getState() + + store.handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'tool_use', + toolName: 'Grep', + toolUseId: 'grep-1', + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Grep', + toolUseId: 'grep-1', + input: { pattern: 'needle' }, + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'tool_use', + toolName: 'Read', + toolUseId: 'read-1', + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Read', + toolUseId: 'read-1', + input: { file_path: '/missing.md' }, + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'read-1', + content: 'File does not exist', + isError: true, + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 0 }, + }) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session).toBeTruthy() + if (!session) return + const grep = session.messages.find((message) => + message.type === 'tool_use' && message.toolUseId === 'grep-1', + ) + const read = session.messages.find((message) => + message.type === 'tool_use' && message.toolUseId === 'read-1', + ) + + expect(read).toMatchObject({ type: 'tool_use', isPending: false }) + expect(grep).toMatchObject({ + type: 'tool_use', + isPending: false, + status: 'stopped', + }) + expect(session.chatState).toBe('idle') + }) +}) + describe('chatStore history mapping', () => { beforeEach(() => { sendMock.mockReset() diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 07c28a5c..642969c7 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -357,9 +357,19 @@ function upsertToolUseMessage( } function markPendingToolUseMessagesStopped(messages: UIMessage[]): UIMessage[] { + const resolvedToolUseIds = new Set( + messages + .filter((message) => message.type === 'tool_result') + .map((message) => message.toolUseId), + ) let changed = false const stoppedMessages = messages.map((message) => { - if (message.type !== 'tool_use' || !message.isPending) return message + if ( + message.type !== 'tool_use' || + (!message.isPending && resolvedToolUseIds.has(message.toolUseId)) + ) { + return message + } changed = true return { ...message, @@ -1893,8 +1903,11 @@ export const useChatStore = create((set, get) => ({ } else if (text !== session.streamingText) { update(() => ({ streamingText: text })) } + const appendedCompletionMessage = completionMessages !== session.messages + const finalMessages = markPendingToolUseMessagesStopped(completionMessages) if (session.elapsedTimer) clearInterval(session.elapsedTimer) update(() => ({ + messages: finalMessages, tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, @@ -1905,9 +1918,8 @@ export const useChatStore = create((set, get) => ({ streamingFallback: null, })) useTabStore.getState().updateTabStatus(sessionId, 'idle') - const appendedCompletionMessage = completionMessages !== session.messages const notification = wasAgentRunning && appendedCompletionMessage - ? buildAgentCompletionNotification(sessionId, completionMessages, text) + ? buildAgentCompletionNotification(sessionId, finalMessages, text) : null if (notification) { void notifyDesktop({ diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 27a607a3..59789479 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -7,6 +7,7 @@ import { buildCronCliArgs, buildCronTaskSpawnOptions, CronScheduler, + resolveCronTaskTimeoutMs, resolveCronProjectRoot, } from '../services/cronScheduler.js' import { CronService } from '../services/cronService.js' @@ -24,6 +25,7 @@ const originalHome = process.env.HOME const originalShell = process.env.SHELL const originalZdotdir = process.env.ZDOTDIR const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV +const originalTaskTimeout = process.env.CC_HAHA_TASK_TIMEOUT_MS const isWindows = process.platform === 'win32' const unixOnly = isWindows ? it.skip : it @@ -107,6 +109,11 @@ function restoreEnv(): void { } else { delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV } + if (originalTaskTimeout) { + process.env.CC_HAHA_TASK_TIMEOUT_MS = originalTaskTimeout + } else { + delete process.env.CC_HAHA_TASK_TIMEOUT_MS + } resetTerminalShellEnvironmentCacheForTests() } @@ -125,6 +132,64 @@ describe('cron scheduler launcher resolution', () => { await cleanupTmpDir(tmpDir) }) + it('uses a configurable scheduled task timeout with the default unchanged', () => { + expect(resolveCronTaskTimeoutMs({})).toBe(10 * 60 * 1000) + expect(resolveCronTaskTimeoutMs({ CC_HAHA_TASK_TIMEOUT_MS: '1800000' })).toBe(1_800_000) + expect(resolveCronTaskTimeoutMs({ CC_HAHA_TASK_TIMEOUT_MS: 'not-a-number' })).toBe(10 * 60 * 1000) + expect(resolveCronTaskTimeoutMs({ CC_HAHA_TASK_TIMEOUT_MS: '0' })).toBe(10 * 60 * 1000) + }) + + unixOnly('executeTask arms the subprocess timeout from CC_HAHA_TASK_TIMEOUT_MS', async () => { + const binDir = path.join(tmpDir, 'bin') + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const appRoot = path.join(tmpDir, 'app-root') + await fs.mkdir(binDir, { recursive: true }) + await fs.mkdir(appRoot, { recursive: true }) + await fs.writeFile( + sidecarPath, + [ + '#!/bin/sh', + '/bin/cat >/dev/null', + 'printf \'%s\\n\' \'{"type":"result","result":"timeout env ok"}\'', + 'exit 0', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(sidecarPath, 0o755) + + const originalSetTimeout = globalThis.setTimeout + const timeoutCalls: number[] = [] + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + timeoutCalls.push(timeout ?? 0) + return originalSetTimeout(handler, timeout, ...args) + }) as typeof setTimeout + + process.env.PATH = binDir + process.env.CLAUDE_CLI_PATH = sidecarPath + process.env.CLAUDE_APP_ROOT = appRoot + process.env.CC_HAHA_TASK_TIMEOUT_MS = '12345' + + try { + const cronService = new CronService() + const scheduler = new CronScheduler(cronService) + const task = await cronService.createTask({ + cron: '* * * * *', + prompt: 'cron timeout env test', + name: 'Timeout Env Task', + recurring: true, + folderPath: tmpDir, + }) + + const run = await scheduler.executeTask(task) + + expect(run.status).toBe('completed') + expect(timeoutCalls).toContain(12_345) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + it('uses the bundled sidecar launcher when one is configured', () => { const sidecarPath = path.join(tmpDir, 'claude-sidecar') const appRoot = path.join(tmpDir, 'app-root') diff --git a/src/server/__tests__/proxy-network-settings.test.ts b/src/server/__tests__/proxy-network-settings.test.ts index eae73730..681062f9 100644 --- a/src/server/__tests__/proxy-network-settings.test.ts +++ b/src/server/__tests__/proxy-network-settings.test.ts @@ -4,6 +4,10 @@ import * as os from 'os' import * as path from 'path' import { handleProxyRequest, withStreamIdleTimeout } from '../proxy/handler.js' import { ProviderService } from '../services/providerService.js' +import { + clearTraceCaptureStateForTests, + traceCaptureService, +} from '../services/traceCaptureService.js' import { resetSettingsCache } from '../../utils/settings/settingsCache.js' let tmpDir: string @@ -14,6 +18,7 @@ async function setup() { originalConfigDir = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = tmpDir resetSettingsCache() + clearTraceCaptureStateForTests() } async function teardown() { @@ -23,9 +28,19 @@ async function teardown() { delete process.env.CLAUDE_CONFIG_DIR } resetSettingsCache() + clearTraceCaptureStateForTests() await fs.rm(tmpDir, { recursive: true, force: true }) } +async function waitForTraceCall(sessionId: string) { + for (let attempt = 0; attempt < 30; attempt++) { + const trace = await traceCaptureService.getSessionTrace(sessionId) + if (trace.calls.length > 0 && trace.calls[0]?.response) return trace + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return traceCaptureService.getSessionTrace(sessionId) +} + describe('proxy network settings', () => { beforeEach(setup) afterEach(teardown) @@ -180,6 +195,70 @@ describe('proxy network settings', () => { } }) + test('records redacted OpenAI proxy request headers in trace capture', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider({ + presetId: 'custom', + name: 'OpenAI Trace Proxy', + baseUrl: 'https://api.example.com', + apiKey: 'sk-trace-secret', + apiFormat: 'openai_chat', + models: { + main: 'model-main', + haiku: 'model-main', + sonnet: 'model-main', + opus: 'model-main', + }, + }) + + const originalFetch = globalThis.fetch + globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { + return new Response(JSON.stringify({ + id: 'chatcmpl-trace-headers', + object: 'chat.completion', + created: 0, + model: 'model-main', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + try { + const req = new Request( + `http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-claude-code-session-id': 'session-proxy-trace-headers', + }, + body: JSON.stringify({ + model: 'model-main', + max_tokens: 64, + messages: [{ role: 'user', content: 'hello' }], + }), + }, + ) + + const res = await handleProxyRequest(req, new URL(req.url)) + expect(res.status).toBe(200) + + const trace = await waitForTraceCall('session-proxy-trace-headers') + expect(trace.calls).toHaveLength(1) + expect(trace.calls[0].request.headers.Authorization).toBe('[redacted]') + expect(trace.calls[0].request.headers['Content-Type']).toBe('application/json') + } finally { + globalThis.fetch = originalFetch + } + }) + test('uses configured AI request timeout while opening and reading streaming upstream requests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index 0614f040..a359d747 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -367,6 +367,58 @@ describe('titleService', () => { expect(upstreamCalls[0].body.stream).toBe(true) }) + test('honors the configured provider auth strategy for title generation', async () => { + const upstreamCalls: Array<{ headers: Record }> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + upstreamCalls.push({ + headers: Object.fromEntries(req.headers.entries()), + }) + return Response.json({ + content: [{ type: 'text', text: '{"title":"Trace ok"}' }], + }) + }, + }) + + try { + const providerId = 'auth-token-title-test' + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ + activeId: providerId, + providers: [ + { + id: providerId, + presetId: 'custom', + name: 'Bearer Provider', + apiKey: 'bearer-title-key', + authStrategy: 'auth_token', + baseUrl: `http://127.0.0.1:${server.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'bearer-main', + haiku: 'bearer-haiku', + sonnet: 'bearer-main', + opus: 'bearer-main', + }, + }, + ], + }, null, 2), + ) + + await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok') + + expect(upstreamCalls).toHaveLength(1) + expect(upstreamCalls[0].headers.authorization).toBe('Bearer bearer-title-key') + expect(upstreamCalls[0].headers['x-api-key']).toBeUndefined() + } finally { + server.stop(true) + } + }) + test('parses JSON title responses wrapped in markdown fences', () => { expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```')) .toBe('Write bash script') diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index b422d8d7..01682544 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -271,6 +271,10 @@ async function handleOpenaiChat( imageContentMode: shouldUseTextOnlyOpenAIChatContent(baseUrl) ? 'text_only' : 'vision', }) const url = `${baseUrl}/v1/chat/completions` + const upstreamRequestHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + } const proxyOptions = getProxyFetchOptions({ proxyUrl }) const startedAtMs = Date.now() const startedAt = new Date(startedAtMs).toISOString() @@ -280,6 +284,7 @@ async function handleOpenaiChat( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, }) : undefined @@ -288,10 +293,7 @@ async function handleOpenaiChat( try { upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, + headers: upstreamRequestHeaders, body: JSON.stringify(transformed), ...proxyOptions, }, aiRequestTimeoutMs, isStream) @@ -303,6 +305,7 @@ async function handleOpenaiChat( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, error: err, @@ -328,6 +331,7 @@ async function handleOpenaiChat( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, responseStatus: upstream.status, @@ -351,6 +355,7 @@ async function handleOpenaiChat( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, error: new Error('Upstream returned no body for stream'), @@ -371,6 +376,7 @@ async function handleOpenaiChat( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, responseStatus: 200, @@ -400,6 +406,7 @@ async function handleOpenaiChat( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, responseStatus: 200, @@ -434,6 +441,10 @@ async function handleOpenaiResponses( ): Promise { const transformed = anthropicToOpenaiResponses(body, { cacheKey: promptCacheKey }) const url = `${baseUrl}/v1/responses` + const upstreamRequestHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + } const proxyOptions = getProxyFetchOptions({ proxyUrl }) const startedAtMs = Date.now() const startedAt = new Date(startedAtMs).toISOString() @@ -443,6 +454,7 @@ async function handleOpenaiResponses( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, }) : undefined @@ -451,10 +463,7 @@ async function handleOpenaiResponses( try { upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, + headers: upstreamRequestHeaders, body: JSON.stringify(transformed), ...proxyOptions, }, aiRequestTimeoutMs, isStream) @@ -466,6 +475,7 @@ async function handleOpenaiResponses( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, error: err, @@ -491,6 +501,7 @@ async function handleOpenaiResponses( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, responseStatus: upstream.status, @@ -514,6 +525,7 @@ async function handleOpenaiResponses( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, error: new Error('Upstream returned no body for stream'), @@ -534,6 +546,7 @@ async function handleOpenaiResponses( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, responseStatus: 200, @@ -563,6 +576,7 @@ async function handleOpenaiResponses( model: body.model, upstreamUrl: url, upstreamRequest: transformed, + requestHeaders: upstreamRequestHeaders, startedAt, startedAtMs, responseStatus: 200, @@ -608,12 +622,14 @@ function startProxyTraceCall({ model, upstreamUrl, upstreamRequest, + requestHeaders, startedAt, }: { context: ProxyTraceContext model: string upstreamUrl: string upstreamRequest: unknown + requestHeaders: Record startedAt: string }): string { const callId = createTraceCallId() @@ -628,6 +644,7 @@ function startProxyTraceCall({ request: { method: 'POST', url: upstreamUrl, + headers: requestHeaders, bodySnapshot: createTraceBodySnapshot({ pending: true, note: 'proxy request body captured on call completion', @@ -660,6 +677,7 @@ async function recordProxyTrace({ model, upstreamUrl, upstreamRequest, + requestHeaders, startedAt, startedAtMs, responseStatus, @@ -674,6 +692,7 @@ async function recordProxyTrace({ model: string upstreamUrl: string upstreamRequest: unknown + requestHeaders?: Record startedAt: string startedAtMs: number responseStatus?: number @@ -704,6 +723,7 @@ async function recordProxyTrace({ request: { method: 'POST', url: upstreamUrl, + headers: requestHeaders, body: requestBody, }, ...(responseStatus !== undefined diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 919cb781..2caf3951 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -262,7 +262,19 @@ function trimRuns(data: RunsFile): void { // ─── Scheduler ───────────────────────────────────────────────────────────────── -const TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes +const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes + +export function resolveCronTaskTimeoutMs( + env: { CC_HAHA_TASK_TIMEOUT_MS?: string } = process.env, +): number { + const raw = env.CC_HAHA_TASK_TIMEOUT_MS?.trim() + if (!raw) return DEFAULT_TASK_TIMEOUT_MS + + const timeoutMs = Number(raw) + return Number.isInteger(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_TASK_TIMEOUT_MS +} type CronCliResolutionOptions = { cliPath?: string | null @@ -530,6 +542,7 @@ export class CronScheduler { ]) const childEnv = await this.buildTaskChildEnv(workDir, task) + const taskTimeoutMs = resolveCronTaskTimeoutMs() const proc = Bun.spawn( cliArgs, buildCronTaskSpawnOptions(workDir, childEnv), @@ -554,7 +567,7 @@ export class CronScheduler { // ignore } } - }, TASK_TIMEOUT_MS) + }, taskTimeoutMs) try { // Collect stdout @@ -585,7 +598,7 @@ export class CronScheduler { new Date(completedAt).getTime() - new Date(startedAt).getTime() // Determine if this was a timeout - const wasTimeout = durationMs >= TASK_TIMEOUT_MS + const wasTimeout = durationMs >= taskTimeoutMs // Extract only meaningful AI text responses from raw NDJSON output. // The raw stream contains system/init messages, tool_use blocks, and @@ -817,13 +830,14 @@ export class CronScheduler { const data = await readRunsFile() let changed = false const now = Date.now() + const taskTimeoutMs = resolveCronTaskTimeoutMs() for (const run of data.runs) { if (run.status !== 'running') continue const startedAt = new Date(run.startedAt).getTime() // If "running" for longer than the task timeout + 1-minute buffer, // the owning process is certainly dead. - if (now - startedAt > TASK_TIMEOUT_MS + 60_000) { + if (now - startedAt > taskTimeoutMs + 60_000) { run.status = 'failed' run.error = 'Process terminated before task could complete' run.completedAt = new Date().toISOString() diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index 50a7a456..ae4b4433 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -7,6 +7,7 @@ */ import { ProviderService } from './providerService.js' +import { getPresetAuthStrategy } from './providerRuntimeEnv.js' import { sessionService } from './sessionService.js' import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' @@ -16,6 +17,7 @@ import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenai import { openaiResponsesStreamToAnthropicResponse } from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js' import { extractConversationText, SESSION_TITLE_PROMPT } from '../../utils/sessionTitle.js' +import type { ProviderAuthStrategy } from '../types/provider.js' const TITLE_MAX_LEN = 50 const TITLE_MAX_OUTPUT_TOKENS = 100 @@ -102,6 +104,36 @@ function buildTitleUserPrompt( ].join('\n') } +function buildAnthropicTitleRequestHeaders( + apiKey: string, + authStrategy: ProviderAuthStrategy, +): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + } + + switch (authStrategy) { + case 'api_key': + headers['x-api-key'] = apiKey + break + case 'auth_token': + case 'auth_token_empty_api_key': + headers.Authorization = `Bearer ${apiKey}` + break + case 'dual_same_token': + headers['x-api-key'] = apiKey + headers.Authorization = `Bearer ${apiKey}` + break + case 'dual_dummy': + headers['x-api-key'] = 'dummy' + headers.Authorization = 'Bearer dummy' + break + } + + return headers +} + /** * Quick placeholder title derived from user message text. * Returns first sentence, collapsed to single line, max 50 chars. @@ -157,11 +189,8 @@ export async function generateTitle( const model = resolvedProvider.models.haiku || resolvedProvider.models.main const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` - const requestHeaders = { - 'Content-Type': 'application/json', - 'x-api-key': resolvedProvider.apiKey, - 'anthropic-version': '2023-06-01', - } + const authStrategy = resolvedProvider.authStrategy ?? getPresetAuthStrategy(resolvedProvider.presetId) + const requestHeaders = buildAnthropicTitleRequestHeaders(resolvedProvider.apiKey, authStrategy) const requestBody = { model, max_tokens: TITLE_MAX_OUTPUT_TOKENS,