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:
cita-777
2026-05-08 16:51:05 +08:00
38 changed files with 2466 additions and 124 deletions
@@ -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',
+2 -2
View File
@@ -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(
{
+89 -21
View File
@@ -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',
}),
+197 -55
View File
@@ -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',
+54 -1
View File
@@ -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;