diff --git a/src/utils/__tests__/sideQuery.chatgptAuth.test.ts b/src/utils/__tests__/sideQuery.chatgptAuth.test.ts new file mode 100644 index 0000000000..ee6da27760 --- /dev/null +++ b/src/utils/__tests__/sideQuery.chatgptAuth.test.ts @@ -0,0 +1,315 @@ +/** + * Regression: Auto mode classifier sideQuery under ChatGPT OAuth. + * + * Bug: sideQueryViaOpenAICompatible always used getOpenAIClient() which only + * reads OPENAI_API_KEY. With OPENAI_AUTH_MODE=chatgpt and no API key, the + * classifier got 401 and fail-closed to human confirmation. + * + * Fix: when isChatGPTAuthEnabled(), route OpenAI side queries through the + * ChatGPT Responses + OAuth path used by the main loop. + * + * Avoid mocking getAPIProvider (process-global pollution). Select OpenAI via + * CLAUDE_CODE_USE_OPENAI env. Mock only client + ChatGPT token surface. + */ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { logMock } from '../../../tests/mocks/log' +import { debugMock } from '../../../tests/mocks/debug' + +mock.module('src/utils/log.ts', logMock) +mock.module('src/utils/debug.ts', debugMock) + +mock.module('src/services/analytics/index.js', () => ({ + logEvent: () => {}, + logEventAsync: async () => {}, + stripProtoFields: (v: V) => v, + attachAnalyticsSink: () => {}, + _resetForTesting: () => {}, +})) + +let getOpenAIClientCallCount = 0 +let chatCompletionsCreateCount = 0 +let lastChatCompletionsArgs: Record | null = null + +mock.module('src/services/api/openai/client.js', () => ({ + getOpenAIClient: () => { + getOpenAIClientCallCount++ + return { + chat: { + completions: { + create: async (args: Record) => { + chatCompletionsCreateCount++ + lastChatCompletionsArgs = args + return { + id: 'chatcmpl_test', + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + type: 'function', + id: 'call_api_key', + function: { + name: 'classify_result', + arguments: JSON.stringify({ shouldBlock: false }), + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 2 }, + } + }, + }, + }, + } + }, + clearOpenAIClientCache: () => {}, +})) + +// Keep isChatGPTAuthEnabled env-driven (same as production) so other suite +// files are not forced into ChatGPT mode. +mock.module('src/services/api/openai/chatgptAuth.js', () => ({ + isChatGPTAuthEnabled: () => process.env.OPENAI_AUTH_MODE === 'chatgpt', + getValidChatGPTAuth: async () => ({ + accessToken: 'test-access-token-not-real', + accountId: 'acct_test', + }), + removeChatGPTAuth: async () => {}, + requestChatGPTDeviceCode: async () => { + throw new Error('not used') + }, + completeChatGPTDeviceLogin: async () => { + throw new Error('not used') + }, +})) + +type CapturedFetch = { + url: string + headers: Record + body: Record +} +let capturedFetch: CapturedFetch | null = null +let originalFetch: typeof globalThis.fetch + +const ENV_KEYS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_GROK', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'OPENAI_AUTH_MODE', + 'OPENAI_API_KEY', +] as const + +const savedEnv: Record = {} + +function buildFunctionCallSse(toolName: string, argsJson: string): string { + return [ + `data: ${JSON.stringify({ + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + call_id: 'call_chatgpt_1', + name: toolName, + }, + })}`, + '', + `data: ${JSON.stringify({ + type: 'response.function_call_arguments.delta', + output_index: 0, + delta: argsJson, + })}`, + '', + `data: ${JSON.stringify({ + type: 'response.output_item.done', + output_index: 0, + })}`, + '', + `data: ${JSON.stringify({ + type: 'response.completed', + response: { + status: 'completed', + usage: { input_tokens: 11, output_tokens: 7 }, + }, + })}`, + '', + '', + ].join('\n') +} + +function enableOpenAIProvider(): void { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + delete process.env.CLAUDE_CODE_USE_GROK + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY +} + +beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + getOpenAIClientCallCount = 0 + chatCompletionsCreateCount = 0 + lastChatCompletionsArgs = null + capturedFetch = null + enableOpenAIProvider() + originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const headers: Record = {} + const rawHeaders = init?.headers + if ( + rawHeaders && + typeof rawHeaders === 'object' && + !Array.isArray(rawHeaders) + ) { + for (const [k, v] of Object.entries( + rawHeaders as Record, + )) { + headers[k] = v + } + } + const body = + typeof init?.body === 'string' + ? (JSON.parse(init.body) as Record) + : {} + capturedFetch = { url, headers, body } + return new Response( + buildFunctionCallSse( + 'classify_result', + '{"shouldBlock":false,"reason":"ok"}', + ), + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ) + }) as unknown as typeof fetch +}) + +afterEach(() => { + globalThis.fetch = originalFetch + for (const key of ENV_KEYS) { + const value = savedEnv[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +const classifierTool = { + name: 'classify_result', + description: 'Classify the action', + input_schema: { + type: 'object', + properties: { + shouldBlock: { type: 'boolean' }, + reason: { type: 'string' }, + }, + }, +} + +describe('sideQuery OpenAI ChatGPT OAuth path', () => { + test('uses ChatGPT Responses + OAuth, not empty-key Chat Completions', async () => { + process.env.OPENAI_AUTH_MODE = 'chatgpt' + delete process.env.OPENAI_API_KEY + const { sideQuery } = await import('../sideQuery.js') + + const result = await sideQuery({ + querySource: 'auto_mode', + model: 'gpt-5.5', + system: 'You are a classifier.', + messages: [{ role: 'user', content: 'classify this action' }], + tools: [classifierTool as never], + tool_choice: { type: 'tool', name: 'classify_result' }, + max_tokens: 256, + }) + + expect(getOpenAIClientCallCount).toBe(0) + expect(chatCompletionsCreateCount).toBe(0) + expect(capturedFetch).not.toBeNull() + expect(capturedFetch!.url).toContain( + 'chatgpt.com/backend-api/codex/responses', + ) + // OAuth header present; do not assert the real token value. + expect(capturedFetch!.headers.Authorization).toMatch(/^Bearer \S+/) + expect(capturedFetch!.headers['ChatGPT-Account-Id']).toBe('acct_test') + expect(capturedFetch!.body.stream).toBe(true) + expect(capturedFetch!.body.model).toBe('gpt-5.5') + expect(Array.isArray(capturedFetch!.body.tools)).toBe(true) + expect(capturedFetch!.body.tool_choice).toEqual({ + type: 'function', + name: 'classify_result', + }) + + const toolUse = result.content.find( + ( + b, + ): b is { + type: 'tool_use' + id: string + name: string + input: unknown + } => b.type === 'tool_use', + ) + expect(toolUse).toBeDefined() + expect(toolUse!.name).toBe('classify_result') + expect(toolUse!.input).toEqual({ shouldBlock: false, reason: 'ok' }) + expect(result.stop_reason).toBe('tool_use') + expect(result.usage.input_tokens).toBe(11) + expect(result.usage.output_tokens).toBe(7) + }) + + test('API key mode still uses Chat Completions client', async () => { + delete process.env.OPENAI_AUTH_MODE + process.env.OPENAI_API_KEY = 'sk-test-not-real' + const { sideQuery } = await import('../sideQuery.js') + + const result = await sideQuery({ + querySource: 'auto_mode', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + tools: [classifierTool as never], + tool_choice: { type: 'tool', name: 'classify_result' }, + }) + + expect(getOpenAIClientCallCount).toBe(1) + expect(chatCompletionsCreateCount).toBe(1) + expect(capturedFetch).toBeNull() + expect(lastChatCompletionsArgs?.model).toBe('gpt-4o') + + const toolUse = result.content.find(b => b.type === 'tool_use') as + | { type: 'tool_use'; name: string; input: unknown } + | undefined + expect(toolUse?.name).toBe('classify_result') + expect(toolUse?.input).toEqual({ shouldBlock: false }) + }) + + test('ChatGPT OAuth request failure propagates for fail-closed classifiers', async () => { + process.env.OPENAI_AUTH_MODE = 'chatgpt' + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: { message: 'unauthorized' } }), { + status: 401, + statusText: 'Unauthorized', + })) as unknown as typeof fetch + + const { sideQuery } = await import('../sideQuery.js') + + await expect( + sideQuery({ + querySource: 'auto_mode', + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'classify' }], + tools: [classifierTool as never], + tool_choice: { type: 'tool', name: 'classify_result' }, + }), + ).rejects.toThrow(/ChatGPT Responses API request failed \(401\)/) + expect(getOpenAIClientCallCount).toBe(0) + }) +}) diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 3d6d645dcf..33e012eb58 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -34,6 +34,12 @@ import { getAPIProvider } from './model/providers.js' import { normalizeModelStringForAPI } from './model/model.js' import { getOpenAIClient } from '../services/api/openai/client.js' import { getGrokClient } from '../services/api/grok/client.js' +import { isChatGPTAuthEnabled } from '../services/api/openai/chatgptAuth.js' +import { + adaptResponsesStreamToAnthropic, + buildResponsesRequest, + createChatGPTResponsesStream, +} from '../services/api/openai/responsesAdapter.js' import { anthropicMessagesToOpenAI, resolveOpenAIModel, @@ -45,6 +51,7 @@ import { anthropicToolChoiceToGemini, } from '@ant/model-provider' import type { SystemPrompt } from './systemPromptType.js' +import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' type MessageParam = Anthropic.MessageParam type TextBlockParam = Anthropic.TextBlockParam @@ -369,6 +376,238 @@ export async function sideQuery(opts: SideQueryOptions): Promise { return response } +/** + * Collect Anthropic stream events from the ChatGPT Responses adapter into a + * single BetaMessage for side-query callers (classifiers, explainers, etc.). + */ +async function collectAnthropicStreamToBetaMessage( + stream: AsyncIterable, + fallbackModel: string, +): Promise { + let messageId = `msg_side_${Date.now()}` + let model = fallbackModel + let stopReason: BetaMessage['stop_reason'] = 'end_turn' + let usage = { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + } + const contentBlocks: Record> = {} + + for await (const event of stream) { + switch (event.type) { + case 'message_start': { + messageId = event.message.id + model = event.message.model || model + if (event.message.usage) { + usage = { + input_tokens: event.message.usage.input_tokens ?? 0, + output_tokens: event.message.usage.output_tokens ?? 0, + cache_creation_input_tokens: + event.message.usage.cache_creation_input_tokens ?? 0, + cache_read_input_tokens: + event.message.usage.cache_read_input_tokens ?? 0, + } + } + break + } + case 'content_block_start': { + const cb = event.content_block as unknown as Record + if (cb.type === 'tool_use') { + contentBlocks[event.index] = { ...cb, input: '' } + } else if (cb.type === 'text') { + contentBlocks[event.index] = { ...cb, text: '' } + } else if (cb.type === 'thinking') { + contentBlocks[event.index] = { + ...cb, + thinking: '', + signature: '', + } + } else { + contentBlocks[event.index] = { ...cb } + } + break + } + case 'content_block_delta': { + const block = contentBlocks[event.index] + if (!block) break + const delta = event.delta as { + type: string + text?: string + partial_json?: string + thinking?: string + signature?: string + } + if (delta.type === 'text_delta') { + block.text = String(block.text ?? '') + String(delta.text ?? '') + } else if (delta.type === 'input_json_delta') { + block.input = + String(block.input ?? '') + String(delta.partial_json ?? '') + } else if (delta.type === 'thinking_delta') { + block.thinking = + String(block.thinking ?? '') + String(delta.thinking ?? '') + } else if (delta.type === 'signature_delta') { + block.signature = delta.signature + } + break + } + case 'message_delta': { + const delta = event.delta as { + stop_reason?: BetaMessage['stop_reason'] + } + if (delta.stop_reason != null) { + stopReason = delta.stop_reason + } + const deltaUsage = ( + event as { + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } + } + ).usage + if (deltaUsage) { + if (typeof deltaUsage.input_tokens === 'number') { + usage.input_tokens = deltaUsage.input_tokens + } + if (typeof deltaUsage.output_tokens === 'number') { + usage.output_tokens = deltaUsage.output_tokens + } + if ( + typeof deltaUsage.cache_creation_input_tokens === 'number' && + deltaUsage.cache_creation_input_tokens > 0 + ) { + usage.cache_creation_input_tokens = + deltaUsage.cache_creation_input_tokens + } + if ( + typeof deltaUsage.cache_read_input_tokens === 'number' && + deltaUsage.cache_read_input_tokens > 0 + ) { + usage.cache_read_input_tokens = deltaUsage.cache_read_input_tokens + } + } + break + } + default: + break + } + } + + const content = Object.keys(contentBlocks) + .map(Number) + .sort((a, b) => a - b) + .map(index => { + const block = contentBlocks[index]! + if (block.type === 'tool_use') { + const rawInput = block.input + let parsed: unknown = {} + if (typeof rawInput === 'string' && rawInput.length > 0) { + try { + parsed = JSON.parse(rawInput) + } catch { + parsed = {} + } + } else if (rawInput && typeof rawInput === 'object') { + parsed = rawInput + } + return { + type: 'tool_use' as const, + id: String(block.id ?? `toolu_${index}`), + name: String(block.name ?? ''), + input: parsed, + } + } + if (block.type === 'thinking') { + return { + type: 'thinking' as const, + thinking: String(block.thinking ?? ''), + signature: String(block.signature ?? ''), + } + } + return { + type: 'text' as const, + text: String(block.text ?? ''), + } + }) + + // Forced tool_choice classifiers care about tool_use blocks, not stop_reason + // from the Responses adapter (which often reports end_turn even with tools). + if (content.some(b => b.type === 'tool_use') && stopReason === 'end_turn') { + stopReason = 'tool_use' + } + + return { + id: messageId, + type: 'message', + role: 'assistant', + content: content as BetaMessage['content'], + model, + stop_reason: stopReason, + stop_sequence: null, + usage, + } as BetaMessage +} + +/** + * ChatGPT OAuth side query via the Codex Responses API. + * + * Must not use getOpenAIClient() — that path only reads OPENAI_API_KEY and + * yields 401 under OPENAI_AUTH_MODE=chatgpt (no API key configured). + */ +async function sideQueryViaChatGPTResponses( + opts: SideQueryOptions, + openaiModel: string, + openaiMessages: Array<{ + role: 'system' | 'user' | 'assistant' + content: string + }>, + openaiTools: unknown[] | undefined, + openaiToolChoice: unknown, +): Promise { + const start = Date.now() + const request = buildResponsesRequest({ + model: openaiModel, + messages: openaiMessages, + tools: openaiTools ?? [], + toolChoice: openaiToolChoice, + }) + + const rawStream = await createChatGPTResponsesStream({ + request, + signal: opts.signal ?? new AbortController().signal, + }) + const adapted = adaptResponsesStreamToAnthropic(rawStream, openaiModel) + const betaMessage = await collectAnthropicStreamToBetaMessage( + adapted, + openaiModel, + ) + + const now = Date.now() + const lastCompletion = getLastApiCompletionTimestamp() + logEvent('tengu_api_success', { + requestId: + betaMessage.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + querySource: + opts.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + openaiModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + inputTokens: betaMessage.usage.input_tokens, + outputTokens: betaMessage.usage.output_tokens, + cachedInputTokens: betaMessage.usage.cache_read_input_tokens ?? 0, + uncachedInputTokens: betaMessage.usage.input_tokens, + durationMsIncludingRetries: now - start, + timeSinceLastApiCallMs: + lastCompletion !== null ? now - lastCompletion : undefined, + }) + setLastApiCompletionTimestamp(now) + + return betaMessage +} + /** * OpenAI-compatible side query for OpenAI and Grok providers. * Both use the OpenAI SDK with different base URLs. @@ -377,6 +616,10 @@ export async function sideQuery(opts: SideQueryOptions): Promise { * non-streaming request, and wraps the response back into a BetaMessage * shape so callers remain provider-agnostic. * + * When OPENAI_AUTH_MODE=chatgpt, OpenAI side queries use the ChatGPT OAuth + * Responses API path (same auth/transport as the main loop) instead of the + * API-key Chat Completions client. + * * Supports tools and tool_choice for structured output (e.g. yoloClassifier, * permissionExplainer). */ @@ -397,17 +640,11 @@ async function sideQueryViaOpenAICompatible( const provider = getAPIProvider() const normalizedModel = normalizeModelStringForAPI(model) - // Resolve model name and client per provider - let openaiModel: string - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - let client: import('openai').default - if (provider === 'grok') { - openaiModel = resolveGrokModel(normalizedModel) - client = getGrokClient({ maxRetries: opts.maxRetries ?? 2 }) - } else { - openaiModel = resolveOpenAIModel(normalizedModel) - client = getOpenAIClient({ maxRetries: opts.maxRetries ?? 2 }) - } + // Resolve model name per provider + const openaiModel = + provider === 'grok' + ? resolveGrokModel(normalizedModel) + : resolveOpenAIModel(normalizedModel) // Build system prompt text const systemText = extractSystemText(system) @@ -431,6 +668,24 @@ async function sideQueryViaOpenAICompatible( ? anthropicToolChoiceToOpenAI(tool_choice) : undefined + // ChatGPT subscription auth: use Responses API + OAuth, never empty API key. + if (provider === 'openai' && isChatGPTAuthEnabled()) { + return sideQueryViaChatGPTResponses( + opts, + openaiModel, + openaiMessages, + openaiTools, + openaiToolChoice, + ) + } + + // API-key / OpenAI-compatible / Grok: Chat Completions + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + const client: import('openai').default = + provider === 'grok' + ? getGrokClient({ maxRetries: opts.maxRetries ?? 2 }) + : getOpenAIClient({ maxRetries: opts.maxRetries ?? 2 }) + const start = Date.now() const requestParams: Record = {