Fix responses compact fallback handling (#426)

* fix route drag overlay alignment

* fix(web): address route drag review comments

* fix route card review follow-up

* Fix responses compact fallback handling

* Fix compact endpoint candidate typing

* Tighten compact fallback matcher
This commit is contained in:
Cita
2026-04-05 19:41:09 +08:00
committed by GitHub
parent b3f6fa0d4f
commit df75a80bf9
10 changed files with 368 additions and 29 deletions
+25
View File
@@ -52,6 +52,7 @@ interface RuntimeSettingsBody {
systemProxyUrl?: string;
modelAvailabilityProbeEnabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
responsesCompactFallbackToResponsesEnabled?: boolean;
disableCrossProtocolFallback?: boolean;
proxySessionChannelConcurrencyLimit?: number;
proxySessionChannelQueueWaitMs?: number;
@@ -425,6 +426,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.codexUpstreamWebsocketEnabled = value;
return;
}
case 'responses_compact_fallback_to_responses_enabled': {
if (typeof value !== 'boolean') return;
config.responsesCompactFallbackToResponsesEnabled = value;
return;
}
case 'disable_cross_protocol_fallback': {
if (typeof value !== 'boolean') return;
config.disableCrossProtocolFallback = value;
@@ -711,6 +717,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
logCleanupRetentionDays: config.logCleanupRetentionDays,
modelAvailabilityProbeEnabled: config.modelAvailabilityProbeEnabled,
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
responsesCompactFallbackToResponsesEnabled: config.responsesCompactFallbackToResponsesEnabled,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs,
@@ -1166,6 +1173,24 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('codex_upstream_websocket_enabled', config.codexUpstreamWebsocketEnabled);
}
if (body.responsesCompactFallbackToResponsesEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.responsesCompactFallbackToResponsesEnabled, 'Compact 回退到 Responses 开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'Compact 回退到 Responses 开关格式无效',
});
}
if (nextValue !== config.responsesCompactFallbackToResponsesEnabled) {
changedLabels.push('Compact 不支持时回退到普通 Responses');
}
config.responsesCompactFallbackToResponsesEnabled = nextValue;
upsertSetting('responses_compact_fallback_to_responses_enabled', config.responsesCompactFallbackToResponsesEnabled);
}
if (body.disableCrossProtocolFallback !== undefined) {
let nextValue = false;
try {
@@ -1,5 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
import { resetUpstreamEndpointRuntimeState } from '../../services/upstreamEndpointRuntimeMemory.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -105,6 +107,7 @@ vi.mock('../../db/index.js', () => ({
describe('responses proxy compact upstream routing', () => {
let app: FastifyInstance;
const originalResponsesCompactFallbackToResponsesEnabled = config.responsesCompactFallbackToResponsesEnabled;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
@@ -113,6 +116,8 @@ describe('responses proxy compact upstream routing', () => {
});
beforeEach(() => {
resetUpstreamEndpointRuntimeState();
config.responsesCompactFallbackToResponsesEnabled = false;
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
@@ -129,7 +134,7 @@ describe('responses proxy compact upstream routing', () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
site: { id: 44, name: 'demo-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
@@ -140,6 +145,7 @@ describe('responses proxy compact upstream routing', () => {
});
afterAll(async () => {
config.responsesCompactFallbackToResponsesEnabled = originalResponsesCompactFallbackToResponsesEnabled;
if (app) {
await app.close();
}
@@ -171,6 +177,193 @@ describe('responses proxy compact upstream routing', () => {
expect(targetUrl).toContain('/v1/responses/compact');
});
it('keeps native compact as the default even for generic openai-compatible upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'Invalid URL (POST /v1/responses/compact)',
type: 'invalid_request_error',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(404);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses/compact');
});
it('optionally falls back from compact to ordinary responses when compact is explicitly unsupported', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'Invalid URL (POST /v1/responses/compact)',
type: 'invalid_request_error',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_123',
object: 'response',
output_text: 'hello from fallback',
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact');
expect(response.json()).toMatchObject({
object: 'response.compaction',
});
});
it('does not fall back for unrelated unsupported errors that do not mention compact', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'Model not supported for this account',
type: 'invalid_request_error',
},
}), {
status: 422,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_ignored',
object: 'response',
output_text: 'should not fallback',
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(422);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
});
it('strips stream fields when forwarding compact requests to codex upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
account: {
id: 33,
username: 'codex-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'gpt-5.4',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'cmp_123',
object: 'response.compaction',
input_tokens: 4,
output_tokens: 2,
total_tokens: 6,
output: [
{
id: 'rs_123',
type: 'compaction',
encrypted_content: 'enc-compact-payload',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.4',
input: 'hello',
stream_options: { include_obfuscation: true },
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/responses/compact');
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.stream).toBeUndefined();
expect(forwardedBody.stream_options).toBeUndefined();
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false);
});
it('preserves native response.compaction payloads instead of coercing them into object=response', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'cmp_123',