diff --git a/packages/shared/aiCore/config/aihubmix.ts b/packages/shared/aiCore/config/aihubmix.ts index 182e4fb194..21b875da34 100644 --- a/packages/shared/aiCore/config/aihubmix.ts +++ b/packages/shared/aiCore/config/aihubmix.ts @@ -17,7 +17,7 @@ const extraProviderConfig =

(provider: P) => { } } -function isOpenAILLMModel(model: M): boolean { +export function isOpenAILLMModel(model: M): boolean { const modelId = getLowerBaseModelName(model.id) const reasonings = ['o1', 'o3', 'o4', 'gpt-oss'] if (reasonings.some((r) => modelId.includes(r))) { diff --git a/src/main/apiServer/adapters/converters/OpenAIResponsesMessageConverter.ts b/src/main/apiServer/adapters/converters/OpenAIResponsesMessageConverter.ts new file mode 100644 index 0000000000..5ffea3b99b --- /dev/null +++ b/src/main/apiServer/adapters/converters/OpenAIResponsesMessageConverter.ts @@ -0,0 +1,262 @@ +/** + * OpenAI Responses API Message Converter + * + * Converts OpenAI Responses API format to AI SDK format. + * Uses types from @cherrystudio/openai SDK. + */ + +import type { ProviderOptions, ToolCallPart, ToolResultPart } from '@ai-sdk/provider-utils' +import type OpenAI from '@cherrystudio/openai' +import type { Provider } from '@types' +import type { ImagePart, ModelMessage, TextPart, Tool as AiSdkTool } from 'ai' +import { tool, zodSchema } from 'ai' + +import type { IMessageConverter, StreamTextOptions } from '../interfaces' +import { type JsonSchemaLike, jsonSchemaToZod } from './json-schema-to-zod' +import type { ReasoningEffort } from './provider-options-mapper' +import { mapReasoningEffortToProviderOptions } from './provider-options-mapper' + +// SDK types +type ResponseCreateParams = OpenAI.Responses.ResponseCreateParams +type EasyInputMessage = OpenAI.Responses.EasyInputMessage +type FunctionTool = OpenAI.Responses.FunctionTool +type ResponseInputText = OpenAI.Responses.ResponseInputText +type ResponseInputImage = OpenAI.Responses.ResponseInputImage + +/** + * Extended ResponseCreateParams with reasoning_effort + */ +export type ResponsesCreateParams = ResponseCreateParams & { + reasoning_effort?: ReasoningEffort +} + +/** + * OpenAI Responses Message Converter + */ +export class OpenAIResponsesMessageConverter implements IMessageConverter { + /** + * Convert Responses API params to AI SDK ModelMessage[] + */ + toAiSdkMessages(params: ResponsesCreateParams): ModelMessage[] { + const messages: ModelMessage[] = [] + + // Add instructions as system message if present + if (params.instructions && typeof params.instructions === 'string') { + messages.push({ role: 'system', content: params.instructions }) + } + + // Handle no input + if (!params.input) { + return messages + } + + // Handle string input + if (typeof params.input === 'string') { + messages.push({ role: 'user', content: params.input }) + return messages + } + + // Handle message array input + const inputArray = params.input + + // Build tool call ID to name mapping for tool results + const toolCallIdToName = new Map() + for (const item of inputArray) { + // Handle ResponseFunctionToolCall + if ('type' in item && item.type === 'function_call' && 'call_id' in item && 'name' in item) { + const funcCall = item as OpenAI.Responses.ResponseFunctionToolCall + toolCallIdToName.set(funcCall.call_id, funcCall.name) + } + } + + for (const item of inputArray) { + const converted = this.convertInputItem(item, toolCallIdToName) + if (converted.length > 0) { + messages.push(...converted) + } + } + + return messages + } + + /** + * Convert a single input item to AI SDK message(s) + */ + private convertInputItem( + item: OpenAI.Responses.ResponseInputItem, + toolCallIdToName: Map + ): ModelMessage[] { + // Handle EasyInputMessage (has role and content) + if ('role' in item && 'content' in item) { + return this.convertEasyInputMessage(item as EasyInputMessage) + } + + // Handle function_call_output + if ('type' in item && item.type === 'function_call_output') { + const output = item as OpenAI.Responses.ResponseInputItem.FunctionCallOutput + const outputStr = typeof output.output === 'string' ? output.output : JSON.stringify(output.output) + return this.convertFunctionCallOutput(output.call_id, outputStr, toolCallIdToName) + } + + return [] + } + + /** + * Convert EasyInputMessage to AI SDK message + */ + private convertEasyInputMessage(msg: EasyInputMessage): ModelMessage[] { + switch (msg.role) { + case 'developer': + case 'system': + return this.convertSystemMessage(msg.content) + case 'user': + return this.convertUserMessage(msg.content) + case 'assistant': + return this.convertAssistantMessage(msg.content) + default: + return [] + } + } + + /** + * Convert system message content + */ + private convertSystemMessage(content: EasyInputMessage['content']): ModelMessage[] { + if (typeof content === 'string') { + return [{ role: 'system', content }] + } + + // Array content - extract text from input_text parts + const textParts: string[] = [] + for (const part of content) { + if (part.type === 'input_text') { + textParts.push((part as ResponseInputText).text) + } + } + + if (textParts.length > 0) { + return [{ role: 'system', content: textParts.join('\n') }] + } + + return [] + } + + /** + * Convert user message content + */ + private convertUserMessage(content: EasyInputMessage['content']): ModelMessage[] { + if (typeof content === 'string') { + return [{ role: 'user', content }] + } + + const parts: (TextPart | ImagePart)[] = [] + + for (const part of content) { + if (part.type === 'input_text') { + parts.push({ type: 'text', text: (part as ResponseInputText).text }) + } else if (part.type === 'input_image') { + const img = part as ResponseInputImage + if (img.image_url) { + parts.push({ type: 'image', image: img.image_url }) + } + } + } + + if (parts.length > 0) { + return [{ role: 'user', content: parts }] + } + + return [] + } + + /** + * Convert assistant message content + */ + private convertAssistantMessage(content: EasyInputMessage['content']): ModelMessage[] { + const parts: (TextPart | ToolCallPart)[] = [] + + if (typeof content === 'string') { + parts.push({ type: 'text', text: content }) + } else { + for (const part of content) { + // input_text can appear in assistant messages in conversation history + if (part.type === 'input_text') { + parts.push({ type: 'text', text: (part as ResponseInputText).text }) + } + } + } + + if (parts.length > 0) { + return [{ role: 'assistant', content: parts }] + } + + return [] + } + + /** + * Convert function call output to tool result + */ + private convertFunctionCallOutput( + callId: string, + output: string, + toolCallIdToName: Map + ): ModelMessage[] { + const toolName = toolCallIdToName.get(callId) || 'unknown' + + const toolResultPart: ToolResultPart = { + type: 'tool-result', + toolCallId: callId, + toolName, + output: { type: 'text', value: output } + } + + return [{ role: 'tool', content: [toolResultPart] }] + } + + /** + * Convert Responses API tools to AI SDK tools + */ + toAiSdkTools(params: ResponsesCreateParams): Record | undefined { + const tools = params.tools + if (!tools || tools.length === 0) return undefined + + const aiSdkTools: Record = {} + + for (const toolDef of tools) { + if (toolDef.type !== 'function') continue + + const funcTool = toolDef as FunctionTool + const rawSchema = funcTool.parameters + const schema = rawSchema ? jsonSchemaToZod(rawSchema as JsonSchemaLike) : jsonSchemaToZod({ type: 'object' }) + + const aiTool = tool({ + description: funcTool.description || '', + inputSchema: zodSchema(schema) + }) + + aiSdkTools[funcTool.name] = aiTool + } + + return Object.keys(aiSdkTools).length > 0 ? aiSdkTools : undefined + } + + /** + * Extract stream/generation options from Responses API params + */ + extractStreamOptions(params: ResponsesCreateParams): StreamTextOptions { + return { + maxOutputTokens: params.max_output_tokens ?? undefined, + temperature: params.temperature ?? undefined, + topP: params.top_p ?? undefined + } + } + + /** + * Extract provider-specific options from Responses API params + */ + extractProviderOptions(provider: Provider, params: ResponsesCreateParams): ProviderOptions | undefined { + return mapReasoningEffortToProviderOptions(provider, params.reasoning_effort) + } +} + +export default OpenAIResponsesMessageConverter diff --git a/src/main/apiServer/adapters/converters/provider-options-mapper.ts b/src/main/apiServer/adapters/converters/provider-options-mapper.ts index c9a0ea99d5..4289d60b29 100644 --- a/src/main/apiServer/adapters/converters/provider-options-mapper.ts +++ b/src/main/apiServer/adapters/converters/provider-options-mapper.ts @@ -20,6 +20,9 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils' import type { XaiProviderOptions } from '@ai-sdk/xai' import type { MessageCreateParams } from '@anthropic-ai/sdk/resources/messages' import type { ReasoningEffort } from '@cherrystudio/openai/resources' + +// Re-export for use by message converters +export type { ReasoningEffort } import type { OpenRouterProviderOptions } from '@openrouter/ai-sdk-provider' import { SystemProviderIds } from '@shared/types' import { isAnthropicProvider, isAwsBedrockProvider, isGeminiProvider, isOpenAIProvider } from '@shared/utils/provider' diff --git a/src/main/apiServer/adapters/factory/MessageConverterFactory.ts b/src/main/apiServer/adapters/factory/MessageConverterFactory.ts index 9513bc9462..8d1fa18d66 100644 --- a/src/main/apiServer/adapters/factory/MessageConverterFactory.ts +++ b/src/main/apiServer/adapters/factory/MessageConverterFactory.ts @@ -9,6 +9,10 @@ import type { MessageCreateParams } from '@anthropic-ai/sdk/resources/messages' import { AnthropicMessageConverter, type ReasoningCache } from '../converters/AnthropicMessageConverter' import { type ExtendedChatCompletionCreateParams, OpenAIMessageConverter } from '../converters/OpenAIMessageConverter' +import { + OpenAIResponsesMessageConverter, + type ResponsesCreateParams +} from '../converters/OpenAIResponsesMessageConverter' import type { IMessageConverter, InputFormat } from '../interfaces' /** @@ -17,6 +21,7 @@ import type { IMessageConverter, InputFormat } from '../interfaces' export type InputParamsMap = { openai: ExtendedChatCompletionCreateParams anthropic: MessageCreateParams + 'openai-responses': ResponsesCreateParams } /** @@ -58,6 +63,9 @@ export class MessageConverterFactory { if (format === 'openai') { return new OpenAIMessageConverter() as IMessageConverter } + if (format === 'openai-responses') { + return new OpenAIResponsesMessageConverter() as IMessageConverter + } return new AnthropicMessageConverter({ googleReasoningCache: options.googleReasoningCache, openRouterReasoningCache: options.openRouterReasoningCache @@ -68,14 +76,14 @@ export class MessageConverterFactory { * Check if a format is supported */ static supportsFormat(format: string): format is InputFormat { - return format === 'openai' || format === 'anthropic' + return format === 'openai' || format === 'anthropic' || format === 'openai-responses' } /** * Get list of all supported formats */ static getSupportedFormats(): InputFormat[] { - return ['openai', 'anthropic'] + return ['openai', 'anthropic', 'openai-responses'] } } diff --git a/src/main/apiServer/adapters/factory/StreamAdapterFactory.ts b/src/main/apiServer/adapters/factory/StreamAdapterFactory.ts index 3f686de44d..a184657a27 100644 --- a/src/main/apiServer/adapters/factory/StreamAdapterFactory.ts +++ b/src/main/apiServer/adapters/factory/StreamAdapterFactory.ts @@ -6,9 +6,11 @@ */ import { AnthropicSSEFormatter } from '../formatters/AnthropicSSEFormatter' +import { OpenAIResponsesSSEFormatter } from '../formatters/OpenAIResponsesSSEFormatter' import { OpenAISSEFormatter } from '../formatters/OpenAISSEFormatter' import type { ISSEFormatter, IStreamAdapter, OutputFormat, StreamAdapterOptions } from '../interfaces' import { AiSdkToAnthropicSSE } from '../stream/AiSdkToAnthropicSSE' +import { AiSdkToOpenAIResponsesSSE } from '../stream/AiSdkToOpenAIResponsesSSE' import { AiSdkToOpenAISSE } from '../stream/AiSdkToOpenAISSE' /** @@ -51,6 +53,13 @@ export class StreamAdapterFactory { adapterClass: AiSdkToOpenAISSE, formatterClass: OpenAISSEFormatter } + ], + [ + 'openai-responses', + { + adapterClass: AiSdkToOpenAIResponsesSSE, + formatterClass: OpenAIResponsesSSEFormatter + } ] ]) diff --git a/src/main/apiServer/adapters/formatters/OpenAIResponsesSSEFormatter.ts b/src/main/apiServer/adapters/formatters/OpenAIResponsesSSEFormatter.ts new file mode 100644 index 0000000000..004dfdb02c --- /dev/null +++ b/src/main/apiServer/adapters/formatters/OpenAIResponsesSSEFormatter.ts @@ -0,0 +1,50 @@ +/** + * OpenAI Responses API SSE Formatter + * + * Formats OpenAI Responses API stream events for Server-Sent Events. + * Responses API uses named events with semantic types: + * - event: {type}\n + * - data: {json}\n\n + * + * @see https://platform.openai.com/docs/api-reference/responses-streaming + */ + +import type OpenAI from '@cherrystudio/openai' + +import type { ISSEFormatter } from '../interfaces' + +/** + * Use SDK type for ResponseStreamEvent + */ +type ResponseStreamEvent = OpenAI.Responses.ResponseStreamEvent + +/** + * OpenAI Responses API SSE Formatter + * + * Unlike Chat Completions API which uses only `data:` lines, + * Responses API uses named events with `event:` and `data:` lines. + */ +export class OpenAIResponsesSSEFormatter implements ISSEFormatter { + /** + * Format a Responses API event for SSE streaming + * + * @example + * event: response.created + * data: {"type":"response.created","response":{...}} + * + * event: response.output_text.delta + * data: {"type":"response.output_text.delta","delta":"Hello"} + */ + formatEvent(event: ResponseStreamEvent): string { + return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n` + } + + /** + * Format the stream termination marker + */ + formatDone(): string { + return 'data: [DONE]\n\n' + } +} + +export default OpenAIResponsesSSEFormatter diff --git a/src/main/apiServer/adapters/index.ts b/src/main/apiServer/adapters/index.ts index 410aebc3b0..eefac4e064 100644 --- a/src/main/apiServer/adapters/index.ts +++ b/src/main/apiServer/adapters/index.ts @@ -12,6 +12,7 @@ // Stream Adapters export { AiSdkToAnthropicSSE } from './stream/AiSdkToAnthropicSSE' +export { AiSdkToOpenAIResponsesSSE } from './stream/AiSdkToOpenAIResponsesSSE' export { AiSdkToOpenAISSE } from './stream/AiSdkToOpenAISSE' export { BaseStreamAdapter } from './stream/BaseStreamAdapter' @@ -19,9 +20,14 @@ export { BaseStreamAdapter } from './stream/BaseStreamAdapter' export { AnthropicMessageConverter, type ReasoningCache } from './converters/AnthropicMessageConverter' export { type JsonSchemaLike, jsonSchemaToZod } from './converters/json-schema-to-zod' export { type ExtendedChatCompletionCreateParams, OpenAIMessageConverter } from './converters/OpenAIMessageConverter' +export { + OpenAIResponsesMessageConverter, + type ResponsesCreateParams +} from './converters/OpenAIResponsesMessageConverter' // SSE Formatters export { AnthropicSSEFormatter } from './formatters/AnthropicSSEFormatter' +export { OpenAIResponsesSSEFormatter } from './formatters/OpenAIResponsesSSEFormatter' export { type ChatCompletionChunk, OpenAISSEFormatter } from './formatters/OpenAISSEFormatter' // Factory diff --git a/src/main/apiServer/adapters/interfaces.ts b/src/main/apiServer/adapters/interfaces.ts index b4b4f7b93c..2b2ba19232 100644 --- a/src/main/apiServer/adapters/interfaces.ts +++ b/src/main/apiServer/adapters/interfaces.ts @@ -19,7 +19,7 @@ export type OutputFormat = 'anthropic' | 'openai' | 'gemini' | 'openai-responses /** * Supported input formats for message converters */ -export type InputFormat = 'anthropic' | 'openai' +export type InputFormat = 'anthropic' | 'openai' | 'openai-responses' /** * Stream text options extracted from input params diff --git a/src/main/apiServer/adapters/stream/AiSdkToOpenAIResponsesSSE.ts b/src/main/apiServer/adapters/stream/AiSdkToOpenAIResponsesSSE.ts new file mode 100644 index 0000000000..d02cde5f48 --- /dev/null +++ b/src/main/apiServer/adapters/stream/AiSdkToOpenAIResponsesSSE.ts @@ -0,0 +1,420 @@ +/** + * AI SDK to OpenAI Responses API SSE Adapter + * + * Converts AI SDK's fullStream (TextStreamPart) events to OpenAI Responses API SSE format. + * This adapter emits semantic events like: + * - response.created + * - response.in_progress + * - response.output_item.added + * - response.content_part.added + * - response.output_text.delta + * - response.output_text.done + * - response.completed + * + * @see https://platform.openai.com/docs/api-reference/responses-streaming + */ + +import type OpenAI from '@cherrystudio/openai' +import { loggerService } from '@logger' +import type { FinishReason, LanguageModelUsage, TextStreamPart, ToolSet } from 'ai' + +import type { StreamAdapterOptions } from '../interfaces' +import { BaseStreamAdapter } from './BaseStreamAdapter' + +const logger = loggerService.withContext('AiSdkToOpenAIResponsesSSE') + +/** + * Use SDK types for events + */ +type Response = OpenAI.Responses.Response +type ResponseStreamEvent = OpenAI.Responses.ResponseStreamEvent +type ResponseUsage = OpenAI.Responses.ResponseUsage +type ResponseOutputMessage = OpenAI.Responses.ResponseOutputMessage +type ResponseOutputText = OpenAI.Responses.ResponseOutputText + +/** + * Minimal response fields required for streaming. + * Uses Pick to select only necessary fields from the full Response type. + */ +type StreamingResponseFields = Pick + +/** + * Partial response type for streaming that includes optional usage. + * During streaming, we only emit a subset of fields. + */ +type PartialStreamingResponse = StreamingResponseFields & { + usage?: Partial +} + +/** + * Minimal usage type for streaming responses. + * The SDK's ResponseUsage requires input_tokens_details and output_tokens_details, + * but during streaming we may only have the basic token counts. + */ +type StreamingUsage = Pick + +/** + * OpenAI Responses finish reasons + */ +type ResponsesFinishReason = 'stop' | 'max_output_tokens' | 'content_filter' | 'tool_calls' | 'cancelled' | null + +/** + * Tool call state for tracking + */ +interface ToolCallState { + index: number + callId: string + name: string + arguments: string +} + +/** + * Adapter that converts AI SDK fullStream events to OpenAI Responses API SSE events + */ +export class AiSdkToOpenAIResponsesSSE extends BaseStreamAdapter { + private createdAt: number + private sequenceNumber = 0 + private toolCalls: Map = new Map() + private currentToolCallIndex = 0 + private finishReason: ResponsesFinishReason = null + private textContent = '' + private outputItemId: string + private contentPartIndex = 0 + + constructor(options: StreamAdapterOptions) { + super(options) + this.createdAt = Math.floor(Date.now() / 1000) + this.outputItemId = `msg_${this.state.messageId}` + } + + /** + * Get next sequence number + */ + private nextSequence(): number { + return this.sequenceNumber++ + } + + /** + * Build base response object for streaming events. + * Returns a partial response with only the fields needed for streaming. + * Cast to Response for SDK compatibility - streaming events intentionally + * omit fields that are not available until completion. + */ + private buildBaseResponse(status: 'in_progress' | 'completed' | 'failed' = 'in_progress'): PartialStreamingResponse { + return { + id: `resp_${this.state.messageId}`, + object: 'response', + created_at: this.createdAt, + status, + model: this.state.model, + output: [], + usage: this.buildUsage() + } + } + + /** + * Build usage object for streaming responses. + * Uses StreamingUsage which only includes basic token counts, + * omitting the detailed breakdowns (input_tokens_details, output_tokens_details) + * that are not available during streaming. + */ + private buildUsage(): StreamingUsage { + return { + input_tokens: this.state.inputTokens, + output_tokens: this.state.outputTokens, + total_tokens: this.state.inputTokens + this.state.outputTokens + } + } + + /** + * Build base response and cast to Response for event emission. + * This is safe because streaming consumers expect partial data. + */ + private buildResponseForEvent(status: 'in_progress' | 'completed' | 'failed' = 'in_progress'): Response { + return this.buildBaseResponse(status) as Response + } + + /** + * Emit the initial message start events + */ + protected emitMessageStart(): void { + if (this.state.hasEmittedMessageStart) return + this.state.hasEmittedMessageStart = true + + // Emit response.created + const createdEvent: ResponseStreamEvent = { + type: 'response.created', + response: this.buildResponseForEvent('in_progress'), + sequence_number: this.nextSequence() + } + this.emit(createdEvent) + + // Emit response.in_progress + const inProgressEvent: ResponseStreamEvent = { + type: 'response.in_progress', + response: this.buildResponseForEvent('in_progress'), + sequence_number: this.nextSequence() + } + this.emit(inProgressEvent) + + // Emit output_item.added for the message + const outputItemAddedEvent: ResponseStreamEvent = { + type: 'response.output_item.added', + output_index: 0, + item: this.buildOutputMessage(), + sequence_number: this.nextSequence() + } + this.emit(outputItemAddedEvent) + + // Emit content_part.added for text + const contentPartAddedEvent: ResponseStreamEvent = { + type: 'response.content_part.added', + item_id: this.outputItemId, + output_index: 0, + content_index: this.contentPartIndex, + part: { + type: 'output_text', + text: '', + annotations: [] + }, + sequence_number: this.nextSequence() + } + this.emit(contentPartAddedEvent) + } + + /** + * Build output message object + */ + private buildOutputMessage(): ResponseOutputMessage { + return { + type: 'message', + id: this.outputItemId, + status: 'in_progress', + role: 'assistant', + content: [] + } + } + + /** + * Process a single AI SDK chunk and emit corresponding Responses API events + */ + protected processChunk(chunk: TextStreamPart): void { + logger.silly('AiSdkToOpenAIResponsesSSE - Processing chunk:', { chunk: JSON.stringify(chunk) }) + + switch (chunk.type) { + case 'text-delta': + this.emitTextDelta(chunk.text || '') + break + + case 'tool-call': + this.handleToolCall({ + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + args: chunk.input + }) + break + + case 'finish-step': + if (chunk.finishReason === 'tool-calls') { + this.finishReason = 'tool_calls' + } + break + + case 'finish': + this.handleFinish(chunk) + break + + case 'error': + throw chunk.error + + default: + break + } + } + + /** + * Emit text delta event + */ + private emitTextDelta(delta: string): void { + if (!delta) return + + this.textContent += delta + + const event: ResponseStreamEvent = { + type: 'response.output_text.delta', + item_id: this.outputItemId, + output_index: 0, + content_index: this.contentPartIndex, + delta, + logprobs: [], + sequence_number: this.nextSequence() + } + this.emit(event) + } + + /** + * Handle tool call + */ + private handleToolCall(params: { toolCallId: string; toolName: string; args: unknown }): void { + const { toolCallId, toolName, args } = params + + if (this.toolCalls.has(toolCallId)) { + return + } + + const index = this.currentToolCallIndex++ + const argsString = JSON.stringify(args) + + this.toolCalls.set(toolCallId, { + index, + callId: toolCallId, + name: toolName, + arguments: argsString + }) + + this.finishReason = 'tool_calls' + } + + /** + * Handle finish event + */ + private handleFinish(chunk: { type: 'finish'; finishReason?: FinishReason; totalUsage?: LanguageModelUsage }): void { + if (chunk.totalUsage) { + this.state.inputTokens = chunk.totalUsage.inputTokens || 0 + this.state.outputTokens = chunk.totalUsage.outputTokens || 0 + } + + if (!this.finishReason) { + switch (chunk.finishReason) { + case 'stop': + this.finishReason = 'stop' + break + case 'length': + this.finishReason = 'max_output_tokens' + break + case 'tool-calls': + this.finishReason = 'tool_calls' + break + case 'content-filter': + this.finishReason = 'content_filter' + break + default: + this.finishReason = 'stop' + } + } + + this.state.stopReason = this.finishReason + } + + /** + * Finalize the stream and emit closing events + */ + protected finalize(): void { + // Emit output_text.done + const textDoneEvent: ResponseStreamEvent = { + type: 'response.output_text.done', + item_id: this.outputItemId, + output_index: 0, + content_index: this.contentPartIndex, + text: this.textContent, + logprobs: [], + sequence_number: this.nextSequence() + } + this.emit(textDoneEvent) + + // Emit content_part.done + const contentPartDoneEvent: ResponseStreamEvent = { + type: 'response.content_part.done', + item_id: this.outputItemId, + output_index: 0, + content_index: this.contentPartIndex, + part: { + type: 'output_text', + text: this.textContent, + annotations: [] + }, + sequence_number: this.nextSequence() + } + this.emit(contentPartDoneEvent) + + // Emit output_item.done + const outputItemDoneEvent: ResponseStreamEvent = { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'message', + id: this.outputItemId, + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: this.textContent, + annotations: [] + } as ResponseOutputText + ] + }, + sequence_number: this.nextSequence() + } + this.emit(outputItemDoneEvent) + + // Emit response.completed + const completedEvent: ResponseStreamEvent = { + type: 'response.completed', + response: { + ...this.buildResponseForEvent('completed'), + output: [ + { + type: 'message', + id: this.outputItemId, + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: this.textContent, + annotations: [] + } as ResponseOutputText + ] + } + ] + }, + sequence_number: this.nextSequence() + } + this.emit(completedEvent) + } + + /** + * Build a complete Response object for non-streaming responses. + * Returns a partial response cast to Response type. + */ + buildNonStreamingResponse(): Response { + const outputText: ResponseOutputText = { + type: 'output_text', + text: this.textContent, + annotations: [] + } + + const outputMessage: ResponseOutputMessage = { + type: 'message', + id: this.outputItemId, + status: 'completed', + role: 'assistant', + content: [outputText] + } + + const partialResponse: PartialStreamingResponse = { + id: `resp_${this.state.messageId}`, + object: 'response', + created_at: this.createdAt, + status: 'completed', + model: this.state.model, + output: [outputMessage], + usage: this.buildUsage() + } + + return partialResponse as Response + } +} + +export default AiSdkToOpenAIResponsesSSE diff --git a/src/main/apiServer/app.ts b/src/main/apiServer/app.ts index 51d4c3dc21..cf6b4d24fc 100644 --- a/src/main/apiServer/app.ts +++ b/src/main/apiServer/app.ts @@ -13,6 +13,7 @@ import { chatRoutes } from './routes/chat' import { mcpRoutes } from './routes/mcp' import { messagesProviderRoutes, messagesRoutes } from './routes/messages' import { modelsRoutes } from './routes/models' +import { responsesRoutes } from './routes/responses' const logger = loggerService.withContext('ApiServer') @@ -150,6 +151,7 @@ apiRouter.use('/mcps', mcpRoutes) apiRouter.use('/messages', extendMessagesTimeout, messagesRoutes) apiRouter.use('/models', modelsRoutes) // Always enabled apiRouter.use('/agents', agentsRoutes) +apiRouter.use('/responses', extendMessagesTimeout, responsesRoutes) app.use('/v1', apiRouter) // Error handling (must be last) diff --git a/src/main/apiServer/routes/responses.ts b/src/main/apiServer/routes/responses.ts new file mode 100644 index 0000000000..7d1f5c8dfa --- /dev/null +++ b/src/main/apiServer/routes/responses.ts @@ -0,0 +1,306 @@ +import type OpenAI from '@cherrystudio/openai' +import { isOpenAIProvider } from '@shared/utils/provider' +import type { Provider } from '@types' +import type { Request, Response } from 'express' +import express from 'express' + +import { loggerService } from '../../services/LoggerService' +import type { ResponsesCreateParams } from '../adapters' +import { processMessage } from '../services/ProxyStreamService' +import { responsesService } from '../services/responses' +import { isModelOpenAIResponsesCompatible, validateModelId } from '../utils' + +// Use SDK namespace types +type ResponseCreateParams = OpenAI.Responses.ResponseCreateParams + +const logger = loggerService.withContext('ApiServerResponsesRoutes') + +const router = express.Router() + +/** + * Check if provider+model should use direct OpenAI Responses API SDK + * + * A provider+model combination is considered "OpenAI Responses-compatible" if: + * 1. It's a native OpenAI Responses provider (type === 'openai-response'), OR + * 2. For aggregated providers (new-api/cherryin), the model has endpoint_type === 'openai-response' + * + * For these combinations, we bypass AI SDK conversion and use direct passthrough. + */ +function shouldUseDirectOpenAIResponses(provider: Provider, modelId: string): boolean { + // Native OpenAI Responses provider - always use direct SDK + if (isOpenAIProvider(provider)) { + return true + } + + // Check model-level compatibility for aggregated providers + return isModelOpenAIResponsesCompatible(provider, modelId) +} + +interface HandleResponseProcessingOptions { + res: Response + provider: Provider + request: ResponseCreateParams + modelId?: string +} + +/** + * Handle response processing using direct OpenAI SDK + * Used for native OpenAI providers - bypasses AI SDK conversion + */ +async function handleDirectOpenAIResponsesProcessing({ + res, + provider, + request, + modelId +}: HandleResponseProcessingOptions): Promise { + // modelId is guaranteed to be set by caller after validation + const actualModelId = modelId! + + logger.info('Processing response via direct OpenAI SDK', { + providerId: provider.id, + providerType: provider.type, + modelId: actualModelId, + stream: !!request.stream + }) + + try { + const validation = responsesService.validateRequest(request) + if (!validation.isValid) { + res.status(400).json({ + error: { + message: validation.errors.join('; '), + type: 'invalid_request_error', + code: 'validation_error' + } + }) + return + } + + const { client, openaiRequest } = await responsesService.processResponse({ + provider, + request, + modelId: actualModelId + }) + + if (request.stream) { + await responsesService.handleStreaming(client, openaiRequest, { response: res }, provider) + } else { + const response = await responsesService.handleNonStreaming(client, openaiRequest) + res.json(response) + } + } catch (error: unknown) { + logger.error('Direct OpenAI Responses processing error', { error }) + const { statusCode, errorResponse } = responsesService.transformError(error) + res.status(statusCode).json(errorResponse) + } +} + +/** + * Handle response processing using unified AI SDK + * Used for non-OpenAI providers that need format conversion + */ +async function handleUnifiedProcessing({ + res, + provider, + request, + modelId +}: HandleResponseProcessingOptions): Promise { + // modelId is guaranteed to be set by caller after validation + const actualModelId = modelId! + + logger.info('Processing response via unified AI SDK', { + providerId: provider.id, + providerType: provider.type, + modelId: actualModelId, + stream: !!request.stream + }) + + try { + await processMessage({ + response: res, + provider, + modelId: actualModelId, + params: request as ResponsesCreateParams, + inputFormat: 'openai-responses', + outputFormat: 'openai-responses', + onError: (error) => { + logger.error('Response error', error as Error) + }, + onComplete: () => { + logger.debug('Response completed') + } + }) + } catch (error: unknown) { + logger.error('Unified processing error', { error }) + const { statusCode, errorResponse } = responsesService.transformError(error) + res.status(statusCode).json(errorResponse) + } +} + +/** + * Handle response processing - routes to appropriate handler based on provider and model + * + * Routing logic: + * - Native OpenAI Responses providers (type === 'openai-response'): Direct OpenAI SDK + * - Aggregated providers with model.endpoint_type === 'openai-response': Direct OpenAI SDK + * - Other providers/models: Unified AI SDK with Responses API conversion + */ +async function handleResponseProcessing(options: HandleResponseProcessingOptions): Promise { + const { provider, modelId } = options + const actualModelId = modelId! + + if (shouldUseDirectOpenAIResponses(provider, actualModelId)) { + return handleDirectOpenAIResponsesProcessing(options) + } + return handleUnifiedProcessing(options) +} + +/** + * @swagger + * /v1/responses: + * post: + * summary: Create a response + * description: Create a response using OpenAI Responses API format + * tags: [Responses] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - model + * - input + * properties: + * model: + * type: string + * description: Model ID in format provider:model or model + * input: + * oneOf: + * - type: string + * - type: array + * items: + * type: object + * description: The input to generate a response for + * instructions: + * type: string + * description: System instructions for the model + * stream: + * type: boolean + * description: Whether to stream the response + * max_output_tokens: + * type: integer + * description: Maximum number of output tokens + * temperature: + * type: number + * description: Sampling temperature + * tools: + * type: array + * description: Tools available to the model + * responses: + * 200: + * description: Response created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * object: + * type: string + * example: response + * created_at: + * type: integer + * status: + * type: string + * model: + * type: string + * output: + * type: array + * items: + * type: object + * usage: + * type: object + * text/event-stream: + * schema: + * type: string + * description: Server-sent events stream (when stream=true) + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.post('/', async (req: Request, res: Response) => { + try { + const request = req.body as ResponseCreateParams + + if (!request) { + return res.status(400).json({ + error: { + message: 'Request body is required', + type: 'invalid_request_error', + code: 'missing_body' + } + }) + } + + if (!request.model) { + return res.status(400).json({ + error: { + message: 'Model is required', + type: 'invalid_request_error', + code: 'missing_model' + } + }) + } + + // Responses API uses 'input' instead of 'messages' + if (request.input === undefined || request.input === null) { + return res.status(400).json({ + error: { + message: 'Input is required', + type: 'invalid_request_error', + code: 'missing_input' + } + }) + } + + logger.debug('Responses API request', { + model: request.model, + inputType: typeof request.input, + stream: request.stream, + temperature: request.temperature + }) + + // Validate model and get provider + const modelValidation = await validateModelId(request.model) + if (!modelValidation.valid) { + return res.status(400).json({ + error: { + message: modelValidation.error?.message || 'Model not found', + type: 'invalid_request_error', + code: modelValidation.error?.code || 'model_not_found' + } + }) + } + + const provider = modelValidation.provider! + const modelId = modelValidation.modelId! + + return handleResponseProcessing({ + res, + provider, + request, + modelId + }) + } catch (error: unknown) { + logger.error('Responses API error', { error }) + const { statusCode, errorResponse } = responsesService.transformError(error) + return res.status(statusCode).json(errorResponse) + } +}) + +export { router as responsesRoutes } diff --git a/src/main/apiServer/services/responses.ts b/src/main/apiServer/services/responses.ts new file mode 100644 index 0000000000..f73b8931d8 --- /dev/null +++ b/src/main/apiServer/services/responses.ts @@ -0,0 +1,299 @@ +/** + * OpenAI Responses API Service + * + * Provides direct passthrough to OpenAI Responses API for native OpenAI providers, + * bypassing AI SDK conversion for optimal performance. + * + * Similar to MessagesService for Anthropic, this service handles: + * - Request validation + * - Client creation with proper authentication + * - Streaming with SSE event forwarding + * - Non-streaming request handling + */ + +import OpenAI from '@cherrystudio/openai' +import { loggerService } from '@logger' +import type { Provider } from '@types' +import { net } from 'electron' +import type { Response } from 'express' + +// Use SDK namespace types +type ResponseCreateParams = OpenAI.Responses.ResponseCreateParams +type ResponseStreamEvent = OpenAI.Responses.ResponseStreamEvent +type ResponseObject = OpenAI.Responses.Response + +const logger = loggerService.withContext('ResponsesService') + +export interface ValidationResult { + isValid: boolean + errors: string[] +} + +export interface ErrorResponse { + error: { + message: string + type: string + code: string + } +} + +export interface StreamConfig { + response: Response + onChunk?: (chunk: ResponseStreamEvent) => void + onError?: (error: unknown) => void + onComplete?: () => void +} + +export interface ProcessResponseOptions { + provider: Provider + request: ResponseCreateParams + modelId?: string +} + +export interface ProcessResponseResult { + client: OpenAI + openaiRequest: ResponseCreateParams +} + +export class ResponsesService { + validateRequest(request: ResponseCreateParams): ValidationResult { + const errors: string[] = [] + + if (!request.model || typeof request.model !== 'string') { + errors.push('Model is required') + } + + if (request.input === undefined || request.input === null) { + errors.push('Input is required') + } + + return { + isValid: errors.length === 0, + errors + } + } + + async getClient(provider: Provider): Promise { + // Create OpenAI client with Electron's net.fetch + const electronFetch: typeof globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + if (init) { + const initWithAgent = init as RequestInit & { agent?: unknown } + delete initWithAgent.agent + const headers = new Headers(initWithAgent.headers) + if (headers.has('content-length')) { + headers.delete('content-length') + } + initWithAgent.headers = headers + return net.fetch(url, initWithAgent) + } + return net.fetch(url) + } + + const baseURL = provider.apiHost?.replace(/\/$/, '') + + return new OpenAI({ + apiKey: provider.apiKey || '', + baseURL, + fetch: electronFetch + }) + } + + createOpenAIRequest(request: ResponseCreateParams, modelId?: string): ResponseCreateParams { + const openaiRequest: ResponseCreateParams = { + ...request + } + + if (modelId) { + openaiRequest.model = modelId + } + + return openaiRequest + } + + async handleStreaming( + client: OpenAI, + request: ResponseCreateParams, + config: StreamConfig, + provider: Provider + ): Promise { + const { response, onChunk, onError, onComplete } = config + + // Set streaming headers + response.setHeader('Content-Type', 'text/event-stream; charset=utf-8') + response.setHeader('Cache-Control', 'no-cache, no-transform') + response.setHeader('Connection', 'keep-alive') + response.setHeader('X-Accel-Buffering', 'no') + response.flushHeaders() + + const flushableResponse = response as Response & { flush?: () => void } + const flushStream = () => { + if (typeof flushableResponse.flush !== 'function') { + return + } + try { + flushableResponse.flush() + } catch (flushError: unknown) { + logger.warn('Failed to flush streaming response', { error: flushError }) + } + } + + const writeSse = (event: ResponseStreamEvent) => { + if (response.writableEnded || response.destroyed) { + return + } + + // Responses API uses named events + response.write(`event: ${event.type}\n`) + response.write(`data: ${JSON.stringify(event)}\n\n`) + flushStream() + } + + try { + // Use stream: true to get Stream + const stream = await client.responses.create({ + ...request, + stream: true + } as OpenAI.Responses.ResponseCreateParamsStreaming) + + for await (const chunk of stream) { + if (response.writableEnded || response.destroyed) { + logger.warn('Streaming response ended before stream completion', { + provider: provider.id, + model: request.model + }) + break + } + + writeSse(chunk) + + if (onChunk) { + onChunk(chunk) + } + } + + // Send done marker + if (!response.writableEnded) { + response.write('data: [DONE]\n\n') + flushStream() + } + + if (onComplete) { + onComplete() + } + } catch (streamError: unknown) { + logger.error('Stream error', { + error: streamError, + provider: provider.id, + model: request.model + }) + + if (!response.writableEnded) { + const errorEvent = { + type: 'error', + error: { + message: streamError instanceof Error ? streamError.message : 'Stream processing error', + type: 'api_error', + code: 'stream_error' + } + } + response.write(`event: error\n`) + response.write(`data: ${JSON.stringify(errorEvent)}\n\n`) + flushStream() + } + + if (onError) { + onError(streamError) + } + } finally { + if (!response.writableEnded) { + response.end() + } + } + } + + async handleNonStreaming(client: OpenAI, request: ResponseCreateParams): Promise { + return client.responses.create({ + ...request, + stream: false + } as OpenAI.Responses.ResponseCreateParamsNonStreaming) + } + + transformError(error: unknown): { statusCode: number; errorResponse: ErrorResponse } { + let statusCode = 500 + let errorType = 'server_error' + let errorCode = 'internal_error' + let errorMessage = 'Internal server error' + + if (error instanceof OpenAI.APIError) { + statusCode = error.status || 500 + errorMessage = error.message + + if (statusCode === 400) { + errorType = 'invalid_request_error' + errorCode = 'bad_request' + } else if (statusCode === 401) { + errorType = 'authentication_error' + errorCode = 'invalid_api_key' + } else if (statusCode === 403) { + errorType = 'forbidden_error' + errorCode = 'forbidden' + } else if (statusCode === 404) { + errorType = 'not_found_error' + errorCode = 'not_found' + } else if (statusCode === 429) { + errorType = 'rate_limit_error' + errorCode = 'rate_limit_exceeded' + } + } else if (error instanceof Error) { + errorMessage = error.message + + if (errorMessage.includes('API key') || errorMessage.includes('authentication')) { + statusCode = 401 + errorType = 'authentication_error' + errorCode = 'invalid_api_key' + } else if (errorMessage.includes('rate limit') || errorMessage.includes('quota')) { + statusCode = 429 + errorType = 'rate_limit_error' + errorCode = 'rate_limit_exceeded' + } else if (errorMessage.includes('timeout') || errorMessage.includes('connection')) { + statusCode = 502 + errorType = 'server_error' + errorCode = 'upstream_error' + } + } + + return { + statusCode, + errorResponse: { + error: { + message: errorMessage, + type: errorType, + code: errorCode + } + } + } + } + + async processResponse(options: ProcessResponseOptions): Promise { + const { provider, request, modelId } = options + + const client = await this.getClient(provider) + const openaiRequest = this.createOpenAIRequest(request, modelId) + + logger.info('Processing OpenAI Responses API request', { + provider: provider.id, + apiHost: provider.apiHost, + model: openaiRequest.model, + stream: !!request.stream, + inputType: typeof request.input + }) + + return { + client, + openaiRequest + } + } +} + +export const responsesService = new ResponsesService() diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index df27269178..5862ba9604 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,6 +1,7 @@ import { CacheService } from '@main/services/CacheService' import { loggerService } from '@main/services/LoggerService' import { reduxService } from '@main/services/ReduxService' +import { isOpenAILLMModel } from '@shared/aiCore/config/aihubmix' import { isPpioAnthropicCompatibleModel, isSiliconAnthropicCompatibleModel } from '@shared/config/providers' import type { ApiModel, Model, Provider } from '@types' @@ -274,6 +275,7 @@ export function validateProvider(provider: Provider): boolean { } } +// TODO: checker 和 ProviderCreator重构到一起 export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => { switch (providerId) { case 'cherryin': @@ -293,6 +295,25 @@ export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model } } +/** + * Get model checker for OpenAI Responses API compatibility + * + * Returns a function that checks if a model supports OpenAI Responses API endpoint. + * For aggregated providers, only certain models may support this endpoint. + */ +export const getProviderOpenAIResponsesModelChecker = (providerId: string): ((m: Model) => boolean) => { + switch (providerId) { + case 'cherryin': + case 'new-api': + // Check endpoint_type for responses API support + return (m: Model) => m.endpoint_type === 'openai-response' + case 'aihubmix': + return (m) => isOpenAILLMModel(m) + default: + return () => false + } +} + /** * Check if a specific model is compatible with Anthropic API for a given provider. * @@ -321,3 +342,32 @@ export function isModelAnthropicCompatible(provider: Provider, modelId: string): return checker(minimalModel) } + +/** + * Check if a specific model is compatible with OpenAI Responses API for a given provider. + * + * This is used for fine-grained routing decisions at the model level. + * For aggregated providers (like new-api/cherryin), only certain models support the Responses API endpoint. + * + * @param provider - The provider to check + * @param modelId - The model ID to check (without provider prefix) + * @returns true if the model supports OpenAI Responses API endpoint + */ +export function isModelOpenAIResponsesCompatible(provider: Provider, modelId: string): boolean { + const checker = getProviderOpenAIResponsesModelChecker(provider.id) + + const model = provider.models?.find((m) => m.id === modelId) + + if (model) { + return checker(model) + } + + const minimalModel: Model = { + id: modelId, + name: modelId, + provider: provider.id, + group: '' + } + + return checker(minimalModel) +}