551 lines
15 KiB
JavaScript
551 lines
15 KiB
JavaScript
const crypto = require('crypto')
|
|
|
|
const mockRouter = {
|
|
get: jest.fn(),
|
|
post: jest.fn()
|
|
}
|
|
|
|
jest.mock(
|
|
'express',
|
|
() => ({
|
|
Router: () => mockRouter
|
|
}),
|
|
{ virtual: true }
|
|
)
|
|
|
|
jest.mock(
|
|
'../config/config',
|
|
() => ({
|
|
requestTimeout: 1000
|
|
}),
|
|
{ virtual: true }
|
|
)
|
|
|
|
jest.mock('../src/middleware/auth', () => ({
|
|
authenticateApiKey: jest.fn((_req, _res, next) => next())
|
|
}))
|
|
|
|
jest.mock('axios', () => ({
|
|
post: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/scheduler/unifiedOpenAIScheduler', () => ({
|
|
selectAccountForApiKey: jest.fn(),
|
|
markAccountRateLimited: jest.fn(),
|
|
isAccountRateLimited: jest.fn().mockResolvedValue(false),
|
|
removeAccountRateLimit: jest.fn(),
|
|
markAccountUnauthorized: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/account/openaiAccountService', () => ({
|
|
getAccount: jest.fn(),
|
|
decrypt: jest.fn(),
|
|
isTokenExpired: jest.fn(() => false),
|
|
refreshAccountToken: jest.fn(),
|
|
updateCodexUsageSnapshot: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/account/openaiResponsesAccountService', () => ({
|
|
getAccount: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/relay/openaiResponsesRelayService', () => ({
|
|
handleRequest: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/services/apiKeyService', () => ({
|
|
hasPermission: jest.fn(() => true),
|
|
recordUsage: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/models/redis', () => ({
|
|
getUsageStats: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/utils/logger', () => ({
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
debug: jest.fn(),
|
|
api: jest.fn(),
|
|
security: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/utils/proxyHelper', () => ({
|
|
createProxyAgent: jest.fn(() => null),
|
|
getProxyDescription: jest.fn(() => 'none')
|
|
}))
|
|
|
|
jest.mock('../src/utils/rateLimitHelper', () => ({
|
|
updateRateLimitCounters: jest.fn()
|
|
}))
|
|
|
|
jest.mock('../src/utils/sseParser', () => ({
|
|
IncrementalSSEParser: jest.fn().mockImplementation(() => ({
|
|
feed: jest.fn(() => []),
|
|
getRemaining: jest.fn(() => '')
|
|
}))
|
|
}))
|
|
|
|
jest.mock('../src/utils/errorSanitizer', () => ({
|
|
getSafeMessage: jest.fn((error) => error?.message || 'error')
|
|
}))
|
|
|
|
jest.mock('../src/utils/requestDetailHelper', () => ({
|
|
createRequestDetailMeta: jest.fn(() => null),
|
|
extractOpenAICacheReadTokens: jest.fn(() => 0)
|
|
}))
|
|
|
|
const unifiedOpenAIScheduler = require('../src/services/scheduler/unifiedOpenAIScheduler')
|
|
const axios = require('axios')
|
|
const apiKeyService = require('../src/services/apiKeyService')
|
|
const openaiAccountService = require('../src/services/account/openaiAccountService')
|
|
const openaiResponsesAccountService = require('../src/services/account/openaiResponsesAccountService')
|
|
const openaiResponsesRelayService = require('../src/services/relay/openaiResponsesRelayService')
|
|
const openaiRoutes = require('../src/routes/openaiRoutes')
|
|
|
|
function createHash(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
function createReq({
|
|
path = '/v1/responses',
|
|
body = {},
|
|
userAgent = 'my-client/1.0',
|
|
apiKeyOverrides = {},
|
|
fromUnifiedEndpoint = false
|
|
} = {}) {
|
|
return {
|
|
method: 'POST',
|
|
path,
|
|
originalUrl: `/openai${path}`,
|
|
headers: {
|
|
'user-agent': userAgent
|
|
},
|
|
body: JSON.parse(JSON.stringify(body)),
|
|
apiKey: {
|
|
id: 'key_1',
|
|
permissions: ['openai'],
|
|
enableOpenAIResponsesCodexAdaptation: true,
|
|
enableOpenAIResponsesPayloadRules: false,
|
|
openaiResponsesPayloadRules: [],
|
|
...apiKeyOverrides
|
|
},
|
|
_fromUnifiedEndpoint: fromUnifiedEndpoint
|
|
}
|
|
}
|
|
|
|
function createRes() {
|
|
const res = {
|
|
statusCode: 200,
|
|
headers: {},
|
|
destroyed: false,
|
|
writableEnded: false,
|
|
headersSent: false,
|
|
status: jest.fn((code) => {
|
|
res.statusCode = code
|
|
return res
|
|
}),
|
|
json: jest.fn((payload) => {
|
|
res.payload = payload
|
|
return res
|
|
}),
|
|
setHeader: jest.fn((key, value) => {
|
|
res.headers[key] = value
|
|
}),
|
|
set: jest.fn((key, value) => {
|
|
res.headers[key] = value
|
|
return res
|
|
})
|
|
}
|
|
return res
|
|
}
|
|
|
|
describe('openai responses payload toggles', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
|
|
unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({
|
|
accountId: 'resp-1',
|
|
accountType: 'openai-responses'
|
|
})
|
|
|
|
openaiResponsesAccountService.getAccount.mockResolvedValue({
|
|
id: 'resp-1',
|
|
name: 'Responses Account',
|
|
apiKey: 'sk-responses'
|
|
})
|
|
|
|
openaiResponsesRelayService.handleRequest.mockResolvedValue({ ok: true })
|
|
openaiAccountService.decrypt.mockReturnValue('decrypted-token')
|
|
})
|
|
|
|
test('keeps standard responses payload unchanged for openai-responses when both toggles are off', async () => {
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-5-2025-08-07',
|
|
temperature: 0.2,
|
|
service_tier: 'priority',
|
|
prompt_cache_key: 'session-a'
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: false
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req.body).toEqual({
|
|
model: 'gpt-5-2025-08-07',
|
|
temperature: 0.2,
|
|
service_tier: 'priority',
|
|
prompt_cache_key: 'session-a'
|
|
})
|
|
expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith(
|
|
req.apiKey,
|
|
createHash('session-a'),
|
|
'gpt-5'
|
|
)
|
|
})
|
|
|
|
test('applies Codex adaptation only when adaptation toggle is on', async () => {
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-5-2025-08-07',
|
|
temperature: 0.2,
|
|
service_tier: 'priority',
|
|
prompt_cache_key: 'session-b'
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req.body.model).toBe('gpt-5')
|
|
expect(req.body.instructions).toBe(openaiRoutes.CODEX_CLI_INSTRUCTIONS)
|
|
expect(req.body.temperature).toBeUndefined()
|
|
expect(req.body.service_tier).toBeUndefined()
|
|
expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith(
|
|
req.apiKey,
|
|
createHash('session-b'),
|
|
'gpt-5'
|
|
)
|
|
})
|
|
|
|
test('applies payload rules directly on the original payload when adaptation is off', async () => {
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-4.1',
|
|
temperature: 0.5,
|
|
prompt_cache_key: 'old-key',
|
|
text: { format: {} }
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: true,
|
|
openaiResponsesPayloadRules: [
|
|
{ path: 'model', valueType: 'string', value: 'gpt-5' },
|
|
{ path: 'prompt_cache_key', valueType: 'string', value: 'new-key' },
|
|
{ path: 'text.format.type', valueType: 'string', value: 'json_schema' }
|
|
]
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req.body).toEqual({
|
|
model: 'gpt-5',
|
|
temperature: 0.5,
|
|
prompt_cache_key: 'new-key',
|
|
text: {
|
|
format: {
|
|
type: 'json_schema'
|
|
}
|
|
}
|
|
})
|
|
expect(req.body.instructions).toBeUndefined()
|
|
expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith(
|
|
req.apiKey,
|
|
createHash('new-key'),
|
|
'gpt-5'
|
|
)
|
|
})
|
|
|
|
test('applies payload rules after Codex adaptation when both toggles are on', async () => {
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-5-2025-08-07',
|
|
prompt_cache_key: 'legacy-key',
|
|
temperature: 0.2,
|
|
instructions: 'raw'
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: true,
|
|
enableOpenAIResponsesPayloadRules: true,
|
|
openaiResponsesPayloadRules: [
|
|
{ path: 'model', valueType: 'string', value: 'gpt-5.5' },
|
|
{ path: 'instructions', valueType: 'string', value: 'custom instructions' },
|
|
{ path: 'prompt_cache_key', valueType: 'string', value: 'rule-key' }
|
|
]
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req.body.model).toBe('gpt-5.5')
|
|
expect(req.body.instructions).toBe('custom instructions')
|
|
expect(req.body.temperature).toBeUndefined()
|
|
expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith(
|
|
req.apiKey,
|
|
createHash('rule-key'),
|
|
'gpt-5.5'
|
|
)
|
|
})
|
|
|
|
test('normalizes dated gpt-5 models only for scheduling and upstream openai requests when adaptation is off', async () => {
|
|
unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({
|
|
accountId: 'openai-1',
|
|
accountType: 'openai'
|
|
})
|
|
openaiAccountService.getAccount.mockResolvedValue({
|
|
id: 'openai-1',
|
|
name: 'OpenAI Account',
|
|
accessToken: 'encrypted-token',
|
|
accountId: 'chatgpt-account-1'
|
|
})
|
|
axios.post.mockResolvedValue({
|
|
status: 200,
|
|
data: {
|
|
model: 'gpt-5',
|
|
usage: {
|
|
input_tokens: 10,
|
|
output_tokens: 4,
|
|
total_tokens: 14
|
|
}
|
|
},
|
|
headers: {}
|
|
})
|
|
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-5-2025-08-07',
|
|
service_tier: 'priority',
|
|
prompt_cache_key: 'compat-key',
|
|
stream: false
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: false
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith(
|
|
req.apiKey,
|
|
createHash('compat-key'),
|
|
'gpt-5'
|
|
)
|
|
expect(req.body.model).toBe('gpt-5')
|
|
expect(req.body.service_tier).toBe('priority')
|
|
expect(axios.post).toHaveBeenCalled()
|
|
expect(axios.post.mock.calls[0][1]).toMatchObject({
|
|
model: 'gpt-5',
|
|
service_tier: 'priority',
|
|
store: false
|
|
})
|
|
})
|
|
|
|
test('normalizes payload-rule gpt-5 aliases for openai scheduling without applying full Codex adaptation', async () => {
|
|
unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({
|
|
accountId: 'openai-1',
|
|
accountType: 'openai'
|
|
})
|
|
openaiAccountService.getAccount.mockResolvedValue({
|
|
id: 'openai-1',
|
|
name: 'OpenAI Account',
|
|
accessToken: 'encrypted-token',
|
|
accountId: 'chatgpt-account-1'
|
|
})
|
|
axios.post.mockResolvedValue({
|
|
status: 200,
|
|
data: {
|
|
model: 'gpt-5',
|
|
usage: {
|
|
input_tokens: 8,
|
|
output_tokens: 3,
|
|
total_tokens: 11
|
|
}
|
|
},
|
|
headers: {}
|
|
})
|
|
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-4.1',
|
|
text: { format: {} },
|
|
prompt_cache_key: 'rule-model-key',
|
|
stream: false
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: true,
|
|
openaiResponsesPayloadRules: [
|
|
{ path: 'model', valueType: 'string', value: 'gpt-5-2025-08-07' }
|
|
]
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith(
|
|
req.apiKey,
|
|
createHash('rule-model-key'),
|
|
'gpt-5'
|
|
)
|
|
expect(req.body.model).toBe('gpt-5')
|
|
expect(req.body.text).toEqual({ format: {} })
|
|
expect(req.body.instructions).toBeUndefined()
|
|
expect(axios.post.mock.calls[0][1]).toMatchObject({
|
|
model: 'gpt-5',
|
|
text: { format: {} },
|
|
store: false
|
|
})
|
|
})
|
|
|
|
test('records the mutated service_tier for standard responses sent through openai accounts', async () => {
|
|
unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({
|
|
accountId: 'openai-1',
|
|
accountType: 'openai'
|
|
})
|
|
openaiAccountService.getAccount.mockResolvedValue({
|
|
id: 'openai-1',
|
|
name: 'OpenAI Account',
|
|
accessToken: 'encrypted-token',
|
|
accountId: 'chatgpt-account-1'
|
|
})
|
|
axios.post.mockResolvedValue({
|
|
status: 200,
|
|
data: {
|
|
model: 'gpt-4.1',
|
|
usage: {
|
|
input_tokens: 12,
|
|
output_tokens: 6,
|
|
total_tokens: 18
|
|
}
|
|
},
|
|
headers: {}
|
|
})
|
|
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-4.1',
|
|
prompt_cache_key: 'tier-rule-key',
|
|
stream: false
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: true,
|
|
openaiResponsesPayloadRules: [
|
|
{ path: 'service_tier', valueType: 'string', value: 'priority' }
|
|
]
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req._serviceTier).toBe('priority')
|
|
expect(apiKeyService.recordUsage).toHaveBeenCalled()
|
|
expect(apiKeyService.recordUsage.mock.calls[0][8]).toBe('priority')
|
|
})
|
|
|
|
test('records null service_tier after Codex adaptation removes it for openai accounts', async () => {
|
|
unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({
|
|
accountId: 'openai-1',
|
|
accountType: 'openai'
|
|
})
|
|
openaiAccountService.getAccount.mockResolvedValue({
|
|
id: 'openai-1',
|
|
name: 'OpenAI Account',
|
|
accessToken: 'encrypted-token',
|
|
accountId: 'chatgpt-account-1'
|
|
})
|
|
axios.post.mockResolvedValue({
|
|
status: 200,
|
|
data: {
|
|
model: 'gpt-5',
|
|
usage: {
|
|
input_tokens: 10,
|
|
output_tokens: 4,
|
|
total_tokens: 14
|
|
}
|
|
},
|
|
headers: {}
|
|
})
|
|
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-5-2025-08-07',
|
|
temperature: 0.2,
|
|
service_tier: 'priority',
|
|
prompt_cache_key: 'adapt-tier-key',
|
|
stream: false
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req.body.service_tier).toBeUndefined()
|
|
expect(req._serviceTier).toBeNull()
|
|
expect(apiKeyService.recordUsage).toHaveBeenCalled()
|
|
expect(apiKeyService.recordUsage.mock.calls[0][8]).toBeNull()
|
|
})
|
|
|
|
test('captures the post-rule service_tier before relaying openai-responses requests', async () => {
|
|
const req = createReq({
|
|
body: {
|
|
model: 'gpt-4.1',
|
|
prompt_cache_key: 'relay-tier-key'
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: true,
|
|
openaiResponsesPayloadRules: [
|
|
{ path: 'service_tier', valueType: 'string', value: 'priority' }
|
|
]
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req._serviceTier).toBe('priority')
|
|
expect(openaiResponsesRelayService.handleRequest).toHaveBeenCalled()
|
|
expect(openaiResponsesRelayService.handleRequest.mock.calls[0][0]._serviceTier).toBe('priority')
|
|
})
|
|
|
|
test('does not apply the new rule flow to compact responses routes', async () => {
|
|
const req = createReq({
|
|
path: '/v1/responses/compact',
|
|
body: {
|
|
model: 'o1-mini',
|
|
prompt_cache_key: 'compact-key',
|
|
temperature: 0.1
|
|
},
|
|
apiKeyOverrides: {
|
|
enableOpenAIResponsesCodexAdaptation: false,
|
|
enableOpenAIResponsesPayloadRules: true,
|
|
openaiResponsesPayloadRules: [
|
|
{ path: 'model', valueType: 'string', value: 'gpt-5' },
|
|
{ path: 'prompt_cache_key', valueType: 'string', value: 'rule-key' }
|
|
]
|
|
}
|
|
})
|
|
|
|
await openaiRoutes.handleResponses(req, createRes())
|
|
|
|
expect(req.body.model).toBe('o1-mini')
|
|
expect(req.body.prompt_cache_key).toBe('compact-key')
|
|
expect(req.body.instructions).toBe(openaiRoutes.CODEX_CLI_INSTRUCTIONS)
|
|
})
|
|
})
|