[codex] HTTP-first Codex upstream websocket settings (#280)

* http-first codex upstream websocket settings

* address websocket runtime review comments

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
Cita
2026-03-25 21:01:01 +08:00
committed by GitHub
parent 5d2596285b
commit 7e1fb8eb1b
14 changed files with 677 additions and 86 deletions
+108
View File
@@ -49,6 +49,9 @@ type RuntimeSettings = {
logCleanupUsageLogsEnabled: boolean;
logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number;
codexUpstreamWebsocketEnabled: boolean;
proxySessionChannelConcurrencyLimit: number;
proxySessionChannelQueueWaitMs: number;
routingFallbackUnitCost: number;
routingWeights: RoutingWeights;
systemProxyUrl: string;
@@ -190,6 +193,9 @@ export default function Settings() {
logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
codexUpstreamWebsocketEnabled: false,
proxySessionChannelConcurrencyLimit: 2,
proxySessionChannelQueueWaitMs: 1500,
routingFallbackUnitCost: 1,
routingWeights: defaultWeights,
systemProxyUrl: '',
@@ -204,6 +210,7 @@ export default function Settings() {
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
@@ -412,6 +419,13 @@ export default function Settings() {
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30,
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
? Math.trunc(Number(runtimeInfo.proxySessionChannelConcurrencyLimit))
: 2,
proxySessionChannelQueueWaitMs: Number(runtimeInfo.proxySessionChannelQueueWaitMs) >= 0
? Math.trunc(Number(runtimeInfo.proxySessionChannelQueueWaitMs))
: 1500,
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
? Number(runtimeInfo.routingFallbackUnitCost)
: 1,
@@ -569,6 +583,34 @@ export default function Settings() {
}
};
const saveProxyTransportSettings = async () => {
setSavingProxyTransport(true);
try {
const res = await api.updateRuntimeSettings({
codexUpstreamWebsocketEnabled: runtime.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: runtime.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: runtime.proxySessionChannelQueueWaitMs,
});
setRuntime((prev) => ({
...prev,
codexUpstreamWebsocketEnabled: typeof res?.codexUpstreamWebsocketEnabled === 'boolean'
? res.codexUpstreamWebsocketEnabled
: prev.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: Number(res?.proxySessionChannelConcurrencyLimit) >= 0
? Math.trunc(Number(res.proxySessionChannelConcurrencyLimit))
: prev.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: Number(res?.proxySessionChannelQueueWaitMs) >= 0
? Math.trunc(Number(res.proxySessionChannelQueueWaitMs))
: prev.proxySessionChannelQueueWaitMs,
}));
toast.success('传输与会话并发设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingProxyTransport(false);
}
};
const testSystemProxy = async () => {
const proxyUrl = runtime.systemProxyUrl.trim();
if (!proxyUrl) {
@@ -1220,6 +1262,72 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>Codex 上游传输与会话并发</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.7 }}>
默认采用 HTTP 优先。只有这里开启后,metapi 才会在 Codex 请求上尝试把上游升级为 WebSocket。
下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.7 }}>
从旧版本升级时,原先账号 `extraConfig.websockets` 的行为不再单独生效;现在统一以这里的全局设置和下游客户端是否开启为准。
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 14 }}>
<input
type="checkbox"
checked={runtime.codexUpstreamWebsocketEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, codexUpstreamWebsocketEnabled: e.target.checked }))}
/>
允许 metapi 到 Codex 上游使用 WebSocket
</label>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12, marginBottom: 10 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>会话通道并发上限</div>
<input
type="number"
min={0}
value={runtime.proxySessionChannelConcurrencyLimit}
onChange={(e) => {
const nextValue = Number(e.target.value);
setRuntime((prev) => ({
...prev,
proxySessionChannelConcurrencyLimit: Number.isFinite(nextValue) && nextValue >= 0
? Math.trunc(nextValue)
: prev.proxySessionChannelConcurrencyLimit,
}));
}}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>排队等待时间(毫秒)</div>
<input
type="number"
min={0}
step={100}
value={runtime.proxySessionChannelQueueWaitMs}
onChange={(e) => {
const nextValue = Number(e.target.value);
setRuntime((prev) => ({
...prev,
proxySessionChannelQueueWaitMs: Number.isFinite(nextValue) && nextValue >= 0
? Math.trunc(nextValue)
: prev.proxySessionChannelQueueWaitMs,
}));
}}
style={inputStyle}
/>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.7 }}>
这组 lease 只作用于能识别稳定 `session_id` 的会话型请求;没有稳定会话标识的普通请求不会进入这个池。
</div>
<div>
<button onClick={saveProxyTransportSettings} disabled={savingProxyTransport} className="btn btn-primary">
{savingProxyTransport ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存传输与并发'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Settings proxy transport', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
codexUpstreamWebsocketEnabled: false,
proxySessionChannelConcurrencyLimit: 4,
proxySessionChannelQueueWaitMs: 3200,
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
codexUpstreamWebsocketEnabled: true,
proxySessionChannelConcurrencyLimit: 6,
proxySessionChannelQueueWaitMs: 4200,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('saves codex upstream websocket and session lease settings from the settings page', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const websocketToggleLabel = root.root.find((node) => (
node.type === 'label'
&& collectText(node).includes('允许 metapi 到 Codex 上游使用 WebSocket')
));
const websocketToggle = websocketToggleLabel.findByType('input');
expect(websocketToggle.props.checked).toBe(false);
const concurrencyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'number'
&& node.props.value === 4
));
const queueWaitInput = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'number'
&& node.props.value === 3200
));
await act(async () => {
websocketToggle.props.onChange({ target: { checked: true } });
concurrencyInput.props.onChange({ target: { value: '6' } });
queueWaitInput.props.onChange({ target: { value: '4200' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存传输与并发'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
codexUpstreamWebsocketEnabled: true,
proxySessionChannelConcurrencyLimit: 6,
proxySessionChannelQueueWaitMs: 4200,
});
} finally {
root?.unmount();
}
});
});