mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
5798756e3f
commit
a159ee3511
@ -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')
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user