fix: stream ChatGPT OAuth title requests

ChatGPT Codex requires streamed Responses API requests, including hidden non-chat paths such as title generation. Keep the OAuth adapter streaming upstream while preserving JSON responses for non-streaming Anthropic callers, and route ChatGPT Official titles through the OAuth Codex endpoint.

Constraint: ChatGPT Codex rejects non-streaming requests with HTTP 400
Rejected: Disable AI title generation for ChatGPT OAuth | would leave first-session metadata degraded
Confidence: high
Scope-risk: narrow
Tested: bun test src/services/openaiAuth/fetch.test.ts
Tested: bun test src/server/__tests__/title-service.test.ts
Tested: bun run check:server
Tested: git diff --check
Not-tested: Live ChatGPT OAuth account round trip after remote build
This commit is contained in:
程序员阿江(Relakkes) 2026-05-21 15:31:56 +08:00
parent 5798756e3f
commit a159ee3511
5 changed files with 334 additions and 2 deletions

View File

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

View File

@ -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<string, string>
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<Uint8Array>,
model: string,
): Promise<AnthropicResponse> {
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<string, unknown>
try {
data = JSON.parse(jsonStr) as Record<string, unknown>
} 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<string, unknown>,
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, unknown>): 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)
)
}

View File

@ -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<string | null> {
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

View File

@ -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<string, unknown>
}> = []
const fetchOverride: typeof fetch = async (input, init) => {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
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' }],
})
})
})

View File

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