diff --git a/.gitignore b/.gitignore index 2e8c6f1..c3f8def 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules/ data/ test/ config.yaml -camoufox/ \ No newline at end of file +camoufox/ +.env +e2e/.env \ No newline at end of file diff --git a/e2e/api.test.js b/e2e/api.test.js new file mode 100644 index 0000000..60e63ce --- /dev/null +++ b/e2e/api.test.js @@ -0,0 +1,380 @@ +/** + * WebAI2API HTTP API E2E 测试 + * @description 测试 OpenAI 兼容 API:认证、模型列表、流式/非流式生成 + * @note 需要服务已启动,配置在 e2e/.env + */ + +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// --- 加载 .env 配置 --- + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const envPath = path.join(__dirname, '.env'); +const envContent = fs.readFileSync(envPath, 'utf-8'); +const env = {}; +for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); +} + +const BASE_URL = env.API_BASE_URL || 'http://localhost:9330'; +const AUTH_TOKEN = env.API_AUTH_TOKEN || ''; +const MODEL = env.API_MODEL || 'deepseek_text/deepseek-v4-flash'; + +// --- 辅助函数 --- + +/** 发起 API 请求 */ +async function apiRequest(path, options = {}) { + const url = `${BASE_URL}${path}`; + const headers = { + 'Content-Type': 'application/json', + ...(AUTH_TOKEN ? { 'Authorization': `Bearer ${AUTH_TOKEN}` } : {}), + ...options.headers, + }; + return fetch(url, { ...options, headers }); +} + +/** 解析 Chat Completions SSE 文本为 chunk 数组 */ +function parseSSE(body) { + const chunks = []; + for (const line of body.split('\n')) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (data === '[DONE]') { + chunks.push({ type: 'done' }); + } else { + try { + chunks.push({ type: 'chunk', data: JSON.parse(data) }); + } catch { /* 忽略非 JSON 行 */ } + } + } + return chunks; +} + +/** 解析 Responses API SSE 文本为 event 数组 */ +function parseResponsesSSE(body) { + const events = []; + const lines = body.split('\n'); + let currentEvent = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + try { + events.push({ event: currentEvent, data: JSON.parse(data) }); + } catch { /* 忽略非 JSON 行 */ } + currentEvent = ''; + } + } + return events; +} + +// --- 测试 --- + +test.describe('API 认证', () => { + test('无 token 时返回 401', async () => { + const resp = await fetch(`${BASE_URL}/v1/models`); + expect(resp.status).toBe(401); + const body = await resp.json(); + expect(body.error).toBeTruthy(); + expect(body.error.type).toBe('invalid_request_error'); + }); + + test('错误 token 时返回 401', async () => { + const resp = await fetch(`${BASE_URL}/v1/models`, { + headers: { 'Authorization': 'Bearer wrong-token' }, + }); + expect(resp.status).toBe(401); + }); +}); + +test.describe('GET /v1/models', () => { + test('返回正确的列表格式', async () => { + const resp = await apiRequest('/v1/models'); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.object).toBe('list'); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThan(0); + }); + + test('模型包含必要字段', async () => { + const resp = await apiRequest('/v1/models'); + const body = await resp.json(); + for (const model of body.data) { + expect(model.id).toBeTruthy(); + expect(model.object).toBe('model'); + expect(model.owned_by).toBeTruthy(); + } + }); + + test(`配置的模型 ${MODEL} 在列表中`, async () => { + const resp = await apiRequest('/v1/models'); + const body = await resp.json(); + const found = body.data.some(m => m.id === MODEL); + expect(found).toBe(true); + }); +}); + +test.describe('POST /v1/chat/completions', () => { + test('非流式请求返回正确格式', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: 'Reply with exactly: hello' }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + + // OpenAI 格式验证 + expect(body.id).toBeTruthy(); + expect(body.object).toBe('chat.completion'); + expect(body.model).toBe(MODEL); + expect(Array.isArray(body.choices)).toBe(true); + expect(body.choices.length).toBe(1); + + const choice = body.choices[0]; + expect(choice.index).toBe(0); + expect(choice.message.role).toBe('assistant'); + expect(choice.message.content).toBeTruthy(); + expect(choice.finish_reason).toBe('stop'); + + console.log(`[非流式] 回复: ${choice.message.content.slice(0, 80)}`); + }); + + test('流式请求返回 SSE 并最终完成', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: 'Reply with exactly: hi' }], + stream: true, + }), + }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toContain('text/event-stream'); + + // 读取完整响应后解析 SSE + const body = await resp.text(); + const chunks = parseSSE(body); + + // 至少有一个 data chunk 和一个 [DONE] + const dataChunks = chunks.filter(c => c.type === 'chunk'); + const doneChunks = chunks.filter(c => c.type === 'done'); + expect(dataChunks.length).toBeGreaterThan(0); + expect(doneChunks.length).toBe(1); + + // 提取所有文本内容 + const textParts = dataChunks + .filter(c => c.data.choices?.[0]?.delta?.content) + .map(c => c.data.choices[0].delta.content); + const fullText = textParts.join(''); + expect(fullText.length).toBeGreaterThan(0); + + // 验证 chunk 格式 + expect(dataChunks[0].data.object).toBe('chat.completion.chunk'); + expect(dataChunks[0].data.model).toBe(MODEL); + + console.log(`[流式] 回复 (${dataChunks.length} chunks): ${fullText.slice(0, 80)}`); + }); + + test('无效模型返回 400', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: 'nonexistent-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBeTruthy(); + expect(body.error.type).toBe('invalid_request_error'); + }); + + test('空消息返回 400', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [], + }), + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBeTruthy(); + }); +}); + +test.describe('POST /v1/responses', () => { + test('非流式请求返回正确格式', async () => { + const resp = await apiRequest('/v1/responses', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + input: [{ role: 'user', content: 'Reply with exactly: hello' }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + + // Responses API 格式验证 + expect(body.id).toBeTruthy(); + expect(body.id.startsWith('resp_')).toBe(true); + expect(body.object).toBe('response'); + expect(body.status).toBe('completed'); + expect(body.model).toBe(MODEL); + expect(Array.isArray(body.output)).toBe(true); + expect(body.output.length).toBeGreaterThan(0); + + // 最后一个 output 应该是 message 类型 + const message = body.output.find(o => o.type === 'message'); + expect(message).toBeTruthy(); + expect(message.role).toBe('assistant'); + expect(message.content[0].type).toBe('output_text'); + expect(message.content[0].text).toBeTruthy(); + + console.log(`[Responses 非流式] 回复: ${message.content[0].text.slice(0, 80)}`); + }); + + test('流式请求返回 SSE 并最终完成', async () => { + const resp = await apiRequest('/v1/responses', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + input: [{ role: 'user', content: 'Reply with exactly: hi' }], + stream: true, + }), + }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toContain('text/event-stream'); + + // 读取完整响应后解析 SSE + const body = await resp.text(); + const events = parseResponsesSSE(body); + + // 应该有 response.created 事件 + const created = events.find(e => e.event === 'response.created'); + expect(created).toBeTruthy(); + + // 应该有 delta 事件 + const deltas = events.filter(e => e.event === 'response.output_text.delta'); + expect(deltas.length).toBeGreaterThan(0); + const fullText = deltas.map(e => e.data.delta).join(''); + expect(fullText.length).toBeGreaterThan(0); + + // 应该有 response.completed 事件 + const completed = events.find(e => e.event === 'response.completed'); + expect(completed).toBeTruthy(); + expect(completed.data.response.status).toBe('completed'); + + console.log(`[Responses 流式] 回复 (${deltas.length} deltas): ${fullText.slice(0, 80)}`); + }); +}); + +test.describe('持续性对话', () => { + test('连续多次请求均成功(继续同一对话)', async () => { + const prompts = [ + 'Reply with exactly: first', + 'Reply with exactly: second', + 'Reply with exactly: third', + ]; + + for (let i = 0; i < prompts.length; i++) { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: prompts[i] }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.object).toBe('chat.completion'); + expect(body.choices[0].message.content).toBeTruthy(); + + console.log(`[持续对话 ${i + 1}/${prompts.length}] 回复: ${body.choices[0].message.content.slice(0, 80)}`); + } + }); + + test('对话上下文连续性(模型记住之前的消息)', async () => { + // 第一轮:告诉模型一个唯一标识 + const uniqueId = `unicorn_${Date.now()}`; + const resp1 = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: `Remember this word: ${uniqueId}. Reply with exactly: remembered` }], + stream: false, + }), + }); + expect(resp1.status).toBe(200); + const body1 = await resp1.json(); + console.log(`[上下文 1/2] 回复: ${body1.choices[0].message.content.slice(0, 80)}`); + + // 第二轮:问模型之前记住的词(不重复提示) + const resp2 = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: 'What word did I ask you to remember? Reply with only the word.' }], + stream: false, + }), + }); + expect(resp2.status).toBe(200); + const body2 = await resp2.json(); + const reply = body2.choices[0].message.content; + console.log(`[上下文 2/2] 回复: ${reply.slice(0, 80)}`); + + // 验证模型记住了之前的词 + expect(reply.toLowerCase()).toContain(uniqueId.toLowerCase()); + }); + + test('Responses API 连续多次请求均成功', async () => { + const prompts = [ + 'Reply with exactly: alpha', + 'Reply with exactly: beta', + ]; + + for (let i = 0; i < prompts.length; i++) { + const resp = await apiRequest('/v1/responses', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + input: [{ role: 'user', content: prompts[i] }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.object).toBe('response'); + expect(body.status).toBe('completed'); + + const message = body.output.find(o => o.type === 'message'); + expect(message).toBeTruthy(); + expect(message.content[0].text).toBeTruthy(); + + console.log(`[Responses 持续 ${i + 1}/${prompts.length}] 回复: ${message.content[0].text.slice(0, 80)}`); + } + }); +}); diff --git a/e2e/deepseek.test.js b/e2e/deepseek.test.js new file mode 100644 index 0000000..805df03 --- /dev/null +++ b/e2e/deepseek.test.js @@ -0,0 +1,369 @@ +/** + * DeepSeek 适配器 E2E 测试 + * @description 测试 DeepSeek V4 界面交互:模式切换、功能按钮、文本生成 + * @note 自动检测界面语言,兼容中英文 + */ + +import { test, expect } from '@playwright/test'; +import { manifest } from '../src/backend/adapter/deepseek_text.js'; + +const TARGET_URL = 'https://chat.deepseek.com/'; + +// --- 辅助函数 --- + +/** 检测页面 locale 并返回对应的名称映射 */ +async function detectLocale(page) { + const ph = await page.locator('textarea').getAttribute('placeholder'); + const isZh = ph?.includes('给 DeepSeek') ?? false; + return { + isZh, + modeQuick: isZh ? '快速模式' : 'Instant', + modeExpert: isZh ? '专家模式' : 'Expert', + btnThinking: isZh ? '深度思考' : 'DeepThink', + btnSearch: isZh ? '智能搜索' : 'Search', + }; +} + +/** 按名称列表查找 radio (兼容中英文) */ +async function findRadio(page, names) { + for (const name of names) { + const loc = page.getByRole('radio', { name }); + if (await loc.count() > 0) return loc; + } + return null; +} + +/** 按名称列表查找 button (兼容中英文) */ +async function findButton(page, names) { + for (const name of names) { + const loc = page.getByRole('button', { name }); + if (await loc.count() > 0) return loc; + } + return null; +} + +/** 解析 SSE 响应中的文本和思考内容 (完整版,兼容所有 DeepSeek SSE 格式) */ +function parseSSEResponse(body) { + let text = '', thinking = ''; + let isCollecting = false, isCollectingThinking = false; + + for (const line of body.split('\n')) { + if (line.startsWith('event:') || !line.startsWith('data:')) continue; + const dataStr = line.slice(5).trim(); + if (!dataStr || dataStr === '{}') continue; + try { + const data = JSON.parse(dataStr); + + // 处理 fragment 列表 + const processFragment = (fragment) => { + if (fragment.type === 'RESPONSE') { + isCollecting = true; isCollectingThinking = false; + if (fragment.content) text += fragment.content; + } else if (fragment.type === 'THINK') { + isCollectingThinking = true; isCollecting = false; + if (fragment.content) thinking += fragment.content; + } else { + isCollecting = false; isCollectingThinking = false; + } + }; + + // 初始响应中的 fragments + if (data.v?.response?.fragments && Array.isArray(data.v.response.fragments)) { + data.v.response.fragments.forEach(processFragment); + } + + // fragments APPEND + if (data.p === 'response/fragments' && data.o === 'APPEND' && Array.isArray(data.v)) { + data.v.forEach(processFragment); + } + + // BATCH 操作 + if (data.o === 'BATCH' && data.p === 'response' && Array.isArray(data.v)) { + for (const item of data.v) { + if (item.p === 'fragments' && item.o === 'APPEND' && Array.isArray(item.v)) { + item.v.forEach(processFragment); + } + } + } + + // 路径式内容追加 (response/fragments/-1/content) + if (data.p && typeof data.v === 'string') { + const match = data.p.match(/response\/fragments\/(-?\d+)\/content/); + if (match) { + if (isCollecting) text += data.v; + else if (isCollectingThinking) thinking += data.v; + } + } + + // 纯文本追加 + if (data.v && typeof data.v === 'string' && !data.p && !data.o) { + if (isCollecting) text += data.v; + else if (isCollectingThinking) thinking += data.v; + } + } catch { /* 忽略 */ } + } + return { text, thinking }; +} + +// --- Manifest 结构测试 (无需浏览器) --- + +test.describe('Manifest 结构验证', () => { + test('manifest 包含必要字段', () => { + expect(manifest.id).toBe('deepseek_text'); + expect(manifest.displayName).toBeTruthy(); + expect(manifest.description).toBeTruthy(); + expect(typeof manifest.generate).toBe('function'); + expect(Array.isArray(manifest.models)).toBe(true); + expect(manifest.models.length).toBe(8); + }); + + test('所有模型包含必要属性', () => { + for (const model of manifest.models) { + expect(model.id).toBeTruthy(); + expect(model.imagePolicy).toBe('forbidden'); + } + }); + + test('快速模式模型 (deepseek-v4-flash)', () => { + const flashModels = manifest.models.filter(m => m.id.startsWith('deepseek-v4-flash')); + expect(flashModels.length).toBe(4); + for (const m of flashModels) { + expect(m.expert).toBeUndefined(); + } + }); + + test('专家模式模型 (deepseek-v4-pro)', () => { + const proModels = manifest.models.filter(m => m.id.startsWith('deepseek-v4-pro')); + expect(proModels.length).toBe(4); + for (const m of proModels) { + expect(m.expert).toBe(true); + } + }); + + test('thinking/search 模型配置正确', () => { + expect(manifest.models.filter(m => m.thinking).length).toBe(4); + expect(manifest.models.filter(m => m.search).length).toBe(4); + }); + + test('getTargetUrl 返回正确 URL', () => { + expect(manifest.getTargetUrl()).toBe(TARGET_URL); + }); +}); + +// --- 页面交互 E2E 测试 --- + +test.describe('DeepSeek 页面交互', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + }); + + test('页面加载后显示模式选择器', async ({ page }) => { + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(quickMode).not.toBeNull(); + expect(expertMode).not.toBeNull(); + }); + + test('页面加载后显示功能按钮', async ({ page }) => { + const thinkingBtn = await findButton(page, ['深度思考', 'DeepThink']); + const searchBtn = await findButton(page, ['智能搜索', 'Search']); + expect(thinkingBtn).not.toBeNull(); + expect(searchBtn).not.toBeNull(); + }); + + test('页面加载后显示输入框', async ({ page }) => { + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible(); + const ph = await textarea.getAttribute('placeholder'); + expect(ph).toBeTruthy(); + }); + + test('默认选中快速模式', async ({ page }) => { + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + expect(quickMode).not.toBeNull(); + await expect(quickMode).toBeChecked(); + }); +}); + +// --- 模式切换测试 --- + +test.describe('模式切换', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + }); + + test('切换到专家模式', async ({ page }) => { + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(expertMode).not.toBeNull(); + await expertMode.click(); + await expect(expertMode).toBeChecked(); + }); + + test('切换回快速模式', async ({ page }) => { + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(quickMode).not.toBeNull(); + expect(expertMode).not.toBeNull(); + + await expertMode.click(); + await expect(expertMode).toBeChecked(); + + await quickMode.click(); + await expect(quickMode).toBeChecked(); + }); +}); + +// --- 功能按钮切换测试 --- + +test.describe('功能按钮切换', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + }); + + test('深度思考按钮可切换', async ({ page }) => { + const btn = await findButton(page, ['深度思考', 'DeepThink']); + expect(btn).not.toBeNull(); + + const initialSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + await btn.click(); + await page.waitForTimeout(500); + + const newSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + expect(newSelected).toBe(!initialSelected); + }); + + test('智能搜索按钮可切换', async ({ page }) => { + const btn = await findButton(page, ['智能搜索', 'Search']); + expect(btn).not.toBeNull(); + + const initialSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + await btn.click(); + await page.waitForTimeout(500); + + const newSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + expect(newSelected).toBe(!initialSelected); + }); + + test('深度思考和智能搜索可同时开启', async ({ page }) => { + const thinkingBtn = await findButton(page, ['深度思考', 'DeepThink']); + const searchBtn = await findButton(page, ['智能搜索', 'Search']); + expect(thinkingBtn).not.toBeNull(); + expect(searchBtn).not.toBeNull(); + + // 确保都开启 + for (const btn of [thinkingBtn, searchBtn]) { + const selected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + if (!selected) { + await btn.click(); + await page.waitForTimeout(300); + } + } + + // 验证都已开启 + for (const btn of [thinkingBtn, searchBtn]) { + const selected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + expect(selected).toBe(true); + } + }); +}); + +// --- 文本生成 E2E 测试 --- + +test.describe('文本生成', () => { + test('快速模式发送消息并获取回复', async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + + const textarea = page.locator('textarea'); + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + + // 确保在快速模式 + if (quickMode && !(await quickMode.isChecked())) { + await quickMode.click(); + await page.waitForTimeout(500); + } + + // 关闭深度思考和搜索 + for (const names of [['深度思考', 'DeepThink'], ['智能搜索', 'Search']]) { + const btn = await findButton(page, names); + if (btn && await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected'))) { + await btn.click(); + await page.waitForTimeout(300); + } + } + + const responsePromise = page.waitForResponse( + resp => resp.url().includes('chat/completion') && resp.request().method() === 'POST', + { timeout: 60000 } + ); + + await textarea.click(); + await textarea.fill('Reply with exactly: hello'); + await page.keyboard.press('Enter'); + + const response = await responsePromise; + expect(response.status()).toBe(200); + const { text } = parseSSEResponse(await response.text()); + expect(text.length).toBeGreaterThan(0); + console.log(`[快速模式] 回复 (${text.length}字符): ${text.slice(0, 80)}...`); + }); + + test('专家模式发送消息并获取回复', async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + + const textarea = page.locator('textarea'); + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(expertMode).not.toBeNull(); + await expertMode.click(); + await page.waitForTimeout(800); + + const responsePromise = page.waitForResponse( + resp => resp.url().includes('chat/completion') && resp.request().method() === 'POST', + { timeout: 90000 } + ); + + await textarea.click(); + await textarea.fill('Reply with exactly: 2'); + await page.keyboard.press('Enter'); + + const response = await responsePromise; + expect(response.status()).toBe(200); + const { text } = parseSSEResponse(await response.text()); + expect(text.length).toBeGreaterThan(0); + console.log(`[专家模式] 回复 (${text.length}字符): ${text.slice(0, 80)}...`); + }); + + test('深度思考模式返回 reasoning 内容', async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + + const textarea = page.locator('textarea'); + const thinkingBtn = await findButton(page, ['深度思考', 'DeepThink']); + expect(thinkingBtn).not.toBeNull(); + + // 开启深度思考 + if (!await thinkingBtn.evaluate(el => el.classList.contains('ds-toggle-button--selected'))) { + await thinkingBtn.click(); + await page.waitForTimeout(500); + } + + const responsePromise = page.waitForResponse( + resp => resp.url().includes('chat/completion') && resp.request().method() === 'POST', + { timeout: 90000 } + ); + + await textarea.click(); + await textarea.fill('Why is the sky blue? One sentence.'); + await page.keyboard.press('Enter'); + + const response = await responsePromise; + expect(response.status()).toBe(200); + const { text, thinking } = parseSSEResponse(await response.text()); + expect(text.length).toBeGreaterThan(0); + console.log(`[深度思考] 思考(${thinking.length}字符) 回复(${text.length}字符): ${text.slice(0, 80)}...`); + }); +}); diff --git a/package.json b/package.json index de4c549..8dfe20c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "start": "node supervisor.js", "genkey": "node scripts/genkey.js", "init": "node scripts/init.js", - "postinstall": "node scripts/postinstall.js" + "postinstall": "node scripts/postinstall.js", + "test": "npx playwright test", + "test:manifest": "npx playwright test --grep 'Manifest'" }, "imports": { "#config": "./src/config/index.js", @@ -31,5 +33,8 @@ "sharp": "^0.34.5", "socks-proxy-agent": "^8.0.5", "yaml": "^2.8.2" + }, + "devDependencies": { + "@playwright/test": "^1.59.1" } -} \ No newline at end of file +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..240acc5 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 120_000, + retries: 0, + workers: 1, + use: { + headless: false, + viewport: { width: 1280, height: 800 }, + storageState: './data/deepseek-auth.json', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + testMatch: 'deepseek.test.js', + }, + { + name: 'api', + testMatch: 'api.test.js', + }, + ], +}); diff --git a/src/backend/adapter/deepseek_text.js b/src/backend/adapter/deepseek_text.js index b618703..d1170e8 100644 --- a/src/backend/adapter/deepseek_text.js +++ b/src/backend/adapter/deepseek_text.js @@ -1,5 +1,6 @@ /** * @fileoverview DeepSeek 文本生成适配器 + * @description 支持 DeepSeek V4 快速模式/专家模式,深度思考和智能搜索 */ import { @@ -18,20 +19,74 @@ import { logger } from '../../utils/logger.js'; const TARGET_URL = 'https://chat.deepseek.com/'; const INPUT_SELECTOR = 'textarea'; +// --- 模式名称 (中英文兼容) --- +const MODE_QUICK = ['快速模式', 'Instant']; +const MODE_EXPERT = ['专家模式', 'Expert']; + +// --- 功能按钮名称 (中英文兼容) --- +const BTN_THINKING = ['深度思考', 'DeepThink']; +const BTN_SEARCH = ['智能搜索', 'Search']; +const BTN_NEW_CHAT = ['新对话', 'New Chat']; + +/** + * 按名称列表查找并操作 Playwright locator (兼容中英文) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string[]} names - 名称列表 (中英文) + * @param {'radio'|'button'} role - 元素角色 + * @returns {Promise} 匹配的 locator 或 null + */ +async function findByName(page, names, role) { + for (const name of names) { + const locator = page.getByRole(role, { name }); + if (await locator.count() > 0) return locator; + } + return null; +} + +/** + * 切换模式 (快速模式 / 专家模式,兼容中英文) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string[]} modeNames - 模式名称列表 (中英文) + * @param {object} meta - 日志元数据 + * @returns {Promise} 是否成功切换 + */ +async function switchMode(page, modeNames, meta = {}) { + try { + const radio = await findByName(page, modeNames, 'radio'); + if (!radio) { + logger.debug('适配器', `未找到模式选项: ${modeNames.join('/')}`, meta); + return false; + } + + const isChecked = await radio.isChecked(); + if (!isChecked) { + logger.info('适配器', `切换模式: -> ${modeNames[0]}`, meta); + await safeClick(page, radio, { bias: 'button' }); + await sleep(500, 800); + return true; + } else { + logger.debug('适配器', `已是 ${modeNames[0]} 模式`, meta); + return true; + } + } catch (e) { + logger.warn('适配器', `切换模式 ${modeNames[0]} 失败: ${e.message}`, meta); + return false; + } +} + /** - * 切换功能按钮状态 + * 切换功能按钮状态 (兼容中英文) * @param {import('playwright-core').Page} page - 页面对象 - * @param {string} buttonName - 按钮名称 (DeepThink / Search) + * @param {string[]} buttonNames - 按钮名称列表 (中英文) * @param {boolean} targetState - 目标状态 (true=开启, false=关闭) * @param {object} meta - 日志元数据 * @returns {Promise} 是否成功切换 */ -async function toggleButton(page, buttonName, targetState, meta = {}) { +async function toggleButton(page, buttonNames, targetState, meta = {}) { try { - const btn = page.getByRole('button', { name: buttonName }); - const btnCount = await btn.count(); - if (btnCount === 0) { - logger.debug('适配器', `未找到 ${buttonName} 按钮`, meta); + const btn = await findByName(page, buttonNames, 'button'); + if (!btn) { + logger.debug('适配器', `未找到按钮: ${buttonNames.join('/')}`, meta); return false; } @@ -39,36 +94,41 @@ async function toggleButton(page, buttonName, targetState, meta = {}) { const isSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); if (isSelected !== targetState) { - logger.info('适配器', `切换 ${buttonName}: ${isSelected} -> ${targetState}`, meta); + logger.info('适配器', `切换 ${buttonNames[0]}: ${isSelected} -> ${targetState}`, meta); await safeClick(page, btn, { bias: 'button' }); await sleep(300, 500); return true; } else { - logger.debug('适配器', `${buttonName} 已是目标状态: ${targetState}`, meta); + logger.debug('适配器', `${buttonNames[0]} 已是目标状态: ${targetState}`, meta); return true; } } catch (e) { - logger.warn('适配器', `切换 ${buttonName} 失败: ${e.message}`, meta); + logger.warn('适配器', `切换 ${buttonNames[0]} 失败: ${e.message}`, meta); return false; } } /** - * 配置模型功能 (thinking / search) + * 配置模型功能 (模式切换 + thinking / search,兼容中英文) * @param {import('playwright-core').Page} page - 页面对象 * @param {object} modelConfig - 模型配置 * @param {object} meta - 日志元数据 */ async function configureModel(page, modelConfig, meta = {}) { + const expert = modelConfig?.expert || false; const thinking = modelConfig?.thinking || false; const search = modelConfig?.search || false; - // 切换 DeepThink 状态 - await toggleButton(page, 'DeepThink', thinking, meta); + // 切换模式 (快速模式 / 专家模式) + await switchMode(page, expert ? MODE_EXPERT : MODE_QUICK, meta); + await sleep(200, 400); + + // 切换深度思考状态 + await toggleButton(page, BTN_THINKING, thinking, meta); await sleep(200, 400); - // 切换 Search 状态 - await toggleButton(page, 'Search', search, meta); + // 切换智能搜索状态 + await toggleButton(page, BTN_SEARCH, search, meta); await sleep(200, 400); } @@ -86,8 +146,19 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { const waitTimeout = config?.backend?.pool?.waitTimeout ?? 120000; try { - logger.info('适配器', '开启新会话...', meta); - await gotoWithCheck(page, TARGET_URL); + // 智能导航:如果已在 DeepSeek 对话页面,直接继续对话;否则导航到首页 + const currentUrl = page.url(); + const isOnDeepSeek = currentUrl.includes('chat.deepseek.com'); + const isInConversation = isOnDeepSeek && currentUrl !== TARGET_URL && currentUrl !== TARGET_URL.slice(0, -1); + if (isInConversation) { + logger.info('适配器', '继续当前对话...', meta); + } else if (isOnDeepSeek) { + // 在首页但没有对话,直接使用 + logger.info('适配器', '已在 DeepSeek 首页,开始新对话...', meta); + } else { + logger.info('适配器', '导航到 DeepSeek...', meta); + await gotoWithCheck(page, TARGET_URL); + } // 1. 等待输入框加载 await waitForInput(page, INPUT_SELECTOR, { click: false }); @@ -100,7 +171,25 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { // 3. 输入提示词 logger.info('适配器', '输入提示词...', meta); - await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + try { + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + } catch (clickErr) { + // 点击失败(对话过长导致 textarea 不可交互),尝试点击新对话按钮重置 + logger.warn('适配器', `输入框点击失败,尝试重置对话: ${clickErr.message}`, meta); + try { + const newChatBtn = await findByName(page, BTN_NEW_CHAT, 'button'); + if (newChatBtn) { + await newChatBtn.click(); + await sleep(800, 1200); + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + } else { + throw clickErr; // 没有新对话按钮,抛出原始错误 + } + } catch { + throw clickErr; // 重试失败,抛出原始错误 + } + } await humanType(page, INPUT_SELECTOR, prompt); await sleep(300, 500); @@ -286,19 +375,25 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { export const manifest = { id: 'deepseek_text', displayName: 'DeepSeek (文本生成)', - description: '使用 DeepSeek 官网生成文本,支持 DeepThink 深度思考和 Search 搜索模式。需要已登录的 DeepSeek 账户。', + description: '使用 DeepSeek V4 官网生成文本,支持快速模式/专家模式、深度思考和智能搜索。需要已登录的 DeepSeek 账户。', // 入口 URL getTargetUrl(config, workerConfig) { return TARGET_URL; }, - // 模型列表 + // 模型列表 (DeepSeek V4) models: [ - { id: 'deepseek-v3.2', imagePolicy: 'forbidden' }, - { id: 'deepseek-v3.2-thinking', imagePolicy: 'forbidden', thinking: true }, - { id: 'deepseek-v3.2-search', imagePolicy: 'forbidden', search: true }, - { id: 'deepseek-v3.2-thinking-search', imagePolicy: 'forbidden', thinking: true, search: true }, + // 快速模式 (deepseek-v4-flash) + { id: 'deepseek-v4-flash', imagePolicy: 'forbidden' }, + { id: 'deepseek-v4-flash-thinking', imagePolicy: 'forbidden', thinking: true }, + { id: 'deepseek-v4-flash-search', imagePolicy: 'forbidden', search: true }, + { id: 'deepseek-v4-flash-thinking-search', imagePolicy: 'forbidden', thinking: true, search: true }, + // 专家模式 (deepseek-v4-pro) + { id: 'deepseek-v4-pro', imagePolicy: 'forbidden', expert: true }, + { id: 'deepseek-v4-pro-thinking', imagePolicy: 'forbidden', expert: true, thinking: true }, + { id: 'deepseek-v4-pro-search', imagePolicy: 'forbidden', expert: true, search: true }, + { id: 'deepseek-v4-pro-thinking-search', imagePolicy: 'forbidden', expert: true, thinking: true, search: true }, ], // 无需导航处理器 diff --git a/src/server/api/openai/responses.js b/src/server/api/openai/responses.js new file mode 100644 index 0000000..1b64cd3 --- /dev/null +++ b/src/server/api/openai/responses.js @@ -0,0 +1,466 @@ +/** + * @fileoverview OpenAI Responses API 适配器 + * @description 将 /v1/responses 请求转换为 /v1/chat/completions 格式 + * @see https://platform.openai.com/docs/api-reference/responses + */ + +import crypto from 'crypto'; +import { logger } from '../../../utils/logger.js'; +import { ERROR_CODES } from '../../errors.js'; +import { sendJson, sendSse, sendSseDone, sendApiError, sendHeartbeat } from '../../respond.js'; +import { parseRequest } from './parse.js'; + +/** + * 将 Responses API input 格式转换为 Chat Completions messages 格式 + * @param {Array} input - Responses API 的 input 数组 + * @returns {Array} Chat Completions 的 messages 数组 + */ +function convertInputToMessages(input) { + if (!Array.isArray(input)) return []; + + return input.map(item => { + const message = { role: item.role }; + + // 处理 content:可能是字符串或数组 + if (typeof item.content === 'string') { + message.content = item.content; + } else if (Array.isArray(item.content)) { + message.content = item.content.map(part => { + // input_text -> text + if (part.type === 'input_text') { + return { type: 'text', text: part.text || '' }; + } + // input_image -> image_url + if (part.type === 'input_image') { + return { + type: 'image_url', + image_url: { url: part.image_url || part.url || '' } + }; + } + // 已经是 Chat Completions 格式 + if (part.type === 'text' || part.type === 'image_url') { + return part; + } + // 其他类型转为文本 + return { type: 'text', text: JSON.stringify(part) }; + }); + } else { + message.content = ''; + } + + return message; + }); +} + +/** + * 生成 Responses API 格式的响应 ID + */ +function generateResponseId() { + return 'resp_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24); +} + +/** + * 构造 Responses API 格式的非流式响应 + */ +function buildResponsesOutput(content, model, reasoningContent) { + const output = []; + + if (reasoningContent) { + output.push({ + type: 'reasoning', + id: 'rs_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + summary: [], + content: [{ type: 'reasoning_text', text: reasoningContent }] + }); + } + + output.push({ + type: 'message', + id: 'msg_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + role: 'assistant', + content: [{ + type: 'output_text', + text: content, + annotations: [] + }] + }); + + return { + id: generateResponseId(), + object: 'response', + created_at: Math.floor(Date.now() / 1000), + model, + output, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + status: 'completed' + }; +} + +/** + * 发送 Responses API 流式事件 + */ +function sendResponseSse(res, event, data) { + if (res.writableEnded) return; + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +/** + * 创建 Responses API 路由处理器 + * @param {object} context - 与 chat/completions 共享的上下文 + * @returns {Function} 路由处理函数 + */ +export function createResponsesHandler(context) { + const { + backendName, + getModels, + getImagePolicy, + getModelType, + tempDir, + imageLimit, + queueManager + } = context; + + return async function handleResponses(req, res, requestId) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + + try { + const body = Buffer.concat(chunks).toString(); + const data = JSON.parse(body); + const isStreaming = data.stream === true; + + // 转换 input -> messages + const messages = convertInputToMessages(data.input); + + // 构造 Chat Completions 格式的请求 + const chatRequest = { + model: data.model, + messages, + stream: false, + max_tokens: data.max_output_tokens, + temperature: data.temperature, + top_p: data.top_p + }; + + // 限流检查(非流式) + if (!isStreaming && !queueManager.canAcceptNonStreaming()) { + const status = queueManager.getStatus(); + sendApiError(res, { + code: ERROR_CODES.SERVER_BUSY, + message: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式或稍后重试。` + }); + return; + } + + // 设置响应头 + if (isStreaming) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + } + + // 解析请求(复用 chat/completions 的解析逻辑) + const parseResult = await parseRequest(chatRequest, { + tempDir, + imageLimit, + backendName, + getSupportedModels: getModels, + getImagePolicy, + getModelType, + requestId, + logger + }); + + if (!parseResult.success) { + if (isStreaming) { + sendResponseSse(res, 'error', { + error: { message: parseResult.error.error, type: 'invalid_request_error' } + }); + res.end(); + } else { + sendApiError(res, { + code: parseResult.error.code, + message: parseResult.error.error + }); + } + return; + } + + const { prompt, imagePaths, modelId, modelName } = parseResult.data; + const responseId = generateResponseId(); + + logger.info('服务器', `[Responses] 请求入队: ${prompt.slice(0, 100)}...`, { id: requestId }); + + // 创建代理 res 对象 + const proxyRes = createResponseProxy(res, responseId, modelName, isStreaming); + + // 发送到队列处理 + queueManager.addTask({ + req, + res: proxyRes, + prompt, + imagePaths, + modelId, + modelName, + id: requestId, + isStreaming, + reasoning: false + }); + + } catch (err) { + logger.error('服务器', '[Responses] 请求处理失败', { id: requestId, error: err.message }); + sendApiError(res, { + code: ERROR_CODES.INTERNAL_ERROR, + message: err.message + }); + } + }; +} + +/** + * 创建 Responses API 流式响应代理 + * 将 Chat Completions 的 SSE 事件转换为 Responses API 格式 + */ +function createResponseProxy(res, responseId, modelName, isStreaming) { + let outputIndex = 0; + let contentIndex = 0; + let hasStarted = false; + let headerWritten = false; + let doneSent = false; + let accumulatedText = ''; + let accumulatedReasoning = ''; + let currentItemId = null; + + return { + get writableEnded() { return res.writableEnded; }, + get headersSent() { return res.headersSent || headerWritten; }, + + writeHead(status, headers) { + headerWritten = true; + return res.writeHead(status, headers); + }, + + write(data) { + if (res.writableEnded) return true; + + if (!isStreaming) { + // 非流式模式,直接写入(sendJson 会调用 writeHead + write + end) + return res.write(data); + } + + // 流式模式:解析 Chat Completions SSE 并转换为 Responses API 格式 + const str = typeof data === 'string' ? data : data.toString(); + + // 传递心跳注释(:keepalive) + if (str.startsWith(':')) { + return res.write(data); + } + + const lines = str.split('\n'); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const payload = line.slice(6).trim(); + + if (payload === '[DONE]') { + if (!doneSent && hasStarted) { + doneSent = true; + // 构建 output 内容 + const outputItems = []; + if (accumulatedReasoning) { + outputItems.push({ + type: 'reasoning', + id: 'rs_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + summary: [], + content: [{ type: 'reasoning_text', text: accumulatedReasoning }] + }); + } + outputItems.push({ + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + }); + + sendResponseSse(res, 'response.content_part.done', { + type: 'response.content_part.done', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: accumulatedText, annotations: [] } + }); + sendResponseSse(res, 'response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item: { + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + } + }); + sendResponseSse(res, 'response.completed', { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + model: modelName, + output: outputItems, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } + } + }); + } + continue; + } + + try { + const chunk = JSON.parse(payload); + const choice = chunk.choices?.[0]; + if (!choice) continue; + + const content = choice.delta?.content; + const reasoningContent = choice.delta?.reasoning_content; + + if (!hasStarted && (content || reasoningContent)) { + hasStarted = true; + currentItemId = 'msg_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24); + // 发送 response.created + sendResponseSse(res, 'response.created', { + type: 'response.created', + response: { + id: responseId, + object: 'response', + status: 'in_progress', + model: modelName + } + }); + // 发送 output_item.added + sendResponseSse(res, 'response.output_item.added', { + type: 'response.output_item.added', + output_index: outputIndex, + item: { type: 'message', id: currentItemId, status: 'in_progress', role: 'assistant', content: [] } + }); + sendResponseSse(res, 'response.content_part.added', { + type: 'response.content_part.added', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: '', annotations: [] } + }); + } + + if (content) { + accumulatedText += content; + sendResponseSse(res, 'response.output_text.delta', { + type: 'response.output_text.delta', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + delta: content + }); + } + + if (reasoningContent) { + accumulatedReasoning += reasoningContent; + sendResponseSse(res, 'response.reasoning_text.delta', { + type: 'response.reasoning_text.delta', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + delta: reasoningContent + }); + } + + if (choice.finish_reason === 'stop' && hasStarted && !doneSent) { + doneSent = true; + // 构建 output 内容 + const outputItems = []; + if (accumulatedReasoning) { + outputItems.push({ + type: 'reasoning', + id: 'rs_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + summary: [], + content: [{ type: 'reasoning_text', text: accumulatedReasoning }] + }); + } + outputItems.push({ + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + }); + + sendResponseSse(res, 'response.content_part.done', { + type: 'response.content_part.done', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: accumulatedText, annotations: [] } + }); + sendResponseSse(res, 'response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item: { + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + } + }); + sendResponseSse(res, 'response.completed', { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + model: modelName, + output: outputItems, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } + } + }); + } + } catch { + // 忽略解析错误 + } + } + + return true; + }, + + end(data) { + if (res.writableEnded) return; + + if (!isStreaming && data) { + // 非流式模式:拦截 sendJson 的响应并转换 + try { + const chatResponse = JSON.parse(data.toString()); + const content = chatResponse.choices?.[0]?.message?.content || ''; + const reasoning = chatResponse.choices?.[0]?.message?.reasoning_content; + const responsesOutput = buildResponsesOutput(content, modelName, reasoning); + + // writeHead 已经被 sendJson 调用过,直接写入转换后的数据 + res.end(JSON.stringify(responsesOutput)); + return; + } catch { + // 解析失败,直接返回原始数据 + return res.end(data); + } + } + + return res.end(data); + } + }; +} diff --git a/src/server/api/openai/routes.js b/src/server/api/openai/routes.js index bfba48d..cb3a2c0 100644 --- a/src/server/api/openai/routes.js +++ b/src/server/api/openai/routes.js @@ -8,6 +8,7 @@ import { logger } from '../../../utils/logger.js'; import { ERROR_CODES } from '../../errors.js'; import { sendJson, sendApiError } from '../../respond.js'; import { parseRequest } from './parse.js'; +import { createResponsesHandler } from './responses.js'; /** * 创建 OpenAI API 路由处理器 @@ -25,6 +26,9 @@ export function createOpenAIRouter(context) { queueManager } = context; + // 创建 Responses API 处理器 + const handleResponses = createResponsesHandler(context); + /** * 处理 GET /v1/models */ @@ -167,6 +171,8 @@ export function createOpenAIRouter(context) { await handleCookies(res, requestId, workerName, domain); } else if (req.method === 'POST' && pathname.startsWith('/chat/completions')) { await handleChatCompletions(req, res, requestId); + } else if (req.method === 'POST' && pathname === '/responses') { + await handleResponses(req, res, requestId); } else { res.writeHead(404); res.end();