diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index 1558f0bb..541c2389 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -2,20 +2,27 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' +import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js' +import { ProviderService } from '../services/providerService.js' import { deriveTitle, generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js' import { sessionService } from '../services/sessionService.js' +import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js' describe('titleService', () => { let tmpDir: string let originalConfigDir: string | undefined + let originalFetch: typeof globalThis.fetch beforeEach(async () => { originalConfigDir = process.env.CLAUDE_CONFIG_DIR + originalFetch = globalThis.fetch tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'title-service-test-')) process.env.CLAUDE_CONFIG_DIR = tmpDir }) afterEach(async () => { + globalThis.fetch = originalFetch + hahaOpenAIOAuthService.dispose() restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -185,6 +192,46 @@ describe('titleService', () => { } }) + test('generates titles when ChatGPT Official OAuth is active', async () => { + const providerService = new ProviderService() + await providerService.activateProvider('openai-official') + await hahaOpenAIOAuthService.saveTokens({ + accessToken: 'access-for-title', + refreshToken: 'refresh-for-title', + expiresAt: Date.now() + 60 * 60_000, + accountId: 'acct_title', + email: 'title@example.com', + }) + + const upstreamCalls: Array<{ + url: string + headers: Record + body: Record + }> = [] + globalThis.fetch = (async (input, init) => { + const headers = new Headers(init?.headers) + upstreamCalls.push({ + url: String(input), + headers: Object.fromEntries(headers.entries()), + body: JSON.parse(String(init?.body)) as Record, + }) + return new Response([ + 'event: response.completed', + 'data: {"response":{"id":"resp_title","object":"response","created_at":1779118000,"model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\\"title\\":\\"Trace ok\\"}"}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}', + '', + ].join('\n'), { + headers: { 'Content-Type': 'text/event-stream' }, + }) + }) as typeof fetch + + await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok') + expect(upstreamCalls).toHaveLength(1) + expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT) + expect(upstreamCalls[0].headers.authorization).toBe('Bearer access-for-title') + expect(upstreamCalls[0].headers['chatgpt-account-id']).toBe('acct_title') + expect(upstreamCalls[0].body.stream).toBe(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/streaming/openaiResponsesStreamToAnthropicResponse.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts new file mode 100644 index 00000000..b29cf02d --- /dev/null +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts @@ -0,0 +1,163 @@ +import { openaiResponsesToAnthropic } from '../transform/openaiResponsesToAnthropic.js' +import type { + AnthropicResponse, + OpenAIResponsesResponse, +} from '../transform/types.js' + +type StreamFallbackState = { + id: string + createdAt: number + model: string + status: string + textByKey: Map + usage: OpenAIResponsesResponse['usage'] +} + +/** + * Collect a streamed OpenAI Responses API response into one Anthropic message. + * ChatGPT Codex requires upstream stream=true, but some Anthropic SDK callers + * still expect a non-streaming JSON response. + */ +export async function openaiResponsesStreamToAnthropicResponse( + upstream: ReadableStream, + model: string, +): Promise { + const decoder = new TextDecoder() + const reader = upstream.getReader() + let buffer = '' + let currentEvent = '' + let completedResponse: OpenAIResponsesResponse | null = null + + const fallback: StreamFallbackState = { + id: `resp_${Date.now()}`, + createdAt: Math.floor(Date.now() / 1000), + model, + status: 'completed', + textByKey: new Map(), + usage: undefined, + } + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('event: ')) { + currentEvent = trimmed.slice(7).trim() + continue + } + + if (!trimmed.startsWith('data: ')) { + if (trimmed === '') currentEvent = '' + continue + } + + const jsonStr = trimmed.slice(6) + if (jsonStr === '[DONE]') { + currentEvent = '' + continue + } + + let data: Record + try { + data = JSON.parse(jsonStr) as Record + } catch { + currentEvent = '' + continue + } + + if (currentEvent === 'response.completed') { + const response = data.response + if (isOpenAIResponsesResponse(response)) { + completedResponse = response + } + } else { + updateFallbackState(currentEvent, data, fallback) + } + currentEvent = '' + } + } + + return openaiResponsesToAnthropic( + completedResponse ?? buildFallbackResponse(fallback), + model, + ) +} + +function updateFallbackState( + event: string, + data: Record, + fallback: StreamFallbackState, +): void { + if (event === 'response.created') { + if (typeof data.id === 'string') fallback.id = data.id + if (typeof data.created_at === 'number') fallback.createdAt = data.created_at + if (typeof data.model === 'string') fallback.model = data.model + if (typeof data.status === 'string') fallback.status = data.status + return + } + + if (event === 'response.output_text.delta') { + const key = getTextKey(data) + const delta = typeof data.delta === 'string' ? data.delta : '' + fallback.textByKey.set(key, (fallback.textByKey.get(key) ?? '') + delta) + return + } + + if (event === 'response.output_text.done') { + const text = typeof data.text === 'string' ? data.text : null + if (text !== null) fallback.textByKey.set(getTextKey(data), text) + return + } + + if (event === 'response.refusal.delta') { + const key = getTextKey(data) + const delta = typeof data.delta === 'string' ? data.delta : '' + fallback.textByKey.set(key, (fallback.textByKey.get(key) ?? '') + delta) + } +} + +function buildFallbackResponse( + fallback: StreamFallbackState, +): OpenAIResponsesResponse { + const text = [...fallback.textByKey.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, value]) => value) + .join('') + + return { + id: fallback.id, + object: 'response', + created_at: fallback.createdAt, + model: fallback.model, + status: fallback.status, + output: [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text }], + }], + usage: fallback.usage, + } +} + +function getTextKey(data: Record): string { + const outputIndex = typeof data.output_index === 'number' ? data.output_index : 0 + const contentIndex = typeof data.content_index === 'number' ? data.content_index : 0 + return `${outputIndex}:${contentIndex}` +} + +function isOpenAIResponsesResponse( + value: unknown, +): value is OpenAIResponsesResponse { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Array.isArray((value as { output?: unknown }).output) + ) +} diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index da21b45e..a815f9af 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -9,9 +9,16 @@ import { ProviderService } from './providerService.js' import { SettingsService } from './settingsService.js' import { sessionService } from './sessionService.js' +import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' +import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' +import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js' +import { resolveOpenAICodexModel } from '../../services/openaiAuth/models.js' +import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js' +import { openaiResponsesStreamToAnthropicResponse } from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js' const TITLE_MAX_LEN = 50 +const TITLE_MAX_OUTPUT_TOKENS = 100 const TITLE_SYSTEM_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. @@ -63,10 +70,19 @@ export async function generateTitle( if (!resolvedProvider) { const { activeId, providers } = await providerService.listProviders() resolvedProvider = activeId - ? providers.find((provider) => provider.id === activeId) ?? null + ? isOpenAIOfficialProviderId(activeId) + ? await providerService.getProvider(activeId) + : providers.find((provider) => provider.id === activeId) ?? null : null } + if (resolvedProvider && isOpenAIOfficialProviderId(resolvedProvider.id)) { + return await generateOpenAIOfficialTitle( + trimmed, + resolvedProvider.models.haiku || resolvedProvider.models.main, + ) + } + if (!resolvedProvider?.baseUrl || !resolvedProvider?.apiKey) return null const model = resolvedProvider.models.haiku || resolvedProvider.models.main @@ -104,6 +120,51 @@ export async function generateTitle( } } +async function generateOpenAIOfficialTitle( + trimmed: string, + model: string, +): Promise { + const tokens = await hahaOpenAIOAuthService.ensureFreshTokens() + if (!tokens?.accessToken) return null + + const mappedModel = resolveOpenAICodexModel(model) + const requestBody = anthropicToOpenaiResponses({ + model: mappedModel, + max_tokens: TITLE_MAX_OUTPUT_TOKENS, + system: TITLE_SYSTEM_PROMPT, + messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], + stream: true, + thinking: { type: 'disabled' }, + }) + requestBody.stream = true + requestBody.max_output_tokens = TITLE_MAX_OUTPUT_TOKENS + + const headers = new Headers() + headers.set('Content-Type', 'application/json') + headers.set('Authorization', `Bearer ${tokens.accessToken}`) + if (tokens.accountId) { + headers.set('ChatGPT-Account-Id', tokens.accountId) + } + + const response = await fetch(OPENAI_CODEX_API_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(15_000), + }) + + if (!response.ok || !response.body) return null + + const body = await openaiResponsesStreamToAnthropicResponse( + response.body, + mappedModel, + ) + const text = body.content.find((b) => b.type === 'text')?.text + if (!text) return null + + return parseGeneratedTitleText(text) +} + export function parseGeneratedTitleText(text: string): string | null { const trimmed = text.trim() if (!trimmed) return null diff --git a/src/services/openaiAuth/fetch.test.ts b/src/services/openaiAuth/fetch.test.ts index d245cb8d..ace92bb7 100644 --- a/src/services/openaiAuth/fetch.test.ts +++ b/src/services/openaiAuth/fetch.test.ts @@ -88,4 +88,46 @@ describe('buildOpenAICodexFetch', () => { content: [{ type: 'text', text: 'ok' }], }) }) + + test('uses streamed Codex responses even for non-streaming Anthropic callers', async () => { + const upstreamCalls: Array<{ + url: string + body: Record + }> = [] + const fetchOverride: typeof fetch = async (input, init) => { + const body = JSON.parse(String(init?.body)) as Record + upstreamCalls.push({ + url: String(input), + body, + }) + return new Response([ + 'event: response.completed', + 'data: {"response":{"id":"resp_456","object":"response","created_at":1779118000,"model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"streamed ok"}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}', + '', + ].join('\n'), { + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + + const openAIFetch = buildOpenAICodexFetch(fetchOverride, 'test') + const response = await openAIFetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model: 'gpt-5.5', + max_tokens: 64, + messages: [{ role: 'user', content: 'Say ok' }], + }), + }) + + expect(upstreamCalls).toHaveLength(1) + expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT) + expect(upstreamCalls[0].body.stream).toBe(true) + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('application/json') + await expect(response.json()).resolves.toMatchObject({ + type: 'message', + model: 'gpt-5.5', + content: [{ type: 'text', text: 'streamed ok' }], + }) + }) }) diff --git a/src/services/openaiAuth/fetch.ts b/src/services/openaiAuth/fetch.ts index 51bfe53b..35f44a27 100644 --- a/src/services/openaiAuth/fetch.ts +++ b/src/services/openaiAuth/fetch.ts @@ -7,6 +7,7 @@ import { getOpenAIOAuthTokens } from './storage.js' import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js' import { openaiResponsesToAnthropic } from '../../server/proxy/transform/openaiResponsesToAnthropic.js' import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropic.js' +import { openaiResponsesStreamToAnthropicResponse } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' import type { AnthropicRequest } from '../../server/proxy/transform/types.js' import { logForDebugging } from '../../utils/debug.js' @@ -36,6 +37,10 @@ export function buildOpenAICodexFetch( ...originalBody, model: mappedModel, }) + const upstreamBody = { + ...transformedBody, + stream: true, + } const tokens = await ensureFreshOpenAITokens() if (!tokens) { @@ -58,7 +63,7 @@ export function buildOpenAICodexFetch( const upstream = await inner(OPENAI_CODEX_API_ENDPOINT, { method: 'POST', headers, - body: JSON.stringify(transformedBody), + body: JSON.stringify(upstreamBody), signal: init?.signal, }) @@ -103,6 +108,14 @@ export function buildOpenAICodexFetch( ) } + if (upstream.body && isEventStreamResponse(upstream)) { + const responseBody = await openaiResponsesStreamToAnthropicResponse( + upstream.body, + mappedModel, + ) + return Response.json(responseBody) + } + const responseBody = await upstream.json() return Response.json( openaiResponsesToAnthropic(responseBody, mappedModel), @@ -110,6 +123,12 @@ export function buildOpenAICodexFetch( } } +function isEventStreamResponse(response: Response): boolean { + return (response.headers.get('Content-Type') ?? '') + .toLowerCase() + .includes('text/event-stream') +} + async function readAnthropicBody( input: RequestInfo | URL, init?: RequestInit,