feat: /login支持codex订阅登录

This commit is contained in:
Bill
2026-05-08 20:35:34 +08:00
parent 73e54d4bbc
commit c7cb3d8f93
17 changed files with 1318 additions and 39 deletions

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test'
import { buildResponsesRequest } from '../responsesAdapter.js'
describe('buildResponsesRequest', () => {
test('includes reasoning effort for ChatGPT Responses requests', () => {
const request = buildResponsesRequest({
model: 'gpt-5.5',
messages: [{ role: 'user', content: 'hello' }],
tools: [],
toolChoice: undefined,
reasoningEffort: 'xhigh',
})
expect(request.reasoning).toEqual({ effort: 'xhigh' })
})
test('does not include unsupported max_output_tokens parameter', () => {
const request = buildResponsesRequest({
model: 'gpt-5.5',
messages: [{ role: 'user', content: 'hello' }],
tools: [],
toolChoice: undefined,
}) as Record<string, unknown>
expect('max_output_tokens' in request).toBe(false)
})
})

View File

@@ -0,0 +1,361 @@
import { chmod, mkdir, readFile, unlink, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { logForDebugging } from 'src/utils/debug.js'
const ISSUER = 'https://auth.openai.com'
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const AUTH_FILE = 'openai-chatgpt-auth.json'
const REFRESH_SKEW_MS = 5 * 60 * 1000
export type ChatGPTDeviceCode = {
verificationUrl: string
userCode: string
deviceAuthId: string
intervalSeconds: number
}
export type ChatGPTAuthTokens = {
idToken: string
accessToken: string
refreshToken: string
accountId?: string
lastRefresh?: string
}
export type ChatGPTAuth = {
accessToken: string
accountId?: string
}
type StoredAuthFile = {
auth_mode?: string
tokens?: {
id_token?: string
access_token?: string
refresh_token?: string
account_id?: string
}
last_refresh?: string
}
function authFilePath(): string {
return join(getClaudeConfigHomeDirLocal(), AUTH_FILE)
}
function getClaudeConfigHomeDirLocal(): string {
return (
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
).normalize('NFC')
}
function codexAuthFilePath(): string {
return join(
process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'),
'auth.json',
)
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function parseJSONRecord(text: string): Record<string, unknown> | null {
try {
const value = JSON.parse(text) as unknown
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: null
} catch {
return null
}
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const [, payload] = token.split('.')
if (!payload) return null
try {
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(
normalized.length + ((4 - (normalized.length % 4)) % 4),
'=',
)
const json = Buffer.from(padded, 'base64').toString('utf8')
return parseJSONRecord(json)
} catch {
return null
}
}
function getOpenAIAuthClaims(token: string): Record<string, unknown> {
const payload = decodeJwtPayload(token)
const nested = payload?.['https://api.openai.com/auth']
if (nested && typeof nested === 'object') {
return nested as Record<string, unknown>
}
return payload ?? {}
}
function getTokenExpiryMs(token: string): number | null {
const payload = decodeJwtPayload(token)
const exp = payload?.exp
return typeof exp === 'number' ? exp * 1000 : null
}
function extractAccountId(tokens: {
idToken?: string
accessToken?: string
accountId?: string
}): string | undefined {
if (tokens.accountId) return tokens.accountId
for (const token of [tokens.idToken, tokens.accessToken]) {
if (!token) continue
const claims = getOpenAIAuthClaims(token)
const accountId =
asString(claims.chatgpt_account_id) ??
asString(claims.chatgpt_account_user_id) ??
asString(claims.account_id)
if (accountId) return accountId
}
return undefined
}
async function readStoredAuth(path: string): Promise<ChatGPTAuthTokens | null> {
try {
const raw = await readFile(path, 'utf8')
const parsed = JSON.parse(raw) as StoredAuthFile
const tokens = parsed.tokens
const idToken = tokens?.id_token
const accessToken = tokens?.access_token
const refreshToken = tokens?.refresh_token
if (!idToken || !accessToken || !refreshToken) return null
return {
idToken,
accessToken,
refreshToken,
accountId: extractAccountId({
idToken,
accessToken,
accountId: tokens.account_id,
}),
lastRefresh: parsed.last_refresh,
}
} catch {
return null
}
}
async function saveStoredAuth(tokens: ChatGPTAuthTokens): Promise<void> {
const path = authFilePath()
await mkdir(getClaudeConfigHomeDirLocal(), { recursive: true })
const body: StoredAuthFile = {
auth_mode: 'chatgpt',
tokens: {
id_token: tokens.idToken,
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
account_id: extractAccountId(tokens),
},
last_refresh: new Date().toISOString(),
}
await writeFile(path, `${JSON.stringify(body, null, 2)}\n`, {
mode: 0o600,
})
await chmod(path, 0o600).catch(() => undefined)
}
async function postJSON<T>(
url: string,
body: Record<string, string>,
): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
throw new Error(`ChatGPT auth request failed (${res.status})`)
}
return (await res.json()) as T
}
async function postForm<T>(url: string, body: URLSearchParams): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(
`ChatGPT token request failed (${res.status})${text ? `: ${text}` : ''}`,
)
}
return (await res.json()) as T
}
export async function requestChatGPTDeviceCode(): Promise<ChatGPTDeviceCode> {
type UserCodeResponse = {
device_auth_id: string
user_code?: string
usercode?: string
interval?: string | number
}
const data = await postJSON<UserCodeResponse>(
`${ISSUER}/api/accounts/deviceauth/usercode`,
{ client_id: CLIENT_ID },
)
const userCode = data.user_code ?? data.usercode
if (!data.device_auth_id || !userCode) {
throw new Error('ChatGPT auth response did not include a device code')
}
const interval =
typeof data.interval === 'number'
? data.interval
: Number.parseInt(data.interval ?? '5', 10)
return {
verificationUrl: `${ISSUER}/codex/device`,
userCode,
deviceAuthId: data.device_auth_id,
intervalSeconds: Number.isFinite(interval) && interval > 0 ? interval : 5,
}
}
async function pollForAuthorizationCode(
deviceCode: ChatGPTDeviceCode,
signal?: AbortSignal,
): Promise<{ authorizationCode: string; codeVerifier: string }> {
type TokenPollResponse = {
authorization_code: string
code_verifier: string
}
const started = Date.now()
while (Date.now() - started < 15 * 60 * 1000) {
if (signal?.aborted) throw new Error('ChatGPT login cancelled')
const res = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_auth_id: deviceCode.deviceAuthId,
user_code: deviceCode.userCode,
}),
signal,
})
if (res.ok) {
const data = (await res.json()) as TokenPollResponse
return {
authorizationCode: data.authorization_code,
codeVerifier: data.code_verifier,
}
}
if (res.status !== 403 && res.status !== 404) {
throw new Error(`ChatGPT device auth failed (${res.status})`)
}
await new Promise(resolve =>
setTimeout(resolve, deviceCode.intervalSeconds * 1000),
)
}
throw new Error('ChatGPT device auth timed out after 15 minutes')
}
async function exchangeAuthorizationCode(params: {
authorizationCode: string
codeVerifier: string
}): Promise<ChatGPTAuthTokens> {
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.authorizationCode,
redirect_uri: `${ISSUER}/deviceauth/callback`,
client_id: CLIENT_ID,
code_verifier: params.codeVerifier,
})
const data = await postForm<TokenResponse>(`${ISSUER}/oauth/token`, body)
return {
idToken: data.id_token,
accessToken: data.access_token,
refreshToken: data.refresh_token,
accountId: extractAccountId({
idToken: data.id_token,
accessToken: data.access_token,
}),
}
}
async function refreshTokens(
tokens: ChatGPTAuthTokens,
): Promise<ChatGPTAuthTokens> {
type TokenResponse = {
id_token: string
access_token: string
refresh_token?: string
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: CLIENT_ID,
scope:
'openid profile email offline_access api.connectors.read api.connectors.invoke',
})
const data = await postForm<TokenResponse>(`${ISSUER}/oauth/token`, body)
return {
idToken: data.id_token,
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken,
accountId: extractAccountId({
idToken: data.id_token,
accessToken: data.access_token,
accountId: tokens.accountId,
}),
}
}
export async function completeChatGPTDeviceLogin(
deviceCode: ChatGPTDeviceCode,
signal?: AbortSignal,
): Promise<ChatGPTAuthTokens> {
const code = await pollForAuthorizationCode(deviceCode, signal)
const tokens = await exchangeAuthorizationCode(code)
await saveStoredAuth(tokens)
return tokens
}
export function isChatGPTAuthEnabled(): boolean {
return process.env.OPENAI_AUTH_MODE === 'chatgpt'
}
export async function removeChatGPTAuth(): Promise<void> {
await unlink(authFilePath()).catch(error => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
})
}
export async function getValidChatGPTAuth(): Promise<ChatGPTAuth> {
let tokens = await readStoredAuth(authFilePath())
if (!tokens) {
tokens = await readStoredAuth(codexAuthFilePath())
if (tokens) {
logForDebugging('[OpenAI] Using ChatGPT auth from Codex auth.json')
}
}
if (!tokens) {
throw new Error(
'ChatGPT account is not logged in. Run /login and select ChatGPT account with subscription.',
)
}
const expiresAt = getTokenExpiryMs(tokens.accessToken)
if (expiresAt !== null && expiresAt <= Date.now() + REFRESH_SKEW_MS) {
tokens = await refreshTokens(tokens)
await saveStoredAuth(tokens)
}
return {
accessToken: tokens.accessToken,
accountId: tokens.accountId ?? extractAccountId(tokens),
}
}

View File

@@ -17,6 +17,13 @@ import {
anthropicToolsToOpenAI,
anthropicToolChoiceToOpenAI,
} from '@ant/model-provider'
import { isChatGPTAuthEnabled } from './chatgptAuth.js'
import {
adaptResponsesStreamToAnthropic,
buildResponsesRequest,
createChatGPTResponsesStream,
type ResponsesReasoningEffort,
} from './responsesAdapter.js'
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
import { toolToAPISchema } from '../../../utils/api.js'
import {
@@ -62,6 +69,29 @@ import {
TOOL_SEARCH_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
function convertToResponsesReasoningEffort(
effortValue: unknown,
): ResponsesReasoningEffort | undefined {
if (effortValue === 'low') return 'low'
if (effortValue === 'medium') return 'medium'
if (effortValue === 'high') return 'high'
if (effortValue === 'xhigh' || effortValue === 'max') return 'xhigh'
if (typeof effortValue === 'number') return 'high'
return undefined
}
function getChatGPTResponsesReasoningEffort(
effortValue: unknown,
): ResponsesReasoningEffort | undefined {
const envOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL?.toLowerCase()
if (envOverride === 'auto' || envOverride === 'unset') return undefined
return (
convertToResponsesReasoningEffort(envOverride) ??
convertToResponsesReasoningEffort(effortValue) ??
'medium'
)
}
/**
* Mirrors the Anthropic request path's deferred-tool announcement for OpenAI.
*
@@ -269,6 +299,9 @@ export async function* queryModelOpenAI(
)
const openaiTools = anthropicToolsToOpenAI(standardTools)
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
const reasoningEffort = getChatGPTResponsesReasoningEffort(
options.effortValue,
)
// 9. Log tool filtering details
if (useToolSearch) {
@@ -307,32 +340,50 @@ export async function* queryModelOpenAI(
options.maxOutputTokensOverride,
)
// 11. Get client
const client = getOpenAIClient({
maxRetries: 0,
fetchOverride: options.fetchOverride as unknown as typeof fetch,
source: options.querySource,
})
logForDebugging(
`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}`,
)
// 12. Call OpenAI API with streaming
const requestBody = buildOpenAIRequestBody({
model: openaiModel,
messages: openaiMessages,
tools: openaiTools,
toolChoice: openaiToolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.chat.completions.create(requestBody, { signal })
// 11. Call OpenAI API with streaming. ChatGPT subscription auth uses the
// Codex Responses backend; API-key/OpenAI-compatible auth keeps the
// existing Chat Completions adapter.
const adaptedStream = isChatGPTAuthEnabled()
? adaptResponsesStreamToAnthropic(
await createChatGPTResponsesStream({
request: buildResponsesRequest({
model: openaiModel,
messages: openaiMessages,
tools: openaiTools,
toolChoice: openaiToolChoice,
reasoningEffort,
}),
signal,
fetchOverride: options.fetchOverride as unknown as typeof fetch,
}),
openaiModel,
)
: adaptOpenAIStreamToAnthropic(
await getOpenAIClient({
maxRetries: 0,
fetchOverride: options.fetchOverride as unknown as typeof fetch,
source: options.querySource,
}).chat.completions.create(
buildOpenAIRequestBody({
model: openaiModel,
messages: openaiMessages,
tools: openaiTools,
toolChoice: openaiToolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
}),
{ signal },
),
openaiModel,
)
// 12. Convert OpenAI stream to Anthropic events, then process into
// AssistantMessage + StreamEvent (matching the Anthropic path behavior)
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel)
// Accumulate content blocks and usage, same as the Anthropic path in claude.ts
const contentBlocks: Record<number, any> = {}

View File

@@ -0,0 +1,480 @@
import { randomUUID } from 'crypto'
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { getValidChatGPTAuth } from './chatgptAuth.js'
type ResponsesInputItem = Record<string, unknown>
type ResponsesTool = Record<string, unknown>
export type ResponsesReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'
type ResponsesRequest = {
model: string
stream: true
store: false
input: ResponsesInputItem[]
instructions?: string
tools?: ResponsesTool[]
tool_choice?: unknown
reasoning?: { effort: ResponsesReasoningEffort }
parallel_tool_calls?: boolean
}
type AnthropicUsage = {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
function textFromContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.map(part => {
if (!part || typeof part !== 'object') return ''
const record = part as Record<string, unknown>
if (typeof record.text === 'string') return record.text
return ''
})
.filter(Boolean)
.join('\n')
}
function convertUserContent(content: unknown): unknown {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return textFromContent(content)
const result: Array<Record<string, unknown>> = []
for (const part of content) {
if (!part || typeof part !== 'object') continue
const record = part as Record<string, unknown>
if (record.type === 'text' && typeof record.text === 'string') {
result.push({ type: 'input_text', text: record.text })
} else if (record.type === 'image_url') {
const imageUrl = record.image_url as Record<string, unknown> | undefined
if (typeof imageUrl?.url === 'string') {
result.push({ type: 'input_image', image_url: imageUrl.url })
}
}
}
return result.length > 0 ? result : textFromContent(content)
}
function convertMessagesToResponsesInput(messages: unknown[]): {
input: ResponsesInputItem[]
instructions?: string
} {
const input: ResponsesInputItem[] = []
const instructions: string[] = []
for (const message of messages) {
if (!message || typeof message !== 'object') continue
const record = message as Record<string, unknown>
const role = record.role
if (role === 'system' || role === 'developer') {
const text = textFromContent(record.content)
if (text) instructions.push(text)
continue
}
if (role === 'tool') {
const callId = record.tool_call_id
if (typeof callId === 'string') {
input.push({
type: 'function_call_output',
call_id: callId,
output: textFromContent(record.content),
})
}
continue
}
if (role === 'assistant') {
const text = textFromContent(record.content)
if (text) {
input.push({ role: 'assistant', content: text })
}
const toolCalls = record.tool_calls
if (Array.isArray(toolCalls)) {
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== 'object') continue
const tc = toolCall as Record<string, unknown>
const fn = tc.function as Record<string, unknown> | undefined
const id = typeof tc.id === 'string' ? tc.id : undefined
const name = typeof fn?.name === 'string' ? fn.name : undefined
if (!id || !name) continue
input.push({
type: 'function_call',
call_id: id,
name,
arguments: typeof fn?.arguments === 'string' ? fn.arguments : '{}',
})
}
}
continue
}
if (role === 'user') {
input.push({
role: 'user',
content: convertUserContent(record.content),
})
}
}
return {
input,
instructions:
instructions.length > 0 ? instructions.join('\n\n') : undefined,
}
}
function convertToolsToResponses(tools: unknown[]): ResponsesTool[] {
const result: ResponsesTool[] = []
for (const tool of tools) {
if (!tool || typeof tool !== 'object') continue
const record = tool as Record<string, unknown>
const fn = record.function as Record<string, unknown> | undefined
const name = typeof fn?.name === 'string' ? fn.name : undefined
if (!name) continue
result.push({
type: 'function',
name,
description: typeof fn?.description === 'string' ? fn.description : '',
parameters:
fn?.parameters && typeof fn.parameters === 'object'
? fn.parameters
: { type: 'object', properties: {} },
strict: false,
})
}
return result
}
function convertToolChoiceToResponses(toolChoice: unknown): unknown {
if (toolChoice === 'required') return 'required'
if (toolChoice === 'auto') return 'auto'
if (!toolChoice || typeof toolChoice !== 'object') return toolChoice
const record = toolChoice as Record<string, unknown>
const fn = record.function as Record<string, unknown> | undefined
if (record.type === 'function' && typeof fn?.name === 'string') {
return { type: 'function', name: fn.name }
}
return toolChoice
}
export function buildResponsesRequest(params: {
model: string
messages: unknown[]
tools: unknown[]
toolChoice: unknown
reasoningEffort?: ResponsesReasoningEffort
}): ResponsesRequest {
const { input, instructions } = convertMessagesToResponsesInput(
params.messages,
)
const tools = convertToolsToResponses(params.tools)
return {
model: params.model,
stream: true,
store: false,
input,
...(instructions ? { instructions } : {}),
...(tools.length > 0 ? { tools } : {}),
...(params.toolChoice
? { tool_choice: convertToolChoiceToResponses(params.toolChoice) }
: {}),
...(params.reasoningEffort
? { reasoning: { effort: params.reasoningEffort } }
: {}),
parallel_tool_calls: true,
}
}
async function* parseSSE(
response: Response,
): AsyncGenerator<Record<string, unknown>, void> {
if (!response.body) throw new Error('ChatGPT response did not include a body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let splitAt = buffer.indexOf('\n\n')
while (splitAt >= 0) {
const frame = buffer.slice(0, splitAt)
buffer = buffer.slice(splitAt + 2)
const data = frame
.split(/\r?\n/)
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trimStart())
.join('\n')
if (data && data !== '[DONE]') {
const parsed = JSON.parse(data) as unknown
if (parsed && typeof parsed === 'object') {
yield parsed as Record<string, unknown>
}
}
splitAt = buffer.indexOf('\n\n')
}
}
}
function extractUsage(
response: Record<string, unknown> | undefined,
): AnthropicUsage {
const usage = response?.usage as Record<string, unknown> | undefined
const inputDetails = usage?.input_tokens_details as
| Record<string, unknown>
| undefined
return {
input_tokens:
typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0,
output_tokens:
typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens:
typeof inputDetails?.cached_tokens === 'number'
? inputDetails.cached_tokens
: 0,
}
}
function mapStopReason(response: Record<string, unknown> | undefined): string {
if (response?.status === 'incomplete') return 'max_tokens'
return 'end_turn'
}
export async function* adaptResponsesStreamToAnthropic(
stream: AsyncIterable<Record<string, unknown>>,
model: string,
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
const toolBlocks = new Map<
number,
{ contentIndex: number; open: boolean; name: string; id: string }
>()
let started = false
let currentContentIndex = -1
let textBlockOpen = false
let thinkingBlockOpen = false
const ensureStarted = async function* () {
if (started) return
started = true
yield {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
} as unknown as BetaRawMessageStreamEvent
}
for await (const event of stream) {
for await (const startedEvent of ensureStarted()) yield startedEvent
const type = event.type
if (type === 'response.output_text.delta') {
if (!textBlockOpen) {
if (thinkingBlockOpen) {
yield {
type: 'content_block_stop',
index: currentContentIndex,
} as BetaRawMessageStreamEvent
thinkingBlockOpen = false
}
currentContentIndex++
textBlockOpen = true
yield {
type: 'content_block_start',
index: currentContentIndex,
content_block: { type: 'text', text: '' },
} as BetaRawMessageStreamEvent
}
yield {
type: 'content_block_delta',
index: currentContentIndex,
delta: { type: 'text_delta', text: String(event.delta ?? '') },
} as BetaRawMessageStreamEvent
continue
}
if (type === 'response.reasoning_text.delta') {
if (!thinkingBlockOpen) {
if (textBlockOpen) {
yield {
type: 'content_block_stop',
index: currentContentIndex,
} as BetaRawMessageStreamEvent
textBlockOpen = false
}
currentContentIndex++
thinkingBlockOpen = true
yield {
type: 'content_block_start',
index: currentContentIndex,
content_block: { type: 'thinking', thinking: '', signature: '' },
} as BetaRawMessageStreamEvent
}
yield {
type: 'content_block_delta',
index: currentContentIndex,
delta: { type: 'thinking_delta', thinking: String(event.delta ?? '') },
} as BetaRawMessageStreamEvent
continue
}
if (type === 'response.output_item.added') {
const item = event.item as Record<string, unknown> | undefined
const outputIndex =
typeof event.output_index === 'number' ? event.output_index : -1
if (item?.type === 'function_call' && outputIndex >= 0) {
if (textBlockOpen) {
yield {
type: 'content_block_stop',
index: currentContentIndex,
} as BetaRawMessageStreamEvent
textBlockOpen = false
}
if (thinkingBlockOpen) {
yield {
type: 'content_block_stop',
index: currentContentIndex,
} as BetaRawMessageStreamEvent
thinkingBlockOpen = false
}
currentContentIndex++
const id = String(item.call_id ?? item.id ?? `call_${outputIndex}`)
const name = String(item.name ?? '')
toolBlocks.set(outputIndex, {
contentIndex: currentContentIndex,
open: true,
name,
id,
})
yield {
type: 'content_block_start',
index: currentContentIndex,
content_block: { type: 'tool_use', id, name, input: {} },
} as BetaRawMessageStreamEvent
}
continue
}
if (type === 'response.function_call_arguments.delta') {
const outputIndex =
typeof event.output_index === 'number' ? event.output_index : -1
const block = toolBlocks.get(outputIndex)
if (block) {
yield {
type: 'content_block_delta',
index: block.contentIndex,
delta: {
type: 'input_json_delta',
partial_json: String(event.delta ?? ''),
},
} as BetaRawMessageStreamEvent
}
continue
}
if (type === 'response.output_item.done') {
const outputIndex =
typeof event.output_index === 'number' ? event.output_index : -1
const block = toolBlocks.get(outputIndex)
if (block?.open) {
yield {
type: 'content_block_stop',
index: block.contentIndex,
} as BetaRawMessageStreamEvent
block.open = false
}
continue
}
if (type === 'response.error') {
const error = event.error as Record<string, unknown> | undefined
throw new Error(String(error?.message ?? 'ChatGPT Responses API error'))
}
if (type === 'response.failed') {
const response = event.response as Record<string, unknown> | undefined
const error = response?.error as Record<string, unknown> | undefined
throw new Error(String(error?.message ?? 'ChatGPT Responses API failed'))
}
if (type === 'response.completed' || type === 'response.incomplete') {
if (textBlockOpen) {
yield {
type: 'content_block_stop',
index: currentContentIndex,
} as BetaRawMessageStreamEvent
textBlockOpen = false
}
if (thinkingBlockOpen) {
yield {
type: 'content_block_stop',
index: currentContentIndex,
} as BetaRawMessageStreamEvent
thinkingBlockOpen = false
}
const response = event.response as Record<string, unknown> | undefined
yield {
type: 'message_delta',
delta: { stop_reason: mapStopReason(response), stop_sequence: null },
usage: extractUsage(response),
} as unknown as BetaRawMessageStreamEvent
yield { type: 'message_stop' } as BetaRawMessageStreamEvent
}
}
}
export async function createChatGPTResponsesStream(params: {
request: ResponsesRequest
signal: AbortSignal
fetchOverride?: typeof fetch
}): Promise<AsyncIterable<Record<string, unknown>>> {
const auth = await getValidChatGPTAuth()
const fetchFn = params.fetchOverride ?? (globalThis.fetch as typeof fetch)
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.accessToken}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'OpenAI-Beta': 'responses=experimental',
Origin: 'https://chatgpt.com',
Referer: 'https://chatgpt.com/',
originator: 'claude-code-best',
}
if (auth.accountId) {
headers['ChatGPT-Account-Id'] = auth.accountId
}
const response = await fetchFn(
'https://chatgpt.com/backend-api/codex/responses',
{
method: 'POST',
headers,
body: JSON.stringify(params.request),
signal: params.signal,
},
)
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(
`ChatGPT Responses API request failed (${response.status})${text ? `: ${text.slice(0, 500)}` : ''}`,
)
}
return parseSSE(response)
}