forked from boos/BoosAPI
Keep per-site probe settings reliable before merge
Refresh the PR onto current main and close the remaining probe-setting review gaps: the primary site save now persists the latency threshold, the site row type carries that field without an any-cast, active probe streams are aborted on unmount, and all-model one-shot probes get the longer timeout they need. Constraint: PR #510 must preserve the per-site model probe feature while merging cleanly into current main. Rejected: Leave threshold persistence behind the separate probe-settings button | the primary save path would still silently drop user changes. Rejected: Keep the SiteRow any-cast | it hides API/schema drift for the new persisted field. Confidence: high Scope-risk: narrow Directive: Keep probe setting fields represented in the typed site save payload and covered by focused web API/editor tests. Tested: npm run typecheck; npm run repo:drift-check; npx vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts src/web/api.test.ts src/web/pages/helpers/sitesEditor.test.ts src/web/pages/sites.disabled-models-save.test.tsx src/web/pages/sites.edit-scroll.test.tsx Not-tested: Full npm test.
This commit is contained in:
@@ -81,6 +81,8 @@ describe('settings and auth events', () => {
|
||||
(config as any).telegramChatId = '';
|
||||
(config as any).telegramUseSystemProxy = false;
|
||||
(config as any).telegramMessageThreadId = '';
|
||||
config.globalBlockedBrands = [];
|
||||
config.globalAllowedModels = [];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -745,6 +747,32 @@ describe('settings and auth events', () => {
|
||||
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('persists global model and brand filters as JSON arrays', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
globalAllowedModels: ['model-alpha', ' model-beta ', 'model-alpha', 'model-gamma'],
|
||||
globalBlockedBrands: ['Codex', ' Codex ', 'Gemini'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as {
|
||||
globalAllowedModels?: string[];
|
||||
globalBlockedBrands?: string[];
|
||||
};
|
||||
expect(updated.globalAllowedModels).toEqual(['model-alpha', 'model-beta', 'model-gamma']);
|
||||
expect(updated.globalBlockedBrands).toEqual(['Codex', 'Gemini']);
|
||||
|
||||
const rows = await db.select().from(schema.settings).all();
|
||||
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
|
||||
expect(settingsMap.get('global_allowed_models')).toBe(JSON.stringify(['model-alpha', 'model-beta', 'model-gamma']));
|
||||
expect(JSON.parse(settingsMap.get('global_allowed_models') || 'null')).toEqual(['model-alpha', 'model-beta', 'model-gamma']);
|
||||
expect(settingsMap.get('global_blocked_brands')).toBe(JSON.stringify(['Codex', 'Gemini']));
|
||||
expect(JSON.parse(settingsMap.get('global_blocked_brands') || 'null')).toEqual(['Codex', 'Gemini']);
|
||||
});
|
||||
|
||||
it('persists and returns log cleanup settings from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1429,7 +1429,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
changedLabels.push('全局品牌屏蔽');
|
||||
}
|
||||
config.globalBlockedBrands = uniqueBrands;
|
||||
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
|
||||
upsertSetting('global_blocked_brands', uniqueBrands);
|
||||
if (prev !== next) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
@@ -1454,7 +1454,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
changedLabels.push('全局模型白名单');
|
||||
}
|
||||
config.globalAllowedModels = uniqueModels;
|
||||
upsertSetting('global_allowed_models', JSON.stringify(uniqueModels));
|
||||
upsertSetting('global_allowed_models', uniqueModels);
|
||||
if (prev !== next) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
|
||||
@@ -9,13 +9,13 @@ import { waitForBackgroundTaskToReachTerminalState } from '../../test-fixtures/b
|
||||
|
||||
const {
|
||||
fetchLatestStableGitHubReleaseMock,
|
||||
fetchLatestDockerHubTagMock,
|
||||
fetchDockerHubTagCandidatesMock,
|
||||
getUpdateCenterHelperStatusMock,
|
||||
streamUpdateCenterDeployMock,
|
||||
streamUpdateCenterRollbackMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
||||
fetchLatestDockerHubTagMock: vi.fn(),
|
||||
fetchDockerHubTagCandidatesMock: vi.fn(),
|
||||
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||
streamUpdateCenterDeployMock: vi.fn(),
|
||||
streamUpdateCenterRollbackMock: vi.fn(),
|
||||
@@ -26,7 +26,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => {
|
||||
return {
|
||||
...actual,
|
||||
fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args),
|
||||
fetchLatestDockerHubTag: (...args: unknown[]) => fetchLatestDockerHubTagMock(...args),
|
||||
fetchDockerHubTagCandidates: (...args: unknown[]) => fetchDockerHubTagCandidatesMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('update center routes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockReset();
|
||||
fetchLatestDockerHubTagMock.mockReset();
|
||||
fetchDockerHubTagCandidatesMock.mockReset();
|
||||
getUpdateCenterHelperStatusMock.mockReset();
|
||||
streamUpdateCenterDeployMock.mockReset();
|
||||
streamUpdateCenterRollbackMock.mockReset();
|
||||
@@ -139,6 +139,18 @@ describe('update center routes', () => {
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
url: null,
|
||||
} as const;
|
||||
const dockerHubRecentTags = [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev',
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-29T12:54:35.591877Z',
|
||||
url: null,
|
||||
},
|
||||
] as const;
|
||||
const helperStatus = {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -161,7 +173,10 @@ describe('update center routes', () => {
|
||||
],
|
||||
} as const;
|
||||
fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease);
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue(dockerHubTag);
|
||||
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||
primary: dockerHubTag,
|
||||
recentNonStable: dockerHubRecentTags,
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus);
|
||||
|
||||
const saveResponse = await app.inject({
|
||||
@@ -216,6 +231,13 @@ describe('update center routes', () => {
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
@@ -242,11 +264,14 @@ describe('update center routes', () => {
|
||||
|
||||
it('returns partial status when a single version source lookup fails', async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.3.1',
|
||||
normalizedVersion: '1.3.1',
|
||||
url: null,
|
||||
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||
primary: {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.3.1',
|
||||
normalizedVersion: '1.3.1',
|
||||
url: null,
|
||||
},
|
||||
recentNonStable: [],
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -426,6 +451,18 @@ describe('update center routes', () => {
|
||||
publishedAt: '2026-03-31T09:00:00Z',
|
||||
url: null,
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev',
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-31T09:05:00Z',
|
||||
url: null,
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
@@ -454,6 +491,12 @@ describe('update center routes', () => {
|
||||
dockerHubTag: {
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
},
|
||||
@@ -462,7 +505,7 @@ describe('update center routes', () => {
|
||||
},
|
||||
});
|
||||
expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled();
|
||||
expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled();
|
||||
expect(fetchDockerHubTagCandidatesMock).not.toHaveBeenCalled();
|
||||
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -477,15 +520,29 @@ describe('update center routes', () => {
|
||||
publishedAt: '2026-03-31T10:00:00Z',
|
||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1',
|
||||
});
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'latest',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
displayVersion: 'latest @ sha256:dddddddddddd',
|
||||
publishedAt: '2026-03-31T10:00:00Z',
|
||||
url: null,
|
||||
fetchDockerHubTagCandidatesMock.mockResolvedValue({
|
||||
primary: {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'latest',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
displayVersion: 'latest @ sha256:dddddddddddd',
|
||||
publishedAt: '2026-03-31T10:00:00Z',
|
||||
url: null,
|
||||
},
|
||||
recentNonStable: [
|
||||
{
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: 'dev-20260417-f67ade2',
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
tagName: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:abababababababababababababababababababababababababababababababab',
|
||||
displayVersion: 'dev-20260417-f67ade2 @ sha256:abababababab',
|
||||
publishedAt: '2026-03-31T10:05:00Z',
|
||||
url: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -512,6 +569,12 @@ describe('update center routes', () => {
|
||||
dockerHubTag: {
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:abababababababababababababababababababababababababababababababab',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
revision: '13',
|
||||
},
|
||||
@@ -520,7 +583,7 @@ describe('update center routes', () => {
|
||||
},
|
||||
});
|
||||
expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchDockerHubTagCandidatesMock).toHaveBeenCalledTimes(1);
|
||||
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
||||
lastResolvedSource: 'github-release',
|
||||
@@ -532,6 +595,11 @@ describe('update center routes', () => {
|
||||
dockerHubTag: expect.objectContaining({
|
||||
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
}),
|
||||
dockerHubRecentTags: [
|
||||
expect.objectContaining({
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
}),
|
||||
],
|
||||
helper: expect.objectContaining({
|
||||
revision: '13',
|
||||
}),
|
||||
|
||||
@@ -112,10 +112,12 @@ describe('chat proxy stream behavior', () => {
|
||||
beforeAll(async () => {
|
||||
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
|
||||
const { responsesProxyRoute } = await import('./responses.js');
|
||||
const { searchProxyRoute } = await import('./search.js');
|
||||
app = Fastify();
|
||||
await app.register(chatProxyRoute);
|
||||
await app.register(claudeMessagesProxyRoute);
|
||||
await app.register(responsesProxyRoute);
|
||||
await app.register(searchProxyRoute);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -156,6 +158,7 @@ describe('chat proxy stream behavior', () => {
|
||||
filter: [],
|
||||
};
|
||||
(config as any).disableCrossProtocolFallback = false;
|
||||
(config as any).openAiServiceTierRules = undefined;
|
||||
config.proxyEmptyContentFailEnabled = false;
|
||||
config.proxyErrorKeywords = [];
|
||||
});
|
||||
@@ -193,7 +196,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('data: ');
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
@@ -233,7 +236,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json()?.choices?.[0]?.message?.content).toBe('你好,来自 zstd 非流式响应');
|
||||
});
|
||||
|
||||
@@ -268,7 +271,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
expect(response.body).toContain('你好,来自 zstd 流式回退');
|
||||
@@ -300,7 +303,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
expect(response.body).toContain('你好,来自 zstd 原生 SSE');
|
||||
@@ -513,7 +516,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('visible answer despite zero output usage');
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
@@ -563,7 +566,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.headers['cache-control']).toContain('no-transform');
|
||||
expect(response.headers['x-accel-buffering']).toBe('no');
|
||||
@@ -600,7 +603,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.body).toContain('"reasoning_content":"plan quietly"');
|
||||
expect(response.body).toContain('"content":"visible answer"');
|
||||
expect(response.body).not.toContain('<think>');
|
||||
@@ -639,7 +642,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.body).toContain('"reasoning_content":"plan "');
|
||||
expect(response.body).toContain('"reasoning_content":"quietly"');
|
||||
expect(response.body).toContain('"content":"visible "');
|
||||
@@ -710,7 +713,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
expect(response.body).toContain('"delta":{"content":"hello"}');
|
||||
@@ -777,7 +780,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body.type).toBe('message');
|
||||
expect(body.role).toBe('assistant');
|
||||
@@ -815,7 +818,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('event: message_start');
|
||||
expect(response.body).toContain('event: content_block_delta');
|
||||
@@ -2018,7 +2021,7 @@ describe('chat proxy stream behavior', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves sub2api responses semantics across compatibility retries without using a strict field-dropping body', async () => {
|
||||
it('rejects external HTTP previous_response_id before sub2api compatibility retries', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
|
||||
@@ -2076,48 +2079,9 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||
const firstBody = JSON.parse(firstOptions.body);
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
|
||||
expect(firstOptions.headers.accept).toBe('text/event-stream');
|
||||
expect(secondOptions.headers.accept).toBe('text/event-stream');
|
||||
expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
||||
expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
||||
expect(firstOptions.headers.originator).toBe('codex_cli_rs');
|
||||
expect(secondOptions.headers.originator).toBe('codex_cli_rs');
|
||||
expect(secondOptions.headers['accept-language']).toBe('zh-CN');
|
||||
expect(secondOptions.headers.session_id).toBe('session-123');
|
||||
expect(secondOptions.headers.conversation_id).toBe('conversation-123');
|
||||
expect(secondOptions.headers['x-codex-turn-state']).toBe('turn-state');
|
||||
expect(secondOptions.headers['x-codex-turn-metadata']).toBe('turn-metadata');
|
||||
expect(secondOptions.headers['x-stainless-lang']).toBeUndefined();
|
||||
expect(secondOptions.headers.version).toBeUndefined();
|
||||
expect(secondOptions.headers['user-agent']).toBe('lightMyRequest');
|
||||
expect(firstBody).toMatchObject({
|
||||
stream: true,
|
||||
store: false,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: { effort: 'high' },
|
||||
prompt_cache_key: 'cache-key-1',
|
||||
service_tier: 'priority',
|
||||
background: true,
|
||||
});
|
||||
expect(secondBody).toMatchObject({
|
||||
stream: true,
|
||||
store: false,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: { effort: 'high' },
|
||||
prompt_cache_key: 'cache-key-1',
|
||||
service_tier: 'priority',
|
||||
background: true,
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.message).toContain('HTTP /v1/responses');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries generic 400 /v1/responses with minimal headers for strict compatibility fallback', async () => {
|
||||
@@ -2863,7 +2827,7 @@ describe('chat proxy stream behavior', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
@@ -4697,6 +4661,184 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(forwardedBody.tool_choice).toEqual({ type: 'function', name: 'Glob' });
|
||||
});
|
||||
|
||||
it('forwards legacy functions/function_call when /v1/chat/completions is routed to /v1/responses upstream', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'upstream-gpt',
|
||||
supportedEndpointTypes: ['/v1/responses'],
|
||||
},
|
||||
],
|
||||
groupRatio: {},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_legacy_chat',
|
||||
object: 'response',
|
||||
model: 'upstream-gpt',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
output_text: 'ok',
|
||||
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
functions: [{
|
||||
name: 'legacy_lookup',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
function_call: { name: 'legacy_lookup' },
|
||||
messages: [
|
||||
{
|
||||
role: 'function',
|
||||
name: 'legacy_lookup',
|
||||
content: '{"ok":true}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'continue',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.tools).toEqual([
|
||||
{ type: 'function', name: 'legacy_lookup', parameters: { type: 'object' } },
|
||||
]);
|
||||
expect(forwardedBody.tool_choice).toEqual({ type: 'function', name: 'legacy_lookup' });
|
||||
expect(forwardedBody.input).toContainEqual({
|
||||
type: 'function_call_output',
|
||||
call_id: 'legacy_lookup',
|
||||
output: '{"ok":true}',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns synthetic Anthropic web_search server tool results without adding a new search dependency', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'search.result',
|
||||
data: [{ title: 'Metapi', url: 'https://example.com/metapi' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages',
|
||||
payload: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
stream: false,
|
||||
tools: [{ type: 'web_search_20250305', max_uses: 2 }],
|
||||
messages: [{ role: 'user', content: 'metapi protocol compatibility' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||
expect(JSON.parse(options.body)).toMatchObject({
|
||||
query: 'metapi protocol compatibility',
|
||||
max_results: 2,
|
||||
});
|
||||
|
||||
const body = response.json();
|
||||
expect(body.content?.[0]).toMatchObject({
|
||||
type: 'server_tool_use',
|
||||
name: 'web_search',
|
||||
});
|
||||
expect(body.content?.[1]).toMatchObject({
|
||||
type: 'web_search_tool_result',
|
||||
});
|
||||
});
|
||||
|
||||
it('streams synthetic Anthropic web_search server tool results over SSE', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'search.result',
|
||||
data: [{ title: 'Metapi SSE' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages',
|
||||
payload: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
stream: true,
|
||||
tools: [{ type: 'web_search_20250305' }],
|
||||
messages: [{ role: 'user', content: 'metapi sse search' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('"type":"server_tool_use"');
|
||||
expect(response.body).toContain('"type":"web_search_tool_result"');
|
||||
expect(response.body).toContain('message_stop');
|
||||
});
|
||||
|
||||
it('returns synthetic Responses web_search results without touching completions upstreams', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'search.result',
|
||||
data: [{ title: 'Metapi Responses', url: 'https://example.com/responses' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-4.1',
|
||||
stream: false,
|
||||
tools: [{ type: 'web_search', name: 'web_search', max_results: 3 }],
|
||||
input: 'metapi responses web search',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||
expect(JSON.parse(options.body)).toMatchObject({
|
||||
query: 'metapi responses web search',
|
||||
max_results: 3,
|
||||
});
|
||||
|
||||
const body = response.json();
|
||||
expect(body.object).toBe('response');
|
||||
expect(body.output_text).toContain('Metapi Responses');
|
||||
expect(body.output?.[0]).toMatchObject({
|
||||
type: 'web_search_call',
|
||||
status: 'completed',
|
||||
action: {
|
||||
type: 'search',
|
||||
query: 'metapi responses web search',
|
||||
},
|
||||
});
|
||||
expect(body.output?.[1]?.content?.[0]?.text).toContain('Metapi Responses');
|
||||
});
|
||||
|
||||
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
|
||||
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
|
||||
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
|
||||
(config as any).openAiServiceTierRules = undefined;
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
@@ -685,6 +686,137 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects external HTTP previous_response_id without touching upstream', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
previous_response_id: 'msg_wrong_1',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.message).toContain('msg_*');
|
||||
expect(response.json().error.message).toContain('HTTP /v1/responses');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects orphan external HTTP function_call_output but keeps codex session inference allowed', async () => {
|
||||
const rejected = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_orphan',
|
||||
output: '{"ok":true}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(rejected.statusCode).toBe(400);
|
||||
expect(rejected.json().error.message).toContain('Responses WebSocket v2');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_codex_preflight_ok',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'accepted',
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const allowed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
session_id: 'session-preflight-codex',
|
||||
'user-agent': 'CodexClient/1.0',
|
||||
},
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_codex',
|
||||
output: '{"ok":true}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('filters HTTP service_tier by selected account policy before forwarding upstream', async () => {
|
||||
(config as any).openAiServiceTierRules = [{
|
||||
action: 'filter',
|
||||
tiers: ['priority'],
|
||||
platforms: ['codex'],
|
||||
accountTypes: ['plus'],
|
||||
}];
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_codex_tier_filtered',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'tier filtered',
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
service_tier: 'fast',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.service_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks HTTP service_tier by selected account policy without touching upstream', async () => {
|
||||
(config as any).openAiServiceTierRules = [{
|
||||
action: 'block',
|
||||
tiers: ['priority'],
|
||||
platforms: ['codex'],
|
||||
accountTypes: ['plus'],
|
||||
}];
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
service_tier: 'fast',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.message).toContain('service_tier');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('infers previous_response_id for codex tool-output follow-up turns when the client only sends conversation_id', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
@@ -927,17 +1059,9 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||
const firstBody = JSON.parse(firstOptions.body);
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
|
||||
expect(firstBody.previous_response_id).toBe('resp_stale');
|
||||
expect(secondBody.previous_response_id).toBeUndefined();
|
||||
expect(secondBody.input).toEqual(firstBody.input);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.message).toContain('HTTP /v1/responses');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips generic downstream headers before forwarding codex responses upstream', async () => {
|
||||
|
||||
@@ -395,6 +395,7 @@ describe('responses websocket transport', () => {
|
||||
upstreamRequests = [];
|
||||
(config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta;
|
||||
(config as any).codexUpstreamWebsocketEnabled = true;
|
||||
(config as any).openAiServiceTierRules = undefined;
|
||||
rejectedUpgradeStatus = 426;
|
||||
rejectedUpgradeStatusText = 'Upgrade Required';
|
||||
rejectedUpgradeBody = 'Upgrade Required';
|
||||
@@ -1556,6 +1557,79 @@ describe('responses websocket transport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('applies service_tier policy to websocket frames before upstream dispatch', async () => {
|
||||
(config as any).openAiServiceTierRules = [{
|
||||
action: 'filter',
|
||||
tiers: ['priority'],
|
||||
platforms: ['openai'],
|
||||
}];
|
||||
const selectedChannel = createSelectedChannel({
|
||||
sitePlatform: 'openai',
|
||||
actualModel: 'gpt-4.1',
|
||||
});
|
||||
selectChannelMock.mockReturnValue(selectedChannel);
|
||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||
fetchMock.mockResolvedValueOnce(createSseResponse([
|
||||
'event: response.completed\n',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_ws_tier","model":"gpt-4.1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]));
|
||||
|
||||
const socket = createClientSocket(baseUrl);
|
||||
await waitForSocketOpen(socket);
|
||||
const responsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-4.1',
|
||||
service_tier: 'fast',
|
||||
input: [],
|
||||
}));
|
||||
await responsePromise;
|
||||
socket.close();
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const forwardedBody = JSON.parse(String(options.body));
|
||||
expect(forwardedBody.service_tier).toBeUndefined();
|
||||
(config as any).openAiServiceTierRules = undefined;
|
||||
});
|
||||
|
||||
it('blocks websocket service_tier before upstream dispatch', async () => {
|
||||
(config as any).openAiServiceTierRules = [{
|
||||
action: 'block',
|
||||
tiers: ['priority'],
|
||||
platforms: ['openai'],
|
||||
}];
|
||||
const selectedChannel = createSelectedChannel({
|
||||
sitePlatform: 'openai',
|
||||
actualModel: 'gpt-4.1',
|
||||
});
|
||||
selectChannelMock.mockReturnValue(selectedChannel);
|
||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||
|
||||
const socket = createClientSocket(baseUrl);
|
||||
await waitForSocketOpen(socket);
|
||||
const errorPromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'error',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-4.1',
|
||||
service_tier: 'fast',
|
||||
input: [],
|
||||
}));
|
||||
const message = await errorPromise;
|
||||
socket.close();
|
||||
|
||||
expect(message.status).toBe(400);
|
||||
expect(message.error.message).toContain('service_tier');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
(config as any).openAiServiceTierRules = undefined;
|
||||
});
|
||||
|
||||
it('keeps streamed output items for follow-up turns when the terminal HTTP fallback payload has an empty output array', async () => {
|
||||
const selectedChannel = createSelectedChannel({
|
||||
sitePlatform: 'openai',
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
|
||||
import { config } from '../../config.js';
|
||||
import { applyOpenAiServiceTierPolicy } from '../../proxy-core/serviceTierPolicy.js';
|
||||
|
||||
const installedApps = new WeakSet<FastifyInstance>();
|
||||
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
|
||||
@@ -43,6 +45,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getServiceTierPolicyRules(): unknown {
|
||||
return (config as typeof config & { openAiServiceTierRules?: unknown }).openAiServiceTierRules;
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -592,6 +598,24 @@ async function handleResponsesWebsocketConnection(
|
||||
writeResponsesWebsocketError(socket, 403, 'model is not allowed for this downstream key');
|
||||
return;
|
||||
}
|
||||
const serviceTierPolicy = applyOpenAiServiceTierPolicy({
|
||||
body: parsed,
|
||||
context: {
|
||||
requestedModel: requestModel,
|
||||
},
|
||||
rules: getServiceTierPolicyRules(),
|
||||
});
|
||||
if (!serviceTierPolicy.ok) {
|
||||
writeResponsesWebsocketError(
|
||||
socket,
|
||||
serviceTierPolicy.statusCode,
|
||||
serviceTierPolicy.payload.error.message,
|
||||
serviceTierPolicy.payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
parsed.service_tier = serviceTierPolicy.body.service_tier;
|
||||
if (serviceTierPolicy.body.service_tier === undefined) delete parsed.service_tier;
|
||||
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|
||||
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest, authContext);
|
||||
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
|
||||
@@ -614,8 +638,8 @@ async function handleResponsesWebsocketConnection(
|
||||
await consumeManagedKeyRequest(authContext.key.id);
|
||||
}
|
||||
|
||||
lastRequest = normalized.nextRequestSnapshot;
|
||||
if (shouldHandleLocalPrewarm) {
|
||||
lastRequest = normalized.nextRequestSnapshot;
|
||||
lastResponseOutput = [];
|
||||
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
|
||||
socket.send(JSON.stringify(payload));
|
||||
@@ -629,6 +653,35 @@ async function handleResponsesWebsocketConnection(
|
||||
: null;
|
||||
}
|
||||
|
||||
const selectedServiceTierPolicy = applyOpenAiServiceTierPolicy({
|
||||
body: normalized.request,
|
||||
context: {
|
||||
requestedModel: requestModel,
|
||||
actualModel: asTrimmedString(selectedChannel?.actualModel),
|
||||
sitePlatform: asTrimmedString(selectedChannel?.site?.platform),
|
||||
accountType: getOauthInfoFromAccount(selectedChannel?.account)?.planType,
|
||||
},
|
||||
rules: getServiceTierPolicyRules(),
|
||||
});
|
||||
if (!selectedServiceTierPolicy.ok) {
|
||||
writeResponsesWebsocketError(
|
||||
socket,
|
||||
selectedServiceTierPolicy.statusCode,
|
||||
selectedServiceTierPolicy.payload.error.message,
|
||||
selectedServiceTierPolicy.payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
normalized.request = selectedServiceTierPolicy.body;
|
||||
normalized.nextRequestSnapshot = {
|
||||
...normalized.nextRequestSnapshot,
|
||||
service_tier: selectedServiceTierPolicy.body.service_tier,
|
||||
};
|
||||
if (selectedServiceTierPolicy.body.service_tier === undefined) {
|
||||
delete normalized.nextRequestSnapshot.service_tier;
|
||||
}
|
||||
lastRequest = normalized.nextRequestSnapshot;
|
||||
|
||||
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
|
||||
? selectedChannel
|
||||
: null;
|
||||
|
||||
Reference in New Issue
Block a user