Skip to content

Commit 1338b50

Browse files
Merge pull request #1295 from DavidShawa/fix/auto-mode-chatgpt-oauth-sidequery
fix(api): use ChatGPT OAuth for OpenAI sideQuery (auto-mode classifier)
2 parents a376e2d + 1fa4bea commit 1338b50

2 files changed

Lines changed: 581 additions & 11 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
/**
2+
* Regression: Auto mode classifier sideQuery under ChatGPT OAuth.
3+
*
4+
* Bug: sideQueryViaOpenAICompatible always used getOpenAIClient() which only
5+
* reads OPENAI_API_KEY. With OPENAI_AUTH_MODE=chatgpt and no API key, the
6+
* classifier got 401 and fail-closed to human confirmation.
7+
*
8+
* Fix: when isChatGPTAuthEnabled(), route OpenAI side queries through the
9+
* ChatGPT Responses + OAuth path used by the main loop.
10+
*
11+
* Avoid mocking getAPIProvider (process-global pollution). Select OpenAI via
12+
* CLAUDE_CODE_USE_OPENAI env. Mock only client + ChatGPT token surface.
13+
*/
14+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
15+
import { logMock } from '../../../tests/mocks/log'
16+
import { debugMock } from '../../../tests/mocks/debug'
17+
18+
mock.module('src/utils/log.ts', logMock)
19+
mock.module('src/utils/debug.ts', debugMock)
20+
21+
mock.module('src/services/analytics/index.js', () => ({
22+
logEvent: () => {},
23+
logEventAsync: async () => {},
24+
stripProtoFields: <V>(v: V) => v,
25+
attachAnalyticsSink: () => {},
26+
_resetForTesting: () => {},
27+
}))
28+
29+
let getOpenAIClientCallCount = 0
30+
let chatCompletionsCreateCount = 0
31+
let lastChatCompletionsArgs: Record<string, unknown> | null = null
32+
33+
mock.module('src/services/api/openai/client.js', () => ({
34+
getOpenAIClient: () => {
35+
getOpenAIClientCallCount++
36+
return {
37+
chat: {
38+
completions: {
39+
create: async (args: Record<string, unknown>) => {
40+
chatCompletionsCreateCount++
41+
lastChatCompletionsArgs = args
42+
return {
43+
id: 'chatcmpl_test',
44+
choices: [
45+
{
46+
finish_reason: 'tool_calls',
47+
message: {
48+
content: null,
49+
tool_calls: [
50+
{
51+
type: 'function',
52+
id: 'call_api_key',
53+
function: {
54+
name: 'classify_result',
55+
arguments: JSON.stringify({ shouldBlock: false }),
56+
},
57+
},
58+
],
59+
},
60+
},
61+
],
62+
usage: { prompt_tokens: 3, completion_tokens: 2 },
63+
}
64+
},
65+
},
66+
},
67+
}
68+
},
69+
clearOpenAIClientCache: () => {},
70+
}))
71+
72+
// Keep isChatGPTAuthEnabled env-driven (same as production) so other suite
73+
// files are not forced into ChatGPT mode.
74+
mock.module('src/services/api/openai/chatgptAuth.js', () => ({
75+
isChatGPTAuthEnabled: () => process.env.OPENAI_AUTH_MODE === 'chatgpt',
76+
getValidChatGPTAuth: async () => ({
77+
accessToken: 'test-access-token-not-real',
78+
accountId: 'acct_test',
79+
}),
80+
removeChatGPTAuth: async () => {},
81+
requestChatGPTDeviceCode: async () => {
82+
throw new Error('not used')
83+
},
84+
completeChatGPTDeviceLogin: async () => {
85+
throw new Error('not used')
86+
},
87+
}))
88+
89+
type CapturedFetch = {
90+
url: string
91+
headers: Record<string, string>
92+
body: Record<string, unknown>
93+
}
94+
let capturedFetch: CapturedFetch | null = null
95+
let originalFetch: typeof globalThis.fetch
96+
97+
const ENV_KEYS = [
98+
'CLAUDE_CODE_USE_OPENAI',
99+
'CLAUDE_CODE_USE_GROK',
100+
'CLAUDE_CODE_USE_GEMINI',
101+
'CLAUDE_CODE_USE_BEDROCK',
102+
'CLAUDE_CODE_USE_VERTEX',
103+
'CLAUDE_CODE_USE_FOUNDRY',
104+
'OPENAI_AUTH_MODE',
105+
'OPENAI_API_KEY',
106+
] as const
107+
108+
const savedEnv: Record<string, string | undefined> = {}
109+
110+
function buildFunctionCallSse(toolName: string, argsJson: string): string {
111+
return [
112+
`data: ${JSON.stringify({
113+
type: 'response.output_item.added',
114+
output_index: 0,
115+
item: {
116+
type: 'function_call',
117+
call_id: 'call_chatgpt_1',
118+
name: toolName,
119+
},
120+
})}`,
121+
'',
122+
`data: ${JSON.stringify({
123+
type: 'response.function_call_arguments.delta',
124+
output_index: 0,
125+
delta: argsJson,
126+
})}`,
127+
'',
128+
`data: ${JSON.stringify({
129+
type: 'response.output_item.done',
130+
output_index: 0,
131+
})}`,
132+
'',
133+
`data: ${JSON.stringify({
134+
type: 'response.completed',
135+
response: {
136+
status: 'completed',
137+
usage: { input_tokens: 11, output_tokens: 7 },
138+
},
139+
})}`,
140+
'',
141+
'',
142+
].join('\n')
143+
}
144+
145+
function enableOpenAIProvider(): void {
146+
process.env.CLAUDE_CODE_USE_OPENAI = '1'
147+
delete process.env.CLAUDE_CODE_USE_GROK
148+
delete process.env.CLAUDE_CODE_USE_GEMINI
149+
delete process.env.CLAUDE_CODE_USE_BEDROCK
150+
delete process.env.CLAUDE_CODE_USE_VERTEX
151+
delete process.env.CLAUDE_CODE_USE_FOUNDRY
152+
}
153+
154+
beforeEach(() => {
155+
for (const key of ENV_KEYS) {
156+
savedEnv[key] = process.env[key]
157+
}
158+
getOpenAIClientCallCount = 0
159+
chatCompletionsCreateCount = 0
160+
lastChatCompletionsArgs = null
161+
capturedFetch = null
162+
enableOpenAIProvider()
163+
originalFetch = globalThis.fetch
164+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
165+
const url = String(input)
166+
const headers: Record<string, string> = {}
167+
const rawHeaders = init?.headers
168+
if (
169+
rawHeaders &&
170+
typeof rawHeaders === 'object' &&
171+
!Array.isArray(rawHeaders)
172+
) {
173+
for (const [k, v] of Object.entries(
174+
rawHeaders as Record<string, string>,
175+
)) {
176+
headers[k] = v
177+
}
178+
}
179+
const body =
180+
typeof init?.body === 'string'
181+
? (JSON.parse(init.body) as Record<string, unknown>)
182+
: {}
183+
capturedFetch = { url, headers, body }
184+
return new Response(
185+
buildFunctionCallSse(
186+
'classify_result',
187+
'{"shouldBlock":false,"reason":"ok"}',
188+
),
189+
{
190+
status: 200,
191+
headers: { 'Content-Type': 'text/event-stream' },
192+
},
193+
)
194+
}) as unknown as typeof fetch
195+
})
196+
197+
afterEach(() => {
198+
globalThis.fetch = originalFetch
199+
for (const key of ENV_KEYS) {
200+
const value = savedEnv[key]
201+
if (value === undefined) delete process.env[key]
202+
else process.env[key] = value
203+
}
204+
})
205+
206+
const classifierTool = {
207+
name: 'classify_result',
208+
description: 'Classify the action',
209+
input_schema: {
210+
type: 'object',
211+
properties: {
212+
shouldBlock: { type: 'boolean' },
213+
reason: { type: 'string' },
214+
},
215+
},
216+
}
217+
218+
describe('sideQuery OpenAI ChatGPT OAuth path', () => {
219+
test('uses ChatGPT Responses + OAuth, not empty-key Chat Completions', async () => {
220+
process.env.OPENAI_AUTH_MODE = 'chatgpt'
221+
delete process.env.OPENAI_API_KEY
222+
const { sideQuery } = await import('../sideQuery.js')
223+
224+
const result = await sideQuery({
225+
querySource: 'auto_mode',
226+
model: 'gpt-5.5',
227+
system: 'You are a classifier.',
228+
messages: [{ role: 'user', content: 'classify this action' }],
229+
tools: [classifierTool as never],
230+
tool_choice: { type: 'tool', name: 'classify_result' },
231+
max_tokens: 256,
232+
})
233+
234+
expect(getOpenAIClientCallCount).toBe(0)
235+
expect(chatCompletionsCreateCount).toBe(0)
236+
expect(capturedFetch).not.toBeNull()
237+
expect(capturedFetch!.url).toContain(
238+
'chatgpt.com/backend-api/codex/responses',
239+
)
240+
// OAuth header present; do not assert the real token value.
241+
expect(capturedFetch!.headers.Authorization).toMatch(/^Bearer \S+/)
242+
expect(capturedFetch!.headers['ChatGPT-Account-Id']).toBe('acct_test')
243+
expect(capturedFetch!.body.stream).toBe(true)
244+
expect(capturedFetch!.body.model).toBe('gpt-5.5')
245+
expect(Array.isArray(capturedFetch!.body.tools)).toBe(true)
246+
expect(capturedFetch!.body.tool_choice).toEqual({
247+
type: 'function',
248+
name: 'classify_result',
249+
})
250+
251+
const toolUse = result.content.find(
252+
(
253+
b,
254+
): b is {
255+
type: 'tool_use'
256+
id: string
257+
name: string
258+
input: unknown
259+
} => b.type === 'tool_use',
260+
)
261+
expect(toolUse).toBeDefined()
262+
expect(toolUse!.name).toBe('classify_result')
263+
expect(toolUse!.input).toEqual({ shouldBlock: false, reason: 'ok' })
264+
expect(result.stop_reason).toBe('tool_use')
265+
expect(result.usage.input_tokens).toBe(11)
266+
expect(result.usage.output_tokens).toBe(7)
267+
})
268+
269+
test('API key mode still uses Chat Completions client', async () => {
270+
delete process.env.OPENAI_AUTH_MODE
271+
process.env.OPENAI_API_KEY = 'sk-test-not-real'
272+
const { sideQuery } = await import('../sideQuery.js')
273+
274+
const result = await sideQuery({
275+
querySource: 'auto_mode',
276+
model: 'gpt-4o',
277+
messages: [{ role: 'user', content: 'hi' }],
278+
tools: [classifierTool as never],
279+
tool_choice: { type: 'tool', name: 'classify_result' },
280+
})
281+
282+
expect(getOpenAIClientCallCount).toBe(1)
283+
expect(chatCompletionsCreateCount).toBe(1)
284+
expect(capturedFetch).toBeNull()
285+
expect(lastChatCompletionsArgs?.model).toBe('gpt-4o')
286+
287+
const toolUse = result.content.find(b => b.type === 'tool_use') as
288+
| { type: 'tool_use'; name: string; input: unknown }
289+
| undefined
290+
expect(toolUse?.name).toBe('classify_result')
291+
expect(toolUse?.input).toEqual({ shouldBlock: false })
292+
})
293+
294+
test('ChatGPT OAuth request failure propagates for fail-closed classifiers', async () => {
295+
process.env.OPENAI_AUTH_MODE = 'chatgpt'
296+
globalThis.fetch = (async () =>
297+
new Response(JSON.stringify({ error: { message: 'unauthorized' } }), {
298+
status: 401,
299+
statusText: 'Unauthorized',
300+
})) as unknown as typeof fetch
301+
302+
const { sideQuery } = await import('../sideQuery.js')
303+
304+
await expect(
305+
sideQuery({
306+
querySource: 'auto_mode',
307+
model: 'gpt-5.5',
308+
messages: [{ role: 'user', content: 'classify' }],
309+
tools: [classifierTool as never],
310+
tool_choice: { type: 'tool', name: 'classify_result' },
311+
}),
312+
).rejects.toThrow(/ChatGPT Responses API request failed \(401\)/)
313+
expect(getOpenAIClientCallCount).toBe(0)
314+
})
315+
})

0 commit comments

Comments
 (0)