diff --git a/README.md b/README.md index c0e57e2..9750c7b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ * **使用前提**:使用 Kiro API 需要[下载 Kiro 客户端](https://aibook.ren/archives/kiro-install)并完成授权登录,以生成 `kiro-auth-token.json` 文件。 * **最佳体验**:推荐配合 Claude Code 使用以获得最佳体验。 * **注意事项**:Kiro 服务政策已调整,请查阅官方公告了解具体使用限制。 +* **OpenAI Responses API**: + * **功能说明**: 支持 OpenAI Responses API 端点,提供更结构化的对话响应能力,适用于需要高级对话管理的应用场景。 + * **配置方法**: 在 `config.json` 或启动参数中设置 `MODEL_PROVIDER` 为 `openaiResponses-custom`,并提供相应的 API 密钥和基础 URL。 * **模型供应商切换**:本项目支持通过 Path 路由和环境变量两种方式,在 API 调用中灵活切换不同的模型供应商。 #### 通过 Path 路由切换 @@ -132,6 +135,7 @@ * `http://localhost:3000/openai-custom` - 使用 OpenAI 自定义供应商处理 Claude 请求。 * `http://localhost:3000/gemini-cli-oauth` - 使用 Gemini CLI OAuth 供应商处理 Claude 请求。 * `http://localhost:3000/openai-qwen-oauth` - 使用 Qwen OAuth 供应商处理 Claude 请求。 + * `http://localhost:3000/openaiResponses-custom` - 使用 OpenAI Responses API 供应商处理结构化对话请求。 这些 Path 路由不仅适用于直接 API 调用,也可在 Cline、Kilo 等编程 Agent 中配置 API 端点时使用,实现灵活的模型调用。例如,将 Agent 的 API 端点设置为 `http://localhost:3000/claude-kiro-oauth` 即可调用通过 Kiro OAuth 认证的 Claude 模型。 @@ -293,6 +297,14 @@ $env:HTTP_PROXY="http://your_proxy_address:port" |------|------|--------|------| | `--qwen-oauth-creds-file` | string | null | Qwen OAuth 凭据 JSON 文件路径 (当 `model-provider` 为 `openai-qwen-oauth` 时必需) | +### 🔄 OpenAI Responses API 参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `--model-provider` | string | openaiResponses-custom | 模型提供商,使用OpenAI Responses API时设置为 `openaiResponses-custom` | +| `--openai-api-key` | string | null | OpenAI API 密钥 (当 `model-provider` 为 `openaiResponses-custom` 时必需) | +| `--openai-base-url` | string | null | OpenAI API 基础 URL (当 `model-provider` 为 `openaiResponses-custom` 时必需) | + ### 📝 系统提示配置参数 | 参数 | 类型 | 默认值 | 说明 | @@ -342,6 +354,9 @@ node src/api-server.js --model-provider openai-custom --openai-api-key sk-xxx -- # 使用Claude提供商 node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx --claude-base-url https://api.anthropic.com +# 使用OpenAI Responses API提供商 +node src/api-server.js --model-provider openaiResponses-custom --openai-api-key sk-xxx --openai-base-url https://api.openai.com/v1 + # 使用Gemini提供商(Base64凭据) node src/api-server.js --model-provider gemini-cli-oauth --gemini-oauth-creds-base64 eyJ0eXBlIjoi... --project-id your-project-id diff --git a/provider_pools.json.example b/provider_pools.json.example index 460d804..9da853c 100644 --- a/provider_pools.json.example +++ b/provider_pools.json.example @@ -23,6 +23,19 @@ "lastErrorTime": null } ], + "openaiResponses-custom": [ + { + "OPENAI_API_KEY": "sk-openai-key", + "OPENAI_BASE_URL": "https://api.openai.com/v1", + "checkModelName": null, + "uuid": "e284628d-302f-456d-91f3-609538678968", + "isHealthy": true, + "lastUsed": null, + "usageCount": 0, + "errorCount": 0, + "lastErrorTime": null + } + ], "gemini-cli-oauth": [ { "GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials1.json", diff --git a/src/adapter.js b/src/adapter.js index c295e2a..4c81104 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -1,3 +1,4 @@ +import { OpenAIResponsesApiService } from './openai/openai-responses-core.js'; // 导入OpenAIResponsesApiService import { GeminiApiService } from './gemini/gemini-core.js'; // 导入geminiApiService import { OpenAIApiService } from './openai/openai-core.js'; // 导入OpenAIApiService import { ClaudeApiService } from './claude/claude-core.js'; // 导入ClaudeApiService @@ -126,6 +127,35 @@ export class OpenAIApiServiceAdapter extends ApiServiceAdapter { } } +// OpenAI Responses API 服务适配器 +export class OpenAIResponsesApiServiceAdapter extends ApiServiceAdapter { + constructor(config) { + super(); + this.openAIResponsesApiService = new OpenAIResponsesApiService(config); + } + + async generateContent(model, requestBody) { + // The adapter expects the requestBody to be in the OpenAI Responses format. + return this.openAIResponsesApiService.generateContent(model, requestBody); + } + + async *generateContentStream(model, requestBody) { + // The adapter expects the requestBody to be in the OpenAI Responses format. + const stream = this.openAIResponsesApiService.generateContentStream(model, requestBody); + yield* stream; + } + + async listModels() { + // The adapter returns the native model list from the underlying service. + return this.openAIResponsesApiService.listModels(); + } + + async refreshToken() { + // OpenAI API keys are typically static and do not require refreshing. + return Promise.resolve(); + } +} + // Claude API 服务适配器 export class ClaudeApiServiceAdapter extends ApiServiceAdapter { constructor(config) { @@ -254,6 +284,9 @@ export function getServiceAdapter(config) { case MODEL_PROVIDER.OPENAI_CUSTOM: serviceInstances[providerKey] = new OpenAIApiServiceAdapter(config); break; + case MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES: + serviceInstances[providerKey] = new OpenAIResponsesApiServiceAdapter(config); + break; case MODEL_PROVIDER.GEMINI_CLI: serviceInstances[providerKey] = new GeminiApiServiceAdapter(config); break; @@ -271,4 +304,4 @@ export function getServiceAdapter(config) { } } return serviceInstances[providerKey]; -} +} \ No newline at end of file diff --git a/src/api-server.js b/src/api-server.js index fe33f71..0061c66 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -649,6 +649,9 @@ function createRequestHandler(config) { if (path === '/v1/chat/completions') { return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid); } + if (path === '/v1/responses') { + return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_RESPONSES, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid); + } const geminiUrlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`); if (geminiUrlPattern.test(path)) { return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid); @@ -719,7 +722,7 @@ async function startServer() { console.log(`------------------------------------------`); console.log(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`); console.log(`Supports multiple API formats:`); - console.log(` • OpenAI-compatible: /v1/chat/completions, /v1/models`); + console.log(` • OpenAI-compatible: /v1/chat/completions, /v1/responses, /v1/models`); console.log(` • Gemini-compatible: /v1beta/models, /v1beta/models/{model}:generateContent`); console.log(` • Claude-compatible: /v1/messages`); console.log(` • Health check: /health`); diff --git a/src/common.js b/src/common.js index b677d9a..d21a572 100644 --- a/src/common.js +++ b/src/common.js @@ -3,7 +3,7 @@ import * as path from 'path'; import * as http from 'http'; // Add http for IncomingMessage and ServerResponse types import * as crypto from 'crypto'; // Import crypto for MD5 hashing import { ApiServiceAdapter } from './adapter.js'; // Import ApiServiceAdapter -import { convertData, getOpenAIStreamChunkStop } from './convert.js'; +import { convertData, getOpenAIStreamChunkStop, getOpenAIResponsesStreamChunkBegin, getOpenAIResponsesStreamChunkEnd } from './convert.js'; import { ProviderStrategyFactory } from './provider-strategies.js'; export const API_ACTIONS = { @@ -15,6 +15,7 @@ export const MODEL_PROTOCOL_PREFIX = { // Model provider constants GEMINI: 'gemini', OPENAI: 'openai', + OPENAI_RESPONSES: 'openairesp', CLAUDE: 'claude', } @@ -22,6 +23,7 @@ export const MODEL_PROVIDER = { // Model provider constants GEMINI_CLI: 'gemini-cli-oauth', OPENAI_CUSTOM: 'openai-custom', + OPENAI_CUSTOM_RESPONSES: 'openaiResponses-custom', CLAUDE_CUSTOM: 'claude-custom', KIRO_API: 'claude-kiro-oauth', QWEN_API: 'openai-qwen-oauth', @@ -43,6 +45,7 @@ export function getProtocolPrefix(provider) { export const ENDPOINT_TYPE = { OPENAI_CHAT: 'openai_chat', + OPENAI_RESPONSES: 'openai_responses', GEMINI_CONTENT: 'gemini_content', CLAUDE_MESSAGE: 'claude_message', OPENAI_MODEL_LIST: 'openai_model_list', @@ -212,10 +215,18 @@ export async function handleStreamRequest(res, service, model, requestBody, from // The service returns a stream in its native format (toProvider). const nativeStream = await service.generateContentStream(model, requestBody); const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider); - const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE; - const openStop = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI; + const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE || getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES; + const openStop = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI ; + const openResponses = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES ; try { + if (openResponses && needsConversion) { + const beginChunks = getOpenAIResponsesStreamChunkBegin(model); + for (const chunk of beginChunks) { + res.write(`event: ${chunk.type}\n`); + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + } for await (const nativeChunk of nativeStream) { // Convert chunk to the client's format (fromProvider), if necessary. const chunkText = extractResponseText(nativeChunk, toProvider); @@ -223,7 +234,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from fullResponseText += chunkText; } - const chunkToSend = needsConversion + const chunkToSend = needsConversion ? convertData(chunkText, 'streamChunk', toProvider, fromProvider, model) : nativeChunk; @@ -231,15 +242,28 @@ export async function handleStreamRequest(res, service, model, requestBody, from continue; } - if (addEvent) { - res.write(`event: ${chunkToSend.type}\n`); - // console.log(`event: ${chunkToSend.type}\n`); - } + // 处理 chunkToSend 可能是数组或对象的情况 + const chunksToSend = Array.isArray(chunkToSend) ? chunkToSend : [chunkToSend]; - // fullOldResponseJson += JSON.stringify(nativeChunk)+"\n"; - // fullResponseJson += JSON.stringify(chunkToSend)+"\n"; - res.write(`data: ${JSON.stringify(chunkToSend)}\n\n`); - // console.log(`data: ${JSON.stringify(chunkToSend)}\n`); + for (const chunk of chunksToSend) { + if (addEvent) { + // fullResponseJson += chunk.type+"\n"; + res.write(`event: ${chunk.type}\n`); + // console.log(`event: ${chunk.type}\n`); + } + + // fullOldResponseJson += JSON.stringify(chunk)+"\n"; + // fullResponseJson += JSON.stringify(chunk)+"\n\n"; + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + // console.log(`data: ${JSON.stringify(chunk)}\n`); + } + } + if (openResponses && needsConversion) { + const endChunks = getOpenAIResponsesStreamChunkEnd(model); + for (const chunk of endChunks) { + res.write(`event: ${chunk.type}\n`); + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } } if (openStop && needsConversion) { res.write(`data: ${JSON.stringify(getOpenAIStreamChunkStop(model))}\n\n`); @@ -261,7 +285,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from res.end(JSON.stringify(errorPayload)); responseClosed = true; } - + } finally { if (!responseClosed) { res.end(); @@ -296,7 +320,7 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP uuid: pooluuid }); } - + // 返回错误响应给客户端 const errorResponse = { error: { @@ -339,7 +363,7 @@ export async function handleModelListRequest(req, res, service, endpointType, CO // 2. Convert the model list to the client's expected format, if necessary. let clientModelList = nativeModelList; - if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) { + if (getProtocolPrefix(fromProvider).includes(getProtocolPrefix(toProvider))) { console.log(`[ModelList Convert] Converting model list from ${toProvider} to ${fromProvider}`); clientModelList = convertData(nativeModelList, 'modelList', toProvider, fromProvider); } else { @@ -379,6 +403,7 @@ export async function handleContentGenerationRequest(req, res, service, endpoint const clientProviderMap = { [ENDPOINT_TYPE.OPENAI_CHAT]: MODEL_PROTOCOL_PREFIX.OPENAI, + [ENDPOINT_TYPE.OPENAI_RESPONSES]: MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES, [ENDPOINT_TYPE.CLAUDE_MESSAGE]: MODEL_PROTOCOL_PREFIX.CLAUDE, [ENDPOINT_TYPE.GEMINI_CONTENT]: MODEL_PROTOCOL_PREFIX.GEMINI, }; diff --git a/src/convert.js b/src/convert.js index 3e31d50..a4a4ae0 100644 --- a/src/convert.js +++ b/src/convert.js @@ -1,5 +1,17 @@ import { v4 as uuidv4 } from 'uuid'; import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js'; +import { + streamStateManager, + generateResponseCreated, + generateResponseInProgress, + generateOutputItemAdded, + generateContentPartAdded, + generateOutputTextDelta, + generateOutputTextDone, + generateContentPartDone, + generateOutputItemDone, + generateResponseCompleted +} from './openai/openai-responses-core.mjs'; // ============================================================================= // 常量和辅助函数定义 @@ -178,10 +190,12 @@ export function convertData(data, type, fromProvider, toProvider, model) { }, [MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeRequestFromOpenAI, // from OpenAI protocol + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: toClaudeRequestFromOpenAIResponses, // from OpenAI protocol (Responses format) }, [MODEL_PROTOCOL_PREFIX.GEMINI]: { // to Gemini protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: toGeminiRequestFromOpenAI, // from OpenAI protocol [MODEL_PROTOCOL_PREFIX.CLAUDE]: toGeminiRequestFromClaude, // from Claude protocol + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: toGeminiRequestFromOpenAIResponses, // from OpenAI protocol (Responses format) }, }, response: { @@ -193,6 +207,10 @@ export function convertData(data, type, fromProvider, toProvider, model) { [MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeChatCompletionFromOpenAI, // from OpenAI protocol }, + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: { // to OpenAI protocol (Responses format) + [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIResponsesFromGemini, // from Gemini protocol + [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIResponsesFromClaude, // from Claude protocol + }, }, streamChunk: { [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol @@ -203,6 +221,10 @@ export function convertData(data, type, fromProvider, toProvider, model) { [MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeStreamChunkFromOpenAI, // from OpenAI protocol }, + [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: { // to OpenAI protocol (Responses format) + [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIResponsesStreamChunkFromGemini, // from Gemini protocol + [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIResponsesStreamChunkFromClaude, // from Claude protocol + }, }, modelList: { [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol @@ -220,7 +242,7 @@ export function convertData(data, type, fromProvider, toProvider, model) { if (!targetConversions) { throw new Error(`Unsupported conversion type: ${type}`); } - + const toConversions = targetConversions[getProtocolPrefix(toProvider)]; if (!toConversions) { throw new Error(`No conversions defined for target protocol: ${getProtocolPrefix(toProvider)} for type: ${type}`); @@ -228,9 +250,9 @@ export function convertData(data, type, fromProvider, toProvider, model) { const conversionFunction = toConversions[getProtocolPrefix(fromProvider)]; if (!conversionFunction) { - throw new Error(`No conversion function found from ${fromProvider} to ${toProvider} for type: ${type}`); + throw new Error(`No conversion function found from ${getProtocolPrefix(fromProvider)} to ${toProvider} for type: ${type}`); } - + console.log(conversionFunction); if (type === 'response' || type === 'streamChunk' || type === 'modelList') { return conversionFunction(data, model); @@ -288,6 +310,7 @@ export function toOpenAIRequestFromGemini(geminiRequest) { return openaiRequest; } + /** * Processes Gemini parts to OpenAI content format with multimodal support. * @param {Array} parts - Array of Gemini parts. @@ -579,33 +602,6 @@ export function toOpenAIStreamChunkFromClaude(claudeChunk, model) { }; } -export function getOpenAIStreamChunkStop(model) { - return { - id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: "", - reasoning_content: "" - }, - finish_reason: 'stop', - message: { - content: "", - reasoning_content: "" - } - }], - usage:{ - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; -} - /** * Converts a Claude API model list response to an OpenAI model list response. * @param {Array} claudeModels - The array of model objects from Claude API. @@ -784,7 +780,7 @@ export function toOpenAIRequestFromClaude(claudeRequest) { }); } } - + // ---------------- OpenAI 兼容性校验 ---------------- // 确保所有 assistant.tool_calls 均有后续 tool 响应消息;否则移除不匹配的 tool_call const validatedMessages = []; @@ -884,7 +880,7 @@ export function toOpenAIRequestFromClaude(claudeRequest) { console.info(`Budget tokens: ${budgetTokens} -> reasoning_effort: '${reasoningEffort}'`); } } - + // Add system message at the beginning if present if (systemMessageContent) { let stringifiedSystemMessageContent = systemMessageContent; @@ -898,6 +894,7 @@ export function toOpenAIRequestFromClaude(claudeRequest) { return openaiRequest; } + /** * Processes Claude content to OpenAI content format with multimodal support. * @param {Array} content - Array of Claude content blocks. @@ -1023,13 +1020,31 @@ export function toGeminiRequestFromOpenAI(openaiRequest) { if (systemInstruction) geminiRequest.systemInstruction = systemInstruction; - // Handle tools and tool_choice + // Handle tools if (openaiRequest.tools?.length) { - geminiRequest.tools = openaiRequest.tools.map(t => { - const tool = {}; - tool[t.function.name] = t.function.parameters || {}; - return tool; - }); + geminiRequest.tools = [{ + functionDeclarations: openaiRequest.tools.map(t => { + // Ensure tool is a valid object and has function property + if (!t || typeof t !== 'object' || !t.function) { + console.warn("Skipping invalid tool declaration in openaiRequest.tools."); + return null; // Return null for invalid tools, filter out later + } + + const func = t.function; + // Clean parameters schema for Gemini compatibility + const parameters = _cleanJsonSchemaProperties(func.parameters || {}); + + return { + name: String(func.name || ''), // Ensure name is string + description: String(func.description || ''), // Ensure description is string + parameters: parameters // Use cleaned parameters + }; + }).filter(Boolean) // Filter out any nulls from invalid tool declarations + }]; + // If no valid functionDeclarations, remove the tools array + if (geminiRequest.tools[0].functionDeclarations.length === 0) { + delete geminiRequest.tools; + } } if (openaiRequest.tool_choice) { @@ -1991,3 +2006,566 @@ export function toClaudeStreamChunkFromGemini(geminiChunk, model) { return null; } + + +/** + * Converts a Claude API response to an OpenAI Responses API response. + * @param {Object} claudeResponse - The Claude API response object. + * @param {string} model - The model name to include in the response. + * @returns {Object} The formatted OpenAI Responses API response. + */ +export function toOpenAIResponsesFromClaude(claudeResponse, model) { + // 根据参考示例重构响应结构 + const content = processClaudeResponseContent(claudeResponse.content); + const textContent = typeof content === 'string' ? content : JSON.stringify(content); + + // 将claude的内容转换为OpenAI Responses输出格式 + let output = []; + + // 添加文本内容 + output.push({ + type: "message", + id: `msg_${uuidv4().replace(/-/g, '')}`, + summary: [], + type: "message", + role: "assistant", + status: "completed", + content: [{ + annotations: [], + logprobs: [], + text: textContent, + type: "output_text" + }] + }); + + return { + background: false, + created_at: Math.floor(Date.now() / 1000), + error: null, + id: `resp_${uuidv4().replace(/-/g, '')}`, + incomplete_details: null, + max_output_tokens: null, + max_tool_calls: null, + metadata: {}, + model: model || claudeResponse.model, + object: "response", + output: output, + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + reasoning: { + // effort: "minimal", + // summary: "detailed" + }, + safety_identifier: "user-"+uuidv4().replace(/-/g, ''), // 示例值 + service_tier: "default", + status: "completed", + store: false, + temperature: 1, + text: { + format: {type: "text"}, + // verbosity: "medium" + }, + tool_choice: "auto", + tools: [], + top_logprobs: 0, + top_p: 1, + truncation: "disabled", + usage: { + input_tokens: claudeResponse.usage?.input_tokens || 0, // 示例值 + input_tokens_details: { + cached_tokens: claudeResponse.usage?.cache_creation_input_tokens || 0, // 如果有缓存相关数据则使用 + }, + output_tokens: claudeResponse.usage?.output_tokens || 0, // 示例值 + output_tokens_details: { + reasoning_tokens: 0 + }, + total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0) // 示例值 + }, + user: null + }; +} + +/** + * Converts a Gemini API response to an OpenAI Responses API response. + * @param {Object} geminiResponse - The Gemini API response object. + * @param {string} model - The model name to include in the response. + * @returns {Object} The formatted OpenAI Responses API response. + */ +export function toOpenAIResponsesFromGemini(geminiResponse, model) { + // 根据参考示例重构响应结构 + const content = processGeminiResponseContent(geminiResponse); + const textContent = typeof content === 'string' ? content : JSON.stringify(content); + + // 将gemini的内容转换为OpenAI Responses输出格式 + let output = []; + + // 添加文本内容 + output.push({ + id: `msg_${uuidv4().replace(/-/g, '')}`, + summary: [], + type: "message", + role: "assistant", + status: "completed", + content: [{ + annotations: [], + logprobs: [], + text: textContent, + type: "output_text" + }] + }); + + return { + background: false, + created_at: Math.floor(Date.now() / 1000), + error: null, + id: `resp_${uuidv4().replace(/-/g, '')}`, + incomplete_details: null, + max_output_tokens: null, + max_tool_calls: null, + metadata: {}, + model: model, + object: "response", + output: output, + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + reasoning: { + // effort: "minimal", + // summary: "detailed" + }, + safety_identifier: "user-"+uuidv4().replace(/-/g, ''), // 示例值 + service_tier: "default", + status: "completed", + store: false, + temperature: 1, + text: { + format: {type: "text"}, + // verbosity: "medium" + }, + tool_choice: "auto", + tools: [], + top_logprobs: 0, + top_p: 1, + truncation: "disabled", + usage: { + input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, // 示例值 + input_tokens_details: { + cached_tokens: geminiResponse.usageMetadata?.cachedTokens || 0, // 使用正确的Gemini缓存字段 + }, + output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, // 示例值 + output_tokens_details: { + reasoning_tokens: 0 + }, + total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0, // 示例值 + }, + user: null + }; +} + + +/** + * Converts an OpenAI Responses API request body to a Claude API request body. + * @param {Object} responsesRequest - The request body from the OpenAI Responses API. + * @returns {Object} The formatted request body for the Claude API. + */ +export function toClaudeRequestFromOpenAIResponses(responsesRequest) { + // The OpenAI Responses API uses input and instructions instead of messages + const claudeRequest = { + model: responsesRequest.model, + max_tokens: checkAndAssignOrDefault(responsesRequest.max_tokens, DEFAULT_MAX_TOKENS), + temperature: checkAndAssignOrDefault(responsesRequest.temperature, DEFAULT_TEMPERATURE), + top_p: checkAndAssignOrDefault(responsesRequest.top_p, DEFAULT_TOP_P), + }; + + // Process instructions as system message + if (responsesRequest.instructions) { + claudeRequest.system = []; + claudeRequest.system.push({ + text: typeof responsesRequest.instructions === 'string' ? responsesRequest.instructions : JSON.stringify(responsesRequest.instructions) + }); + + } + + const claudeMessages = []; + // Process input as user message content + if (responsesRequest.input) { + if (typeof responsesRequest.input === 'string') { + // Create user message with the string content + claudeMessages.push({ + role: 'user', + content: [{ + type: 'text', + text: responsesRequest.input + }] + }); + } else { + // Handle array of messages or items - process the entire array + for (const message of responsesRequest.input) { + const role = message.role === 'assistant' ? 'assistant' : 'user'; + let content = []; + + if (message.role === 'tool') { + // Claude expects tool_result to be in a 'user' message + // The content of a tool message is a single tool_result block + content.push({ + type: 'tool_result', + tool_use_id: message.tool_call_id, // Use tool_call_id from OpenAI tool message + content: safeParseJSON(message.content) // Parse content as JSON if possible + }); + claudeMessages.push({ role: 'user', content: content }); + } else if (message.role === 'assistant' && message.tool_calls?.length) { + // Assistant message with tool calls - properly format as tool_use blocks + // Claude expects tool_use to be in an 'assistant' message + const toolUseBlocks = message.tool_calls.map(tc => ({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input: safeParseJSON(tc.function.arguments) + })); + claudeMessages.push({ role: 'assistant', content: toolUseBlocks }); + } else { + // Regular user or assistant message (text and multimodal) + if (typeof message.content === 'string') { + if (message.content) { + content.push({ type: 'text', text: message.content }); + } + } else if (Array.isArray(message.content)) { + message.content.forEach(item => { + if (!item) return; + switch (item.type) { + case 'input_text': + if (item.text) { + content.push({ type: 'text', text: item.text }); + } + break; + case 'output_text': + if (item.text) { + content.push({ type: 'text', text: item.text }); + } + break; + case 'image_url': + if (item.image_url) { + const imageUrl = typeof item.image_url === 'string' + ? item.image_url + : item.image_url.url; + if (imageUrl.startsWith('data:')) { + const [header, data] = imageUrl.split(','); + const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: data + } + }); + } else { + // Claude requires base64 for images, so for URLs, we'll represent as text + content.push({ type: 'text', text: `[Image: ${imageUrl}]` }); + } + } + break; + case 'audio': + // Handle audio content as text placeholder + if (item.audio_url) { + const audioUrl = typeof item.audio_url === 'string' + ? item.audio_url + : item.audio_url.url; + content.push({ type: 'text', text: `[Audio: ${audioUrl}]` }); + } + break; + } + }); + } + // Only add message if content is not empty + if (content.length > 0) { + claudeMessages.push({ role: role, content: content }); + } + } + } + } + } + + // Process tools if present + // if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { + // claudeRequest.tools = responsesRequest.tools.map(tool => ({ + // name: tool.name, + // description: tool.description || '', + // input_schema: tool.parameters || { type: 'object', properties: {} } + // })); + // claudeRequest.tool_choice = buildClaudeToolChoice(responsesRequest.tool_choice); + // } + + // Process messages + claudeRequest.messages = claudeMessages; + claudeRequest.stream = responsesRequest.stream || false; + return claudeRequest; +} + +/** + * Converts an OpenAI Responses API request body to a Gemini API request body. + * @param {Object} responsesRequest - The request body from the OpenAI Responses API. + * @returns {Object} The formatted request body for the Gemini API. + */ +export function toGeminiRequestFromOpenAIResponses(responsesRequest) { + // The OpenAI Responses API uses input and instructions instead of messages + const geminiRequest = { + contents: [] + }; + + // Process instructions as system instruction + if (responsesRequest.instructions) { + let instructionsText = ''; + if (typeof responsesRequest.instructions === 'string') { + instructionsText = responsesRequest.instructions; + } else { + instructionsText = JSON.stringify(responsesRequest.instructions); + } + geminiRequest.systemInstruction = { + parts: [{ text: instructionsText }] + }; + } + + // Process input as user content + if (responsesRequest.input) { + let inputContent = ''; + if (typeof responsesRequest.input === 'string') { + inputContent = responsesRequest.input; + } else if (Array.isArray(responsesRequest.input)) { + // Handle array of messages or items + if (responsesRequest.input.length > 0) { + // For compatibility, take the content of the last item with text content + const lastInputItem = [...responsesRequest.input].reverse().find(item => + item && ( + (item.content && typeof item.content === 'string') || + (item.content && Array.isArray(item.content) && item.content.some(c => c && c.text)) || + (item.role === 'user' && item.content) + ) + ); + + if (lastInputItem) { + if (typeof lastInputItem.content === 'string') { + inputContent = lastInputItem.content; + } else if (Array.isArray(lastInputItem.content)) { + // Process array of content blocks + inputContent = lastInputItem.content + .filter(block => block && block.text) + .map(block => block.text) + .join(' '); + } else { + // General fallback + inputContent = JSON.stringify(lastInputItem.content || lastInputItem); + } + } + } + } + + if (inputContent) { + // Add user message with the input content + geminiRequest.contents.push({ + role: 'user', + parts: [{ text: inputContent }] + }); + } + } else { + // If no input is provided, ensure we have at least one user message for Gemini + geminiRequest.contents.push({ + role: 'user', + parts: [{ text: 'Hello' }] // Default content to satisfy Gemini API requirement + }); + } + + // Add generation config + const generationConfig = {}; + generationConfig.maxOutputTokens = checkAndAssignOrDefault(responsesRequest.max_tokens, DEFAULT_GEMINI_MAX_TOKENS); + generationConfig.temperature = checkAndAssignOrDefault(responsesRequest.temperature, DEFAULT_TEMPERATURE); + generationConfig.topP = checkAndAssignOrDefault(responsesRequest.top_p, DEFAULT_TOP_P); + + if (Object.keys(generationConfig).length > 0) { + geminiRequest.generationConfig = generationConfig; + } + + // Process tools if present + if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { + geminiRequest.tools = [{ + functionDeclarations: responsesRequest.tools + .filter(tool => tool && (tool.type === 'function' || tool.function)) + .map(tool => { + const func = tool.function || tool; + return { + name: String(func.name || tool.name || ''), + description: String(func.description || tool.description || ''), + parameters: func.parameters || tool.parameters || { type: 'object', properties: {} } + }; + }).filter(Boolean) // Filter out any invalid tools + }]; + + // If no valid functionDeclarations, remove the tools array + if (geminiRequest.tools[0].functionDeclarations.length === 0) { + delete geminiRequest.tools; + } + } + + return geminiRequest; +} + +/** + * Converts a Claude API stream chunk to an OpenAI Responses API stream chunk. + * @param {Object} claudeChunk - The Claude API stream chunk object. + * @param {string} [model] - Optional model name to include in the response. + * @param {string} [requestId] - Optional request ID to maintain stream state across chunks. + * @returns {Array} The formatted OpenAI Responses API stream chunks as an array of events. + */ +export function toOpenAIResponsesStreamChunkFromClaude(claudeChunk, model, requestId = null) { + if (!claudeChunk) { + return []; + } + + // 如果没有提供requestId,则生成一个(首次调用时) + const id = requestId || Date.now().toString(); + + // 设置模型信息(仅在新请求时设置) + if (!requestId) { + streamStateManager.setModel(id, model); + } + + // Handle text content from Claude stream + let content = ''; + if (typeof claudeChunk === 'string') { + content = claudeChunk; + } else if (claudeChunk && typeof claudeChunk === 'object' && claudeChunk.delta?.text) { + content = claudeChunk.delta.text; + } else if (claudeChunk && typeof claudeChunk === 'object') { + content = claudeChunk; + } + + // 对于第一个数据块(fullText为空),生成开始事件 + const state = streamStateManager.getOrCreateState(id); + if (state.fullText === '' && !requestId) { // 只在首次调用时(未指定requestId时)生成开始事件 + // 在这种情况下,我们需要先添加内容到状态 + state.fullText = content; + return [ + // ...getOpenAIResponsesStreamChunkBegin(id, model), + generateOutputTextDelta(id, content), + // ...getOpenAIResponsesStreamChunkEnd(id) + ]; + } else if (content === '') { + // 如果是结束块,生成结束事件 + const doneEvents = getOpenAIResponsesStreamChunkEnd(id); + + // 清理状态 + streamStateManager.cleanup(id); + + return doneEvents; + } else { + // 中间数据块,只返回delta事件,但也要更新状态 + streamStateManager.updateText(id, content); + return [ + generateOutputTextDelta(id, content) + ]; + } +} + +/** + * Converts a Gemini API stream chunk to an OpenAI Responses API stream chunk. + * @param {Object} geminiChunk - The Gemini API stream chunk object. + * @param {string} [model] - Optional model name to include in the response. + * @param {string} [requestId] - Optional request ID to maintain stream state across chunks. + * @returns {Array} The formatted OpenAI Responses API stream chunks as an array of events. + */ +export function toOpenAIResponsesStreamChunkFromGemini(geminiChunk, model, requestId = null) { + if (!geminiChunk) { + return []; + } + + // 如果没有提供requestId,则生成一个(首次调用时) + const id = requestId || Date.now().toString(); + + // 设置模型信息(仅在新请求时设置) + if (!requestId) { + streamStateManager.setModel(id, model); + } + + // Handle text content in stream + let content = ''; + if (typeof geminiChunk === 'string') { + content = geminiChunk; + } else if (geminiChunk && typeof geminiChunk === 'object') { + // Extract content from Gemini chunk if it's an object + content = geminiChunk.content || geminiChunk.text || geminiChunk; + } + + // 对于第一个数据块(fullText为空),生成开始事件 + const state = streamStateManager.getOrCreateState(id); + if (state.fullText === '' && !requestId) { // 只在首次调用时(未指定requestId时)生成开始事件 + // 在这种情况下,我们需要先添加内容到状态 + state.fullText = content; + return [ + // ...getOpenAIResponsesStreamChunkBegin(id, model), + generateOutputTextDelta(id, content), + // ...getOpenAIResponsesStreamChunkEnd(id) + ]; + } else if (content === '') { + // 如果是结束块,生成结束事件 + const doneEvents = getOpenAIResponsesStreamChunkEnd(id); + + // 清理状态 + streamStateManager.cleanup(id); + + return doneEvents; + } else { + // 中间数据块,只返回delta事件,但也要更新状态 + streamStateManager.updateText(id, content); + return [ + generateOutputTextDelta(id, content) + ]; + } +} + +export function getOpenAIStreamChunkStop(model) { + return { + id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + system_fingerprint: "", + choices: [{ + index: 0, + delta: { + content: "", + reasoning_content: "" + }, + finish_reason: 'stop', + message: { + content: "", + reasoning_content: "" + } + }], + usage:{ + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; +} + +export function getOpenAIResponsesStreamChunkBegin(id, model){ + + return [ + generateResponseCreated(id, model), + generateResponseInProgress(id), + generateOutputItemAdded(id), + generateContentPartAdded(id) + ]; +} + +export function getOpenAIResponsesStreamChunkEnd(id){ + + return [ + generateOutputTextDone(id), + generateContentPartDone(id), + generateOutputItemDone(id), + generateResponseCompleted(id) + ]; +} \ No newline at end of file diff --git a/src/openai/openai-responses-core.js b/src/openai/openai-responses-core.js new file mode 100644 index 0000000..ca05fab --- /dev/null +++ b/src/openai/openai-responses-core.js @@ -0,0 +1,146 @@ +import axios from 'axios'; + +// OpenAI Responses API specification service for interacting with third-party models +export class OpenAIResponsesApiService { + constructor(config) { + if (!config.OPENAI_API_KEY) { + throw new Error("OpenAI API Key is required for OpenAIResponsesApiService."); + } + this.config = config; + this.apiKey = config.OPENAI_API_KEY; + this.baseUrl = config.OPENAI_BASE_URL || 'https://api.openai.com/v1'; + console.log(`[OpenAIResponsesApiService] Base URL: ${JSON.stringify(config)}`); + this.axiosInstance = axios.create({ + baseURL: this.baseUrl, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + } + }); + } + + async callApi(endpoint, body, isRetry = false, retryCount = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay + + try { + const response = await this.axiosInstance.post(endpoint, body); + return response.data; + } catch (error) { + const status = error.response?.status; + const data = error.response?.data; + if (status === 401 || status === 403) { + console.error(`[API] Received ${status}. API Key might be invalid or expired.`); + throw error; + } + + // Handle 429 (Too Many Requests) with exponential backoff + if (status === 429 && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } + + // Handle other retryable errors (5xx server errors) + if (status >= 500 && status < 600 && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[API] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } + + console.error(`Error calling OpenAI Responses API (Status: ${status}):`, data || error.message); + throw error; + } + } + + async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay + + // OpenAI 的流式请求需要将 stream 设置为 true + const streamRequestBody = { ...body, stream: true }; + + try { + const response = await this.axiosInstance.post(endpoint, streamRequestBody, { + responseType: 'stream' + }); + + const stream = response.data; + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, newlineIndex).trim(); + buffer = buffer.substring(newlineIndex + 1); + + if (line.startsWith('data: ')) { + const jsonData = line.substring(6).trim(); + if (jsonData === '[DONE]') { + return; // Stream finished + } + try { + const parsedChunk = JSON.parse(jsonData); + yield parsedChunk; + } catch (e) { + console.warn("[OpenAIResponsesApiService] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData); + } + } else if (line === '') { + // Empty line, end of an event + } + } + } + } catch (error) { + const status = error.response?.status; + const data = error.response?.data; + if (status === 401 || status === 403) { + console.error(`[API] Received ${status} during stream. API Key might be invalid or expired.`); + throw error; + } + + // Handle 429 (Too Many Requests) with exponential backoff + if (status === 429 && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } + + // Handle other retryable errors (5xx server errors) + if (status >= 500 && status < 600 && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } + + console.error(`Error calling OpenAI Responses streaming API (Status: ${status}):`, data || error.message); + throw error; + } + } + + async generateContent(model, requestBody) { + return this.callApi('/responses', requestBody); + } + + async *generateContentStream(model, requestBody) { + yield* this.streamApi('/responses', requestBody); + } + + async listModels() { + try { + const response = await this.axiosInstance.get('/models'); + return response.data; + } catch (error) { + const status = error.response?.status; + const data = error.response?.data; + console.error(`Error listing OpenAI Responses models (Status: ${status}):`, data || error.message); + throw error; + } + } +} \ No newline at end of file diff --git a/src/openai/openai-responses-core.mjs b/src/openai/openai-responses-core.mjs new file mode 100644 index 0000000..2069785 --- /dev/null +++ b/src/openai/openai-responses-core.mjs @@ -0,0 +1,329 @@ +import { v4 as uuidv4 } from 'uuid'; + +// 流式处理状态管理 +class StreamState { + constructor() { + this.states = new Map(); // 使用Map存储不同请求的状态 + } + + // 获取或创建状态 + getOrCreateState(requestId) { + if (!this.states.has(requestId)) { + this.states.set(requestId, { + id: `resp_${uuidv4().replace(/-/g, '')}`, + msgId: `msg_${uuidv4().replace(/-/g, '')}`, + fullText: '', + sequenceNumber: 0, + model: null, + status: 'in_progress', + startTime: Math.floor(Date.now() / 1000) + }); + } + return this.states.get(requestId); + } + + // 更新文本内容 + updateText(requestId, textDelta) { + const state = this.getOrCreateState(requestId); + state.fullText += textDelta; + state.sequenceNumber += 1; + return state; + } + + // 设置模型信息 + setModel(requestId, model) { + const state = this.getOrCreateState(requestId); + state.model = model; + return state; + } + + // 完成请求 + completeRequest(requestId) { + const state = this.getOrCreateState(requestId); + state.status = 'completed'; + return state; + } + + // 清理状态 + cleanup(requestId) { + this.states.delete(requestId); + } +} + +// 创建全局流式状态管理器 +const streamStateManager = new StreamState(); + +/** + * Generates a response.created event + */ +function generateResponseCreated(requestId, model) { + const state = streamStateManager.getOrCreateState(requestId); + if (model) { + state.model = model; + } + + return { + type: 'response.created', + response: { + id: state.id, + object: 'response', + created_at: state.startTime, + status: 'in_progress', + error: null, + incomplete_details: null, + instructions: '', + max_output_tokens: null, + model: state.model || 'gpt-4.1-2025-04-14', + output: [], + parallel_tool_calls: true, + previous_response_id: null, + reasoning: { }, + store: false, + temperature: 1, + text: { format: { type: "text" }}, + tool_choice: "auto", + tools: [], + top_logprobs: 0, + top_p: 1, + truncation: "disabled", + usage: null, + user: null, + metadata: {} + } + }; +} + +/** + * Generates a response.in_progress event + */ +function generateResponseInProgress(requestId) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.in_progress', + response: { + id: state.id, + object: 'response', + created_at: state.startTime, + status: 'in_progress', + error: null, + incomplete_details: null, + instructions: '', + max_output_tokens: null, + model: state.model || 'gpt-4.1-2025-04-14', + output: [], + parallel_tool_calls: true, + previous_response_id: null, + reasoning: { }, + service_tier: "auto", + store: false, + temperature: 1, + text: { format: { type: "text" }}, + tool_choice: "auto", + tools: [], + top_logprobs: 0, + top_p: 1, + truncation: "disabled", + usage: null, + user: null, + metadata: {} + } + }; +} + +/** + * Generates a response.output_item.added event + */ +function generateOutputItemAdded(requestId) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.output_item.added', + output_index: 0, + item: { + id: state.msgId, + summary: [], + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [] + } + }; +} + +/** + * Generates a response.content_part.added event + */ +function generateContentPartAdded(requestId) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.content_part.added', + item_id: state.msgId, + output_index: 0, + content_index: 0, + part: { + type: 'output_text', + text: '', + annotations: [], + logprobs: [] + } + }; +} + +/** + * Generates a response.output_text.delta event + */ +function generateOutputTextDelta(requestId, delta) { + const state = streamStateManager.getOrCreateState(requestId); + state.fullText += delta; + + return { + type: 'response.output_text.delta', + item_id: state.msgId, + output_index: 0, + content_index: 0, + delta: delta, + logprobs: [], + obfuscation: null + }; +} + +/** + * Generates a response.output_text.done event + */ +function generateOutputTextDone(requestId) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.output_text.done', + item_id: state.msgId, + output_index: 0, + content_index: 0, + text: state.fullText, + logprobs: [] + }; +} + +/** + * Generates a response.content_part.done event + */ +function generateContentPartDone(requestId) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.content_part.done', + item_id: state.msgId, + output_index: 0, + content_index: 0, + part: { + type: 'output_text', + text: state.fullText, + annotations: [], + logprobs: [] + } + }; +} + +/** + * Generates a response.output_item.done event + */ +function generateOutputItemDone(requestId) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.output_item.done', + output_index: 0, + item: { + id: state.msgId, + summary: [], + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: state.fullText, + annotations: [], + logprobs: [] + } + ] + } + }; +} + +/** + * Generates a response.completed event + */ +function generateResponseCompleted(requestId, usage) { + const state = streamStateManager.getOrCreateState(requestId); + + return { + type: 'response.completed', + response: { + background: false, + created_at: state.startTime, + error: null, + id: state.id, + incomplete_details: null, + max_output_tokens: null, + max_tool_calls: null, + metadata: {}, + model: state.model || 'gpt-4.1-2025-04-14', + object: 'response', + output: [ + { + id: state.msgId, + summary: [], + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: state.fullText, + annotations: [], + logprobs: [] + } + ] + } + ], + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + reasoning: { + }, + safety_identifier: `user-${uuidv4().replace(/-/g, '')}`, // 随机值 + service_tier: "default", + status: "completed", + store: false, + temperature: 1, + text: { + format: { type: "text" } + }, + tool_choice: "auto", + tools: [], + top_logprobs: 0, + top_p: 1, + truncation: "disabled", + usage: usage || { + input_tokens: Math.floor(Math.random() * 100) + 20, // 随机值 + input_tokens_details: { + cached_tokens: Math.floor(Math.random() * 50) // 随机值 + }, + output_tokens: state.fullText.split('').length, + output_tokens_details: { + reasoning_tokens: 0 + }, + total_tokens: Math.floor(Math.random() * 100) + 20 + state.fullText.split('').length // 随机值+文本长度 + }, + user: null + } + }; +} + +// 导出流式状态管理器以供外部使用 +export { streamStateManager, generateResponseCreated, generateResponseInProgress, + generateOutputItemAdded, generateContentPartAdded, generateOutputTextDelta, + generateOutputTextDone, generateContentPartDone, generateOutputItemDone, + generateResponseCompleted }; \ No newline at end of file diff --git a/src/openai/openai-responses-strategy.js b/src/openai/openai-responses-strategy.js new file mode 100644 index 0000000..cd1d6be --- /dev/null +++ b/src/openai/openai-responses-strategy.js @@ -0,0 +1,123 @@ +import { ProviderStrategy } from '../provider-strategy.js'; +import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../common.js'; + +/** + * OpenAI Responses API strategy implementation. + * Migrated from Chat Completions API to Responses API. + */ +class ResponsesAPIStrategy extends ProviderStrategy { + extractModelAndStreamInfo(req, requestBody) { + const model = requestBody.model; + const isStream = requestBody.stream === true; + return { model, isStream }; + } + + extractResponseText(response) { + if (!response.output) { + return ''; + } + + // In Responses API, output is an array of items + for (const item of response.output) { + if (item.type === 'message' && item.content && item.content.length > 0) { + for (const content of item.content) { + if (content.type === 'output_text' && content.text) { + return content.text; + } + } + } + } + return ''; + } + + extractPromptText(requestBody) { + // In Responses API, input can be a string or array of items + if (typeof requestBody.input === 'string') { + return requestBody.input; + } else if (Array.isArray(requestBody.input)) { + // If input is an array of items/messages, get the last user content + const userInputItems = requestBody.input.filter(item => + (item.role && item.role === 'user') || + (item.type && item.type === 'message' && item.role === 'user') || + (item.type && item.type === 'user') + ); + + if (userInputItems.length > 0) { + const lastInput = userInputItems[userInputItems.length - 1]; + if (typeof lastInput.content === 'string') { + return lastInput.content; + } else if (Array.isArray(lastInput.content)) { + return lastInput.content.map(item => item.text || item.content || '').join('\n'); + } + } + } + return ''; + } + + async applySystemPromptFromFile(config, requestBody) { + if (!config.SYSTEM_PROMPT_FILE_PATH) { + return requestBody; + } + + const filePromptContent = config.SYSTEM_PROMPT_CONTENT; + if (filePromptContent === null) { + return requestBody; + } + + // In Responses API, system instructions are typically passed in 'instructions' field + // or in the input array with role: 'system' + requestBody.instructions = requestBody.instructions || filePromptContent; + + // If using instructions field is not desired, append to input array instead + if (!requestBody.instructions || config.SYSTEM_PROMPT_MODE === 'append') { + if (typeof requestBody.input === 'string') { + // Convert to array format to add system message + requestBody.input = [ + { role: 'system', content: filePromptContent }, + { role: 'user', content: requestBody.input } + ]; + } else if (Array.isArray(requestBody.input)) { + // Check if system message already exists + const systemMessageIndex = requestBody.input.findIndex(m => + m.role === 'system' || (m.type && m.type === 'system') + ); + + if (systemMessageIndex !== -1) { + requestBody.input[systemMessageIndex].content = filePromptContent; + } else { + requestBody.input.unshift({ role: 'system', content: filePromptContent }); + } + } else { + // If input is not defined, initialize with system message + requestBody.input = [{ role: 'system', content: filePromptContent }]; + } + } else if (requestBody.instructions) { + // If system prompt mode is not append, then replace the instructions + requestBody.instructions = filePromptContent; + } + + console.log(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'responses'.`); + + return requestBody; + } + + async manageSystemPrompt(requestBody) { + // For Responses API, we may extract instructions or system messages from input + let incomingSystemText = ''; + + if (requestBody.instructions) { + incomingSystemText = requestBody.instructions; + } else if (Array.isArray(requestBody.input)) { + const systemMessage = requestBody.input.find(item => + item.role === 'system' || (item.type && item.type === 'system') + ); + if (systemMessage && systemMessage.content) { + incomingSystemText = systemMessage.content; + } + } + + await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.OPENAI); + } +} + +export { ResponsesAPIStrategy }; \ No newline at end of file diff --git a/src/provider-pool-manager.js b/src/provider-pool-manager.js index 318e22e..cecd9ff 100644 --- a/src/provider-pool-manager.js +++ b/src/provider-pool-manager.js @@ -212,6 +212,9 @@ export class ProviderPoolManager { case MODEL_PROVIDER.QWEN_API: modelName = 'qwen3-coder-flash'; // Example model name for Qwen break; + case MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES: + modelName = 'gpt-5-low'; // Example model name for OpenAI Custom Responses + break; default: console.warn(`[ProviderPoolManager] Unknown provider type for health check: ${providerType}`); return false; @@ -226,6 +229,12 @@ export class ProviderPoolManager { }] }; + if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) { + healthCheckRequest.input = [{ role: 'user', content: 'Hello, are you ok?' }]; + healthCheckRequest.model = modelName; + delete healthCheckRequest.contents; + } + // For OpenAI and Claude providers, we need a different request format if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM || providerType === MODEL_PROVIDER.CLAUDE_CUSTOM || providerType === MODEL_PROVIDER.KIRO_API || providerType === MODEL_PROVIDER.QWEN_API) { healthCheckRequest.messages = [{ role: 'user', content: 'Hello, are you ok?' }]; diff --git a/src/provider-strategies.js b/src/provider-strategies.js index b1146ba..644d2ed 100644 --- a/src/provider-strategies.js +++ b/src/provider-strategies.js @@ -2,6 +2,7 @@ import { MODEL_PROTOCOL_PREFIX } from './common.js'; import { GeminiStrategy } from './gemini/gemini-strategy.js'; import { OpenAIStrategy } from './openai/openai-strategy.js'; import { ClaudeStrategy } from './claude/claude-strategy.js'; +import { ResponsesAPIStrategy } from './openai/openai-responses-strategy.js'; /** * Strategy factory that returns the appropriate strategy instance based on the provider protocol. @@ -13,6 +14,8 @@ class ProviderStrategyFactory { return new GeminiStrategy(); case MODEL_PROTOCOL_PREFIX.OPENAI: return new OpenAIStrategy(); + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + return new ResponsesAPIStrategy(); case MODEL_PROTOCOL_PREFIX.CLAUDE: return new ClaudeStrategy(); default: