1
0
forked from boos/BoosAPI

feat: enhance OAuthManagement with improved provider handling and user feedback

This commit is contained in:
ciat-777
2026-03-17 20:17:04 +08:00
parent eb0c5475ca
commit 07351b527f
3 changed files with 405 additions and 13 deletions
@@ -89,8 +89,9 @@ describe('chat proxy codex oauth compatibility', () => {
}; };
beforeAll(async () => { beforeAll(async () => {
const { claudeMessagesProxyRoute } = await import('./chat.js'); const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
app = Fastify(); app = Fastify();
await app.register(chatProxyRoute);
await app.register(claudeMessagesProxyRoute); await app.register(claudeMessagesProxyRoute);
}); });
@@ -176,4 +177,72 @@ describe('chat proxy codex oauth compatibility', () => {
expect(body.content?.[0]?.type).toBe('text'); expect(body.content?.[0]?.type).toBe('text');
expect(body.content?.[0]?.text).toContain('pong from codex'); expect(body.content?.[0]?.text).toContain('pong from codex');
}); });
it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.content_part.added\n',
'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n',
'event: response.content_part.done\n',
'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'gpt-5.4',
stream: true,
max_tokens: 256,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).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');
expect(response.body).toContain('pong from codex');
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.4',
stream: true,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('"object":"chat.completion.chunk"');
expect(response.body).toContain('pong from codex');
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
}); });
+283
View File
@@ -374,6 +374,289 @@ describe('OAuthManagement page', () => {
} }
}); });
it('explains oauth usage and disables unavailable providers', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: false,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('官方上游连接');
expect(text).toContain('CLI');
expect(text).toContain('下游密钥');
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('当前不可用')
));
expect(startButton.props.disabled).toBe(true);
expect(collectText(root!.root)).toContain('当前环境未启用');
} finally {
root?.unmount();
}
});
it('prefers renamed account title and still shows oauth email', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 7,
provider: 'codex',
username: 'Juricek Team A',
email: 'juricek.chen@gmail.com',
accountKey: 'chatgpt-account-123',
planType: 'team',
modelCount: 11,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeChannelCount: 11,
},
],
total: 1,
limit: 100,
offset: 0,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('Juricek Team A');
expect(text).toContain('juricek.chen@gmail.com');
});
} finally {
root?.unmount();
}
});
it('allows starting gemini oauth without entering a project id', async () => {
promptMock.mockReturnValueOnce('');
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
apiMock.startOAuthProvider.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-state-123',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
callbackPath: '/oauth2callback',
manualCallbackDelayMs: 15000,
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-state-123',
status: 'pending',
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('连接 Gemini CLI')
));
await act(async () => {
await startButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(promptMock).toHaveBeenCalled();
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('gemini-cli', { projectId: undefined });
expect(openMock).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
'oauth-gemini-cli',
expect.stringContaining('width=540'),
);
expect(collectText(root!.root)).not.toContain('Gemini CLI 连接需要 Project ID');
} finally {
root?.unmount();
}
});
it('rebinds gemini oauth without prompting for a project id again', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 11,
provider: 'gemini-cli',
email: 'gemini-user@example.com',
planType: 'cloud',
modelCount: 2,
modelsPreview: ['gemini-2.5-pro', 'gemini-2.5-flash'],
status: 'healthy',
projectId: 'project-demo',
routeChannelCount: 0,
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.rebindOAuthConnection.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-123',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
callbackPath: '/oauth2callback',
manualCallbackDelayMs: 15000,
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-123',
status: 'pending',
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
const rebindButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('重新授权')
));
await act(async () => {
await rebindButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(promptMock).not.toHaveBeenCalled();
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11);
expect(openMock).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
'oauth-gemini-cli',
expect.stringContaining('width=540'),
);
} finally {
root?.unmount();
}
});
it('shows oauth connection status metadata and allows deleting a connection', async () => { it('shows oauth connection status metadata and allows deleting a connection', async () => {
apiMock.getOAuthProviders.mockResolvedValue({ apiMock.getOAuthProviders.mockResolvedValue({
providers: [ providers: [
+52 -12
View File
@@ -34,6 +34,29 @@ function resolveConnectionStatusLabel(status?: string): string {
return status === 'abnormal' ? '异常' : '正常'; return status === 'abnormal' ? '异常' : '正常';
} }
function asTrimmedString(value: string | null | undefined): string {
return typeof value === 'string' ? value.trim() : '';
}
function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string {
if (loading) return '启动中...';
if (!provider.enabled) return '当前不可用';
return `连接 ${provider.label}`;
}
function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string {
return asTrimmedString(connection.username)
|| asTrimmedString(connection.email)
|| asTrimmedString(connection.provider)
|| 'OAuth 连接';
}
function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
const email = asTrimmedString(connection.email);
if (!email) return '';
return email;
}
function renderCodeBlock(value: string) { function renderCodeBlock(value: string) {
return ( return (
<code style={{ <code style={{
@@ -172,20 +195,21 @@ export default function OAuthManagement() {
}, [activeSession]); }, [activeSession]);
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => { const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
if (!provider.enabled) {
setSessionMessage(`${provider.label} 当前环境未启用`);
return;
}
const actionKey = `start:${provider.provider}:${accountId || 0}`; const actionKey = `start:${provider.provider}:${accountId || 0}`;
setActionLoadingKey(actionKey); setActionLoadingKey(actionKey);
try { try {
const projectId = provider.requiresProjectId const shouldPromptForProjectId = provider.requiresProjectId && !accountId;
const projectId = shouldPromptForProjectId
? (() => { ? (() => {
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined; if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
const value = window.prompt('输入 Google Cloud Project ID'); const value = window.prompt('输入 Google Cloud Project ID(可选,留空则自动解析)');
return typeof value === 'string' && value.trim() ? value.trim() : undefined; return typeof value === 'string' && value.trim() ? value.trim() : undefined;
})() })()
: undefined; : undefined;
if (provider.requiresProjectId && !projectId && !accountId) {
setSessionMessage('Gemini CLI 连接需要 Project ID');
return;
}
const started = accountId const started = accountId
? await api.rebindOAuthConnection(accountId) ? await api.rebindOAuthConnection(accountId)
: await api.startOAuthProvider(provider.provider, { projectId }); : await api.startOAuthProvider(provider.provider, { projectId });
@@ -245,7 +269,9 @@ export default function OAuthManagement() {
<div className="page-header"> <div className="page-header">
<div> <div>
<div className="page-title">OAuth 管理</div> <div className="page-title">OAuth 管理</div>
<div className="page-subtitle">统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。</div> <div className="page-subtitle">
统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。
</div>
</div> </div>
</div> </div>
@@ -348,26 +374,33 @@ export default function OAuthManagement() {
<div className="card" style={{ padding: 20, marginBottom: 16 }}> <div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div> <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
适合把官方 CLI / Web 登录得到的账号统一接入 metapi,再供各种 CLI、SDK、下游密钥和模型操练场复用。
</div>
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
{providers.map((provider) => { {providers.map((provider) => {
const actionKey = `start:${provider.provider}:0`; const actionKey = `start:${provider.provider}:0`;
const disabled = !provider.enabled || actionLoadingKey === actionKey;
return ( return (
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div> <div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div> <div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{provider.platform} {provider.platform}
{provider.requiresProjectId ? ' · 需要 Project ID' : ''} {provider.requiresProjectId ? ' · 可选 Project ID' : ''}
{provider.supportsNativeProxy ? ' · 原生代理' : ''} {provider.supportsNativeProxy ? ' · 原生代理' : ''}
{!provider.enabled ? ' · 当前环境未启用' : ''}
</div> </div>
</div> </div>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={() => handleStart(provider)} onClick={() => handleStart(provider)}
disabled={actionLoadingKey === actionKey} disabled={disabled}
title={!provider.enabled ? `${provider.label} 当前环境未启用` : undefined}
style={!provider.enabled ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
> >
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`} {resolveProviderActionLabel(provider, actionLoadingKey === actionKey)}
</button> </button>
</div> </div>
); );
@@ -384,13 +417,20 @@ export default function OAuthManagement() {
{connections.map((connection) => { {connections.map((connection) => {
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`; const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
const deleteActionKey = `delete:${connection.accountId}`; const deleteActionKey = `delete:${connection.accountId}`;
const primaryTitle = resolveConnectionPrimaryTitle(connection);
const emailLabel = resolveConnectionEmailLabel(connection);
return ( return (
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}> <div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{connection.email || connection.username || connection.provider}</div> <div style={{ fontSize: 14, fontWeight: 600 }}>{primaryTitle}</div>
{emailLabel && emailLabel !== primaryTitle && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
授权邮箱: {emailLabel}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{connection.planType || 'unknown'} · {connection.modelCount} 个模型 {connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
</div> </div>
{connection.accountKey && ( {connection.accountKey && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>