mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
refactor(openai-shim): extract Codex dispatch (#2074)
* refactor(openai-shim): extract Codex dispatch * test(openai-shim): cover Codex dispatch guards
This commit is contained in:
@@ -8,16 +8,10 @@ import {
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { COPILOT_HEADERS } from '../../services/github/deviceFlow.js'
|
||||
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
|
||||
const COPILOT_HEADERS: Record<string, string> = {
|
||||
'User-Agent': 'GitHubCopilotChat/0.26.7',
|
||||
'Editor-Version': 'vscode/1.99.3',
|
||||
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
|
||||
'Copilot-Integration-Id': 'vscode-chat',
|
||||
}
|
||||
|
||||
// Large system prompt (~6000 chars, ~1500 tokens) to cross the 1024-token cache threshold
|
||||
const SYSTEM_PROMPT = [
|
||||
'You are a coding assistant. Answer concisely.',
|
||||
|
||||
@@ -5878,53 +5878,6 @@ function makeCodexSseResponse(responseData: Record<string, unknown>): Response {
|
||||
return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`])
|
||||
}
|
||||
|
||||
test('GitHub Copilot codex responses transport does not replay after a pre-header timeout', async () => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com'
|
||||
process.env.OPENAI_API_KEY = 'test-token'
|
||||
process.env.API_TIMEOUT_MS = '20'
|
||||
let fetchCalls = 0
|
||||
const requestUrls: string[] = []
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
fetchCalls++
|
||||
requestUrls.push(String(input))
|
||||
return pendingFetchUntilAbort(init)
|
||||
}) as unknown as FetchType
|
||||
|
||||
const safety = new AbortController()
|
||||
const safetyTimer = setTimeout(() => safety.abort(), 500)
|
||||
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
||||
let caught: unknown
|
||||
try {
|
||||
await waitForPromise(
|
||||
client.beta.messages.create(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 32,
|
||||
stream: false,
|
||||
},
|
||||
{ signal: safety.signal },
|
||||
),
|
||||
750,
|
||||
'GitHub codex responses timeout did not settle',
|
||||
)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
} finally {
|
||||
clearTimeout(safetyTimer)
|
||||
}
|
||||
|
||||
expect(caught).toBeDefined()
|
||||
const error = caught as Error & { constructor: { name: string } }
|
||||
expect(error.constructor.name).toBe('APIConnectionError')
|
||||
expect(isOpenAIRequestNonReplayable(error)).toBe(true)
|
||||
expect(fetchCalls).toBe(1)
|
||||
expect(requestUrls).toEqual([
|
||||
'https://api.githubcopilot.com/responses',
|
||||
])
|
||||
})
|
||||
|
||||
test('GitHub Copilot responses fallback does not replay after a pre-header timeout', async () => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
@@ -6070,81 +6023,6 @@ test('GitHub Copilot responses fallback does not retry non-retryable HTTP failur
|
||||
|
||||
|
||||
// openaiShim test extraction seam 187 start: GitHub Copilot 401 codex_responses retries with refreshed token
|
||||
test('GitHub Copilot 401 codex_responses retries with refreshed token', async () => {
|
||||
const realGithubModule = realGithubModelsCredentials
|
||||
try {
|
||||
const refreshSpy = mock(async () => {
|
||||
process.env.GITHUB_TOKEN = 'refreshed-token'
|
||||
process.env.OPENAI_API_KEY = 'refreshed-token'
|
||||
return true
|
||||
})
|
||||
|
||||
mock.module('../../utils/githubModelsCredentials.js', () => ({
|
||||
...realGithubModule,
|
||||
refreshCopilotTokenOn401: refreshSpy,
|
||||
}))
|
||||
|
||||
let codexCallCount = 0
|
||||
let firstAuth: string | undefined
|
||||
let secondAuth: string | undefined
|
||||
|
||||
globalThis.fetch = ((_, init) => {
|
||||
codexCallCount++
|
||||
const headers = new Headers(init?.headers)
|
||||
const apiKey = headers.get('authorization')?.replace(/^Bearer /, '')
|
||||
|
||||
if (codexCallCount === 1) {
|
||||
firstAuth = apiKey
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: { message: 'token expired' } }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
}
|
||||
|
||||
if (codexCallCount === 2) {
|
||||
secondAuth = apiKey
|
||||
return Promise.resolve(makeCodexSseResponse({
|
||||
response: {
|
||||
id: 'resp_test',
|
||||
output: [{ type: 'message', content: [{ type: 'output_text', text: 'ok' }] }],
|
||||
model: 'gpt-5',
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
throw new Error(`unexpected codex call #${codexCallCount}`)
|
||||
}) as unknown as FetchType
|
||||
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com'
|
||||
process.env.OPENAI_API_KEY = 'initial-token'
|
||||
process.env.GITHUB_TOKEN = 'initial-token'
|
||||
|
||||
const { createOpenAIShimClient: createClient } =
|
||||
await importFreshOpenAIShim('copilot-401-retry-codex')
|
||||
|
||||
const client = createClient({}) as OpenAIShimClient
|
||||
|
||||
const response = await client.beta.messages.create({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 32,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1)
|
||||
expect(process.env.GITHUB_TOKEN).toBe('refreshed-token')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('refreshed-token')
|
||||
expect(codexCallCount).toBe(2)
|
||||
expect(firstAuth).toBe('initial-token')
|
||||
expect(secondAuth).toBe('refreshed-token')
|
||||
expect(response).toBeDefined()
|
||||
expect((response as Record<string, unknown>).content).toBeDefined()
|
||||
} finally {
|
||||
mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule)
|
||||
}
|
||||
})
|
||||
// openaiShim test extraction seam 193 end
|
||||
|
||||
|
||||
|
||||
+24
-151
@@ -37,15 +37,12 @@
|
||||
*/
|
||||
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import {
|
||||
readCodexCredentialsAsync,
|
||||
refreshCodexAccessTokenIfNeeded,
|
||||
} from '../../utils/codexCredentials.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { createStreamAbortError, getStreamIdleTimeoutMs, readWithIdleTimeout, StreamIdleTimeoutError } from './openaiShim/streamControl.js'
|
||||
export { getStreamIdleTimeoutMs } from './openaiShim/streamControl.js'
|
||||
import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { type OpenAIShimEffortLevel } from '../../utils/effort.js'
|
||||
import { COPILOT_HEADERS } from '../github/deviceFlow.js'
|
||||
import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
|
||||
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
|
||||
import {
|
||||
@@ -64,10 +61,10 @@ import {
|
||||
codexStreamToAnthropic,
|
||||
collectCodexCompletedResponse,
|
||||
convertCodexResponseToAnthropicMessage,
|
||||
performCodexRequest,
|
||||
type AnthropicStreamEvent,
|
||||
type ShimCreateParams,
|
||||
} from './codexShim.js'
|
||||
import { dispatchCodexRequest } from './openaiShim/codexDispatch.js'
|
||||
import { hydrateOpenAIShimCompatibilityEnv as hydrateRequestPlanningEnv } from './openaiShim/requestPlanner.js'
|
||||
import { prepareOpenAIRequest } from './openaiShim/requestPreparation.js'
|
||||
import {
|
||||
@@ -92,10 +89,8 @@ export { getApiTimeoutMs } from './openaiShim/transport.js'
|
||||
import { executeOpenAIRequest } from './openaiShim/requestExecutor.js'
|
||||
import {
|
||||
getLocalProviderRetryBaseUrls,
|
||||
getGithubEndpointType,
|
||||
isAzureStyleBaseUrl,
|
||||
isLocalProviderUrl,
|
||||
resolveRuntimeCodexCredentials,
|
||||
resolveProviderRequest,
|
||||
shouldAttemptLocalToollessRetry,
|
||||
} from './providerConfig.js'
|
||||
@@ -105,7 +100,7 @@ import {
|
||||
classifyOpenAINetworkFailure,
|
||||
markOpenAIRequestNonReplayable,
|
||||
} from './openaiErrorClassification.js'
|
||||
import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js'
|
||||
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||
import { logApiCallStart, logApiCallEnd } from '../../utils/requestLogging.js'
|
||||
import {
|
||||
createStreamState,
|
||||
@@ -154,13 +149,6 @@ const GITHUB_429_MAX_DELAY_SEC = 32
|
||||
const CREDENTIAL_POOL_COOLDOWN_MS = 30_000
|
||||
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000
|
||||
const GEMINI_API_HOST = 'generativelanguage.googleapis.com'
|
||||
const COPILOT_HEADERS: Record<string, string> = {
|
||||
'User-Agent': 'GitHubCopilotChat/0.26.7',
|
||||
'Editor-Version': 'vscode/1.99.3',
|
||||
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
|
||||
'Copilot-Integration-Id': 'vscode-chat',
|
||||
}
|
||||
|
||||
function isCopilotTokenExpiredError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return lower.includes('token expired') || lower.includes('token has expired')
|
||||
@@ -461,143 +449,28 @@ class OpenAIShimMessages {
|
||||
options?: { signal?: AbortSignal; headers?: Record<string, string> },
|
||||
requestProcessEnv: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<Response> {
|
||||
const githubEndpointType = getGithubEndpointType(request.baseUrl)
|
||||
const isGithubMode = isGithubModelsMode()
|
||||
const isGithubCopilotEndpoint = isGithubMode && (githubEndpointType === 'copilot' || githubEndpointType === 'ghe')
|
||||
const isGithubWithCodexTransport = isGithubCopilotEndpoint && request.transport === 'codex_responses'
|
||||
|
||||
if (isGithubWithCodexTransport) {
|
||||
const apiTimeoutMs = getApiTimeoutMs()
|
||||
const responsesUrl = `${request.baseUrl}/responses`
|
||||
let didRefreshCopilotCodexToken = false
|
||||
let refreshedCopilotCodexToken: string | undefined
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const apiKey = refreshedCopilotCodexToken ?? this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'GitHub Copilot auth is required. Run /onboard-github to sign in.',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
return await performCodexRequest({
|
||||
request,
|
||||
credentials: {
|
||||
apiKey,
|
||||
source: 'env',
|
||||
},
|
||||
params,
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders,
|
||||
...filterAnthropicHeaders(options?.headers),
|
||||
...COPILOT_HEADERS,
|
||||
},
|
||||
signal: options?.signal,
|
||||
fetcher: (input, init) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
return fetchWithHeadersDeadline(url, init ?? {}, {
|
||||
callerSignal: options?.signal,
|
||||
timeoutMs: apiTimeoutMs,
|
||||
})
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (options?.signal?.aborted) {
|
||||
throw preserveCallerAbortError(error, options.signal)
|
||||
}
|
||||
if (error instanceof ResponseHeadersTimeoutError) {
|
||||
const failure = {
|
||||
...classifyOpenAINetworkFailure(error, {
|
||||
url: responsesUrl,
|
||||
}),
|
||||
retryable: false,
|
||||
}
|
||||
throw createClassifiedTransportError(
|
||||
error,
|
||||
responsesUrl,
|
||||
request.resolvedModel,
|
||||
failure,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!didRefreshCopilotCodexToken &&
|
||||
error instanceof APIError &&
|
||||
error.status === 401
|
||||
) {
|
||||
if (
|
||||
apiKey === (process.env.OPENAI_API_KEY ?? '') &&
|
||||
isCopilotTokenExpiredError(error.message)
|
||||
) {
|
||||
didRefreshCopilotCodexToken = true
|
||||
const refreshed = await refreshCopilotTokenOn401()
|
||||
if (refreshed) {
|
||||
const newApiKey = process.env.OPENAI_API_KEY?.trim() || ''
|
||||
if (newApiKey && newApiKey !== apiKey) {
|
||||
refreshedCopilotCodexToken = newApiKey
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.transport === 'codex_responses' && !isGithubMode) {
|
||||
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
|
||||
async error => {
|
||||
logForDebugging(
|
||||
`[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
refreshed: false,
|
||||
credentials: await readCodexCredentialsAsync(),
|
||||
const codexResponse = await dispatchCodexRequest({
|
||||
request,
|
||||
params,
|
||||
requestOptions: options,
|
||||
defaultHeaders: this.defaultHeaders,
|
||||
providerOverrideApiKey: this.providerOverride?.apiKey,
|
||||
dependencies: {
|
||||
getApiTimeoutMs,
|
||||
fetchWithHeadersDeadline,
|
||||
preserveCallerAbortError,
|
||||
isCopilotTokenExpiredError,
|
||||
classifyResponseHeadersTimeout: (error, requestUrl, model) => {
|
||||
if (!(error instanceof ResponseHeadersTimeoutError)) return undefined
|
||||
const failure = {
|
||||
...classifyOpenAINetworkFailure(error, { url: requestUrl }),
|
||||
retryable: false,
|
||||
}
|
||||
return createClassifiedTransportError(error, requestUrl, model, failure)
|
||||
},
|
||||
)
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
storedCredentials: refreshResult.credentials,
|
||||
})
|
||||
if (!credentials.apiKey) {
|
||||
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
|
||||
const authHint = credentials.authPath
|
||||
? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
|
||||
: oauthHint
|
||||
const safeModel =
|
||||
redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ??
|
||||
'the requested model'
|
||||
throw new Error(
|
||||
`Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`,
|
||||
)
|
||||
}
|
||||
if (!credentials.accountId) {
|
||||
throw new Error(
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
|
||||
)
|
||||
}
|
||||
|
||||
return performCodexRequest({
|
||||
request,
|
||||
credentials,
|
||||
params,
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders,
|
||||
...filterAnthropicHeaders(options?.headers),
|
||||
},
|
||||
signal: options?.signal,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
if (codexResponse) return codexResponse
|
||||
|
||||
return this._doOpenAIRequest(request, params, options, requestProcessEnv)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../../test/sharedMutationLock.js'
|
||||
import type { performCodexRequest } from '../codexShim.js'
|
||||
import type { ResolvedProviderRequest } from '../providerConfig.js'
|
||||
import {
|
||||
dispatchCodexRequest,
|
||||
type CodexDispatchDependencies,
|
||||
} from './codexDispatch.js'
|
||||
|
||||
const originalEnv = {
|
||||
CHATGPT_ACCOUNT_ID: process.env.CHATGPT_ACCOUNT_ID,
|
||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||
CODEX_ACCOUNT_ID: process.env.CODEX_ACCOUNT_ID,
|
||||
CODEX_API_KEY: process.env.CODEX_API_KEY,
|
||||
CODEX_AUTH_JSON_PATH: process.env.CODEX_AUTH_JSON_PATH,
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
}
|
||||
|
||||
function restoreEnv(key: keyof typeof originalEnv): void {
|
||||
const value = originalEnv[key]
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('openaiShim/codexDispatch.test.ts')
|
||||
delete process.env.CHATGPT_ACCOUNT_ID
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CODEX_ACCOUNT_ID
|
||||
delete process.env.CODEX_API_KEY
|
||||
delete process.env.CODEX_AUTH_JSON_PATH
|
||||
delete process.env.CODEX_HOME
|
||||
delete process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(originalEnv) as Array<keyof typeof originalEnv>) {
|
||||
restoreEnv(key)
|
||||
}
|
||||
releaseSharedMutationLock()
|
||||
})
|
||||
|
||||
const params = {
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 32,
|
||||
}
|
||||
|
||||
function request(
|
||||
transport: ResolvedProviderRequest['transport'],
|
||||
baseUrl = 'https://api.openai.com/v1',
|
||||
): ResolvedProviderRequest {
|
||||
return {
|
||||
transport,
|
||||
requestedModel: 'gpt-5',
|
||||
resolvedModel: 'gpt-5',
|
||||
baseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies: CodexDispatchDependencies = {
|
||||
classifyResponseHeadersTimeout: () => undefined,
|
||||
fetchWithHeadersDeadline: () => Promise.reject(new Error('not expected')),
|
||||
getApiTimeoutMs: () => 1_000,
|
||||
isCopilotTokenExpiredError: text => text.includes('expired'),
|
||||
preserveCallerAbortError: error => error,
|
||||
}
|
||||
|
||||
test('returns null when the ordinary OpenAI dispatcher owns the request', async () => {
|
||||
const response = await dispatchCodexRequest({
|
||||
request: request('chat_completions'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: { isGithubModelsMode: () => false },
|
||||
})
|
||||
|
||||
expect(response).toBeNull()
|
||||
})
|
||||
|
||||
test('refreshes an expired GitHub Copilot token once before retrying', async () => {
|
||||
process.env.OPENAI_API_KEY = 'initial-token'
|
||||
const observedKeys: string[] = []
|
||||
const perform = mock(async options => {
|
||||
observedKeys.push(options.credentials.apiKey)
|
||||
if (observedKeys.length === 1) {
|
||||
throw APIError.generate(401, undefined, 'token expired', new Headers())
|
||||
}
|
||||
return new Response('ok')
|
||||
}) as unknown as typeof performCodexRequest
|
||||
const refresh = mock(async () => {
|
||||
process.env.OPENAI_API_KEY = 'refreshed-token'
|
||||
return true
|
||||
})
|
||||
|
||||
const response = await dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://api.githubcopilot.com'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: {
|
||||
isGithubModelsMode: () => true,
|
||||
performCodexRequest: perform,
|
||||
refreshCopilotTokenOn401: refresh,
|
||||
},
|
||||
})
|
||||
|
||||
expect(await response?.text()).toBe('ok')
|
||||
expect(observedKeys).toEqual(['initial-token', 'refreshed-token'])
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not retry a Copilot request when token refresh fails', async () => {
|
||||
process.env.OPENAI_API_KEY = 'initial-token'
|
||||
const error = APIError.generate(401, undefined, 'token expired', new Headers())
|
||||
const perform = mock(async () => {
|
||||
throw error
|
||||
}) as unknown as typeof performCodexRequest
|
||||
const refresh = mock(async () => false)
|
||||
|
||||
await expect(dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://api.githubcopilot.com'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: {
|
||||
isGithubModelsMode: () => true,
|
||||
performCodexRequest: perform,
|
||||
refreshCopilotTokenOn401: refresh,
|
||||
},
|
||||
})).rejects.toBe(error)
|
||||
|
||||
expect(perform).toHaveBeenCalledTimes(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not retry a Copilot request when refresh returns the same token', async () => {
|
||||
process.env.OPENAI_API_KEY = 'unchanged-token'
|
||||
const error = APIError.generate(401, undefined, 'token expired', new Headers())
|
||||
const perform = mock(async () => {
|
||||
throw error
|
||||
}) as unknown as typeof performCodexRequest
|
||||
const refresh = mock(async () => true)
|
||||
|
||||
await expect(dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://api.githubcopilot.com'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: {
|
||||
isGithubModelsMode: () => true,
|
||||
performCodexRequest: perform,
|
||||
refreshCopilotTokenOn401: refresh,
|
||||
},
|
||||
})).rejects.toBe(error)
|
||||
|
||||
expect(perform).toHaveBeenCalledTimes(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('preserves a caller abort while dispatching a Copilot request', async () => {
|
||||
process.env.OPENAI_API_KEY = 'test-token'
|
||||
const controller = new AbortController()
|
||||
const preserved = new Error('preserved caller abort')
|
||||
controller.abort()
|
||||
const requestError = new Error('request aborted')
|
||||
const perform = mock(async () => {
|
||||
throw requestError
|
||||
}) as unknown as typeof performCodexRequest
|
||||
const preserveCallerAbortError = mock(() => preserved)
|
||||
|
||||
await expect(dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://api.githubcopilot.com'),
|
||||
params,
|
||||
requestOptions: { signal: controller.signal },
|
||||
defaultHeaders: {},
|
||||
dependencies: { ...dependencies, preserveCallerAbortError },
|
||||
operations: {
|
||||
isGithubModelsMode: () => true,
|
||||
performCodexRequest: perform,
|
||||
},
|
||||
})).rejects.toBe(preserved)
|
||||
|
||||
expect(perform).toHaveBeenCalledTimes(1)
|
||||
expect(preserveCallerAbortError).toHaveBeenCalledWith(requestError, controller.signal)
|
||||
})
|
||||
|
||||
test('rejects a Copilot request without credentials before dispatch', async () => {
|
||||
const perform = mock(async () => new Response('unexpected')) as unknown as typeof performCodexRequest
|
||||
|
||||
await expect(dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://api.githubcopilot.com'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: {
|
||||
isGithubModelsMode: () => true,
|
||||
performCodexRequest: perform,
|
||||
},
|
||||
})).rejects.toThrow('/onboard-github')
|
||||
|
||||
expect(perform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('classifies a GitHub Copilot pre-header timeout without replaying', async () => {
|
||||
process.env.OPENAI_API_KEY = 'test-token'
|
||||
const timeout = new Error('headers timed out')
|
||||
const classified = new Error('classified non-replayable timeout')
|
||||
const fetchWithDeadline = mock(async () => {
|
||||
throw timeout
|
||||
})
|
||||
const classify = mock((error: unknown) =>
|
||||
error === timeout ? classified : undefined,
|
||||
)
|
||||
const perform = mock(async options =>
|
||||
options.fetcher?.('https://api.githubcopilot.com/responses', {}),
|
||||
) as unknown as typeof performCodexRequest
|
||||
|
||||
await expect(dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://api.githubcopilot.com'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies: {
|
||||
...dependencies,
|
||||
classifyResponseHeadersTimeout: classify,
|
||||
fetchWithHeadersDeadline: fetchWithDeadline,
|
||||
},
|
||||
operations: {
|
||||
isGithubModelsMode: () => true,
|
||||
performCodexRequest: perform,
|
||||
},
|
||||
})).rejects.toBe(classified)
|
||||
|
||||
expect(fetchWithDeadline).toHaveBeenCalledTimes(1)
|
||||
expect(classify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('uses refreshed first-party Codex credentials for dispatch', async () => {
|
||||
let observedCredentials: { apiKey: string; accountId?: string } | undefined
|
||||
const perform = mock(async options => {
|
||||
observedCredentials = options.credentials
|
||||
return new Response('ok')
|
||||
}) as unknown as typeof performCodexRequest
|
||||
|
||||
const response = await dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://chatgpt.com/backend-api/codex'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: {
|
||||
isGithubModelsMode: () => false,
|
||||
performCodexRequest: perform,
|
||||
refreshCodexAccessTokenIfNeeded: async () => ({
|
||||
refreshed: true,
|
||||
credentials: {
|
||||
accessToken: 'codex-access-token',
|
||||
accountId: 'account-1',
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(await response?.text()).toBe('ok')
|
||||
expect(observedCredentials).toMatchObject({
|
||||
apiKey: 'codex-access-token',
|
||||
accountId: 'account-1',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects first-party Codex credentials without an account id', async () => {
|
||||
await expect(dispatchCodexRequest({
|
||||
request: request('codex_responses', 'https://chatgpt.com/backend-api/codex'),
|
||||
params,
|
||||
defaultHeaders: {},
|
||||
dependencies,
|
||||
operations: {
|
||||
isGithubModelsMode: () => false,
|
||||
refreshCodexAccessTokenIfNeeded: async () => ({
|
||||
refreshed: false,
|
||||
credentials: { accessToken: 'codex-access-token' },
|
||||
}),
|
||||
},
|
||||
})).rejects.toThrow('Codex auth is missing chatgpt_account_id')
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import {
|
||||
readCodexCredentialsAsync,
|
||||
refreshCodexAccessTokenIfNeeded,
|
||||
} from '../../../utils/codexCredentials.js'
|
||||
import { logForDebugging } from '../../../utils/debug.js'
|
||||
import { isBareMode } from '../../../utils/envUtils.js'
|
||||
import { COPILOT_HEADERS } from '../../github/deviceFlow.js'
|
||||
import { refreshCopilotTokenOn401 } from '../../../utils/githubModelsCredentials.js'
|
||||
import {
|
||||
performCodexRequest,
|
||||
type ShimCreateParams,
|
||||
} from '../codexShim.js'
|
||||
import {
|
||||
getGithubEndpointType,
|
||||
resolveRuntimeCodexCredentials,
|
||||
type ResolvedProviderRequest,
|
||||
} from '../providerConfig.js'
|
||||
import {
|
||||
redactSecretValueForDisplay,
|
||||
type SecretValueSource,
|
||||
} from '../../../utils/providerProfile.js'
|
||||
import {
|
||||
filterAnthropicHeaders,
|
||||
isGithubModelsMode,
|
||||
} from './providerCompatibility.js'
|
||||
|
||||
type PerformCodexRequest = typeof performCodexRequest
|
||||
type ResponseHeadersTimeoutClassification = Error | undefined
|
||||
|
||||
export type CodexDispatchDependencies = {
|
||||
classifyResponseHeadersTimeout(
|
||||
error: unknown,
|
||||
requestUrl: string,
|
||||
model: string,
|
||||
): ResponseHeadersTimeoutClassification
|
||||
fetchWithHeadersDeadline(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
options: { callerSignal?: AbortSignal; timeoutMs: number },
|
||||
): Promise<Response>
|
||||
getApiTimeoutMs(): number
|
||||
isCopilotTokenExpiredError(text: string): boolean
|
||||
preserveCallerAbortError(error: unknown, callerSignal: AbortSignal): unknown
|
||||
}
|
||||
|
||||
type CodexDispatchOperations = {
|
||||
isGithubModelsMode: typeof isGithubModelsMode
|
||||
performCodexRequest: PerformCodexRequest
|
||||
readCodexCredentialsAsync: typeof readCodexCredentialsAsync
|
||||
refreshCodexAccessTokenIfNeeded: typeof refreshCodexAccessTokenIfNeeded
|
||||
refreshCopilotTokenOn401: typeof refreshCopilotTokenOn401
|
||||
}
|
||||
|
||||
const defaultOperations: CodexDispatchOperations = {
|
||||
isGithubModelsMode,
|
||||
performCodexRequest,
|
||||
readCodexCredentialsAsync,
|
||||
refreshCodexAccessTokenIfNeeded,
|
||||
refreshCopilotTokenOn401,
|
||||
}
|
||||
|
||||
export async function dispatchCodexRequest(options: {
|
||||
request: ResolvedProviderRequest
|
||||
params: ShimCreateParams
|
||||
requestOptions?: { signal?: AbortSignal; headers?: Record<string, string> }
|
||||
defaultHeaders: Record<string, string>
|
||||
providerOverrideApiKey?: string
|
||||
dependencies: CodexDispatchDependencies
|
||||
operations?: Partial<CodexDispatchOperations>
|
||||
}): Promise<Response | null> {
|
||||
const {
|
||||
request,
|
||||
params,
|
||||
requestOptions,
|
||||
defaultHeaders,
|
||||
providerOverrideApiKey,
|
||||
dependencies,
|
||||
} = options
|
||||
const operations = { ...defaultOperations, ...options.operations }
|
||||
const githubEndpointType = getGithubEndpointType(request.baseUrl)
|
||||
const isGithubMode = operations.isGithubModelsMode()
|
||||
const isGithubCopilotEndpoint =
|
||||
isGithubMode &&
|
||||
(githubEndpointType === 'copilot' || githubEndpointType === 'ghe')
|
||||
|
||||
if (isGithubCopilotEndpoint && request.transport === 'codex_responses') {
|
||||
const apiTimeoutMs = dependencies.getApiTimeoutMs()
|
||||
const responsesUrl = `${request.baseUrl}/responses`
|
||||
let didRefreshToken = false
|
||||
let refreshedToken: string | undefined
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const apiKey =
|
||||
refreshedToken ??
|
||||
providerOverrideApiKey ??
|
||||
process.env.OPENAI_API_KEY ??
|
||||
''
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'GitHub Copilot auth is required. Run /onboard-github to sign in.',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
return await operations.performCodexRequest({
|
||||
request,
|
||||
credentials: { apiKey, source: 'env' },
|
||||
params,
|
||||
defaultHeaders: {
|
||||
...defaultHeaders,
|
||||
...filterAnthropicHeaders(requestOptions?.headers),
|
||||
...COPILOT_HEADERS,
|
||||
},
|
||||
signal: requestOptions?.signal,
|
||||
fetcher: (input, init) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
return dependencies.fetchWithHeadersDeadline(url, init ?? {}, {
|
||||
callerSignal: requestOptions?.signal,
|
||||
timeoutMs: apiTimeoutMs,
|
||||
})
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestOptions?.signal?.aborted) {
|
||||
throw dependencies.preserveCallerAbortError(
|
||||
error,
|
||||
requestOptions.signal,
|
||||
)
|
||||
}
|
||||
const timeoutError = dependencies.classifyResponseHeadersTimeout(
|
||||
error,
|
||||
responsesUrl,
|
||||
request.resolvedModel,
|
||||
)
|
||||
if (timeoutError !== undefined) throw timeoutError
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!didRefreshToken &&
|
||||
error instanceof APIError &&
|
||||
error.status === 401 &&
|
||||
apiKey === (process.env.OPENAI_API_KEY ?? '') &&
|
||||
dependencies.isCopilotTokenExpiredError(error.message)
|
||||
) {
|
||||
didRefreshToken = true
|
||||
if (await operations.refreshCopilotTokenOn401()) {
|
||||
const newApiKey = process.env.OPENAI_API_KEY?.trim() || ''
|
||||
if (newApiKey && newApiKey !== apiKey) {
|
||||
refreshedToken = newApiKey
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.transport !== 'codex_responses' || isGithubMode) return null
|
||||
|
||||
const refreshResult = await operations.refreshCodexAccessTokenIfNeeded().catch(
|
||||
async error => {
|
||||
logForDebugging(
|
||||
`[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
refreshed: false,
|
||||
credentials: await operations.readCodexCredentialsAsync(),
|
||||
}
|
||||
},
|
||||
)
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
storedCredentials: refreshResult.credentials,
|
||||
})
|
||||
if (!credentials.apiKey) {
|
||||
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
|
||||
const authHint = credentials.authPath
|
||||
? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
|
||||
: oauthHint
|
||||
const safeModel =
|
||||
redactSecretValueForDisplay(
|
||||
request.requestedModel,
|
||||
process.env as SecretValueSource,
|
||||
) ?? 'the requested model'
|
||||
throw new Error(
|
||||
`Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`,
|
||||
)
|
||||
}
|
||||
if (!credentials.accountId) {
|
||||
throw new Error(
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
|
||||
)
|
||||
}
|
||||
|
||||
return operations.performCodexRequest({
|
||||
request,
|
||||
credentials,
|
||||
params,
|
||||
defaultHeaders: {
|
||||
...defaultHeaders,
|
||||
...filterAnthropicHeaders(requestOptions?.headers),
|
||||
},
|
||||
signal: requestOptions?.signal,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user