cc-haha/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts
程序员阿江(Relakkes) a159ee3511 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
2026-05-21 15:31:56 +08:00

164 lines
4.4 KiB
TypeScript

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