feat: enhance OAuthManagement with improved provider handling and user feedback
This commit is contained in:
@@ -89,8 +89,9 @@ describe('chat proxy codex oauth compatibility', () => {
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const { claudeMessagesProxyRoute } = await import('./chat.js');
|
||||
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
|
||||
app = Fastify();
|
||||
await app.register(chatProxyRoute);
|
||||
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]?.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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
|
||||
@@ -34,6 +34,29 @@ function resolveConnectionStatusLabel(status?: string): string {
|
||||
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) {
|
||||
return (
|
||||
<code style={{
|
||||
@@ -172,20 +195,21 @@ export default function OAuthManagement() {
|
||||
}, [activeSession]);
|
||||
|
||||
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||
if (!provider.enabled) {
|
||||
setSessionMessage(`${provider.label} 当前环境未启用`);
|
||||
return;
|
||||
}
|
||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
try {
|
||||
const projectId = provider.requiresProjectId
|
||||
const shouldPromptForProjectId = provider.requiresProjectId && !accountId;
|
||||
const projectId = shouldPromptForProjectId
|
||||
? (() => {
|
||||
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;
|
||||
})()
|
||||
: undefined;
|
||||
if (provider.requiresProjectId && !projectId && !accountId) {
|
||||
setSessionMessage('Gemini CLI 连接需要 Project ID');
|
||||
return;
|
||||
}
|
||||
const started = accountId
|
||||
? await api.rebindOAuthConnection(accountId)
|
||||
: await api.startOAuthProvider(provider.provider, { projectId });
|
||||
@@ -245,7 +269,9 @@ export default function OAuthManagement() {
|
||||
<div className="page-header">
|
||||
<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>
|
||||
|
||||
@@ -348,26 +374,33 @@ export default function OAuthManagement() {
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<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 }}>
|
||||
{providers.map((provider) => {
|
||||
const actionKey = `start:${provider.provider}:0`;
|
||||
const disabled = !provider.enabled || actionLoadingKey === actionKey;
|
||||
return (
|
||||
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{provider.platform}
|
||||
{provider.requiresProjectId ? ' · 需要 Project ID' : ''}
|
||||
{provider.requiresProjectId ? ' · 可选 Project ID' : ''}
|
||||
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
|
||||
{!provider.enabled ? ' · 当前环境未启用' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -384,13 +417,20 @@ export default function OAuthManagement() {
|
||||
{connections.map((connection) => {
|
||||
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
||||
const deleteActionKey = `delete:${connection.accountId}`;
|
||||
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
||||
const emailLabel = resolveConnectionEmailLabel(connection);
|
||||
return (
|
||||
<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={{ 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 }}>
|
||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||
授权邮箱: {emailLabel}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||
</div>
|
||||
{connection.accountKey && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
|
||||
Reference in New Issue
Block a user