fix: resolve first issue 809 batch

Fixes local Batch A from #809+ triage: configurable scheduled task timeout (#809/#846), OpenAI proxy trace request headers (#836), title generation auth strategy (#854), and unresolved parallel tool card settlement (#889).

Tested: bun test src/server/__tests__/cron-scheduler-launcher.test.ts

Tested: bun test src/server/__tests__/title-service.test.ts

Tested: bun test src/server/__tests__/proxy-network-settings.test.ts

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts

Tested: bun run check:server

Tested: bun run check:desktop

Not-tested: bun run verify and coverage were not run because this is a local batch checkpoint, not a PR-ready/push-ready handoff.

Confidence: high

Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-22 22:45:26 +08:00
parent e944db1e2f
commit 2f8c2a9a70
8 changed files with 363 additions and 20 deletions

View File

@ -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()

View File

@ -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<ChatStore>((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<ChatStore>((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({

View File

@ -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')

View File

@ -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'),

View File

@ -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<string, string> }> = []
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')

View File

@ -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<Response> {
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<string, string>
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<string, string>
startedAt: string
startedAtMs: number
responseStatus?: number
@ -704,6 +723,7 @@ async function recordProxyTrace({
request: {
method: 'POST',
url: upstreamUrl,
headers: requestHeaders,
body: requestBody,
},
...(responseStatus !== undefined

View File

@ -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()

View File

@ -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<string, string> {
const headers: Record<string, string> = {
'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,