diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts index 7b7ab1a..60f6bc1 100644 --- a/src/server/routes/proxy/chat.codex-oauth.test.ts +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -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"'); + }); }); diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index 689bc23..296c65b 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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: [ diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index d6fd9bb..7fde182 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -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 ( { + 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() {
OAuth 管理
-
统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。
+
+ 统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。 +
@@ -348,26 +374,33 @@ export default function OAuthManagement() {
授权入口
+
+ 适合把官方 CLI / Web 登录得到的账号统一接入 metapi,再供各种 CLI、SDK、下游密钥和模型操练场复用。 +
{providers.map((provider) => { const actionKey = `start:${provider.provider}:0`; + const disabled = !provider.enabled || actionLoadingKey === actionKey; return (
{provider.label}
{provider.platform} - {provider.requiresProjectId ? ' · 需要 Project ID' : ''} + {provider.requiresProjectId ? ' · 可选 Project ID' : ''} {provider.supportsNativeProxy ? ' · 原生代理' : ''} + {!provider.enabled ? ' · 当前环境未启用' : ''}
); @@ -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 (
-
{connection.email || connection.username || connection.provider}
+
{primaryTitle}
+ {emailLabel && emailLabel !== primaryTitle && ( +
+ 授权邮箱: {emailLabel} +
+ )}
- {connection.planType || 'unknown'} · {connection.modelCount} 个模型 + {connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
{connection.accountKey && (