forked from boos/BoosAPI
[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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user