feat: Add OpenAI Responses API support

- Introduced new routes and services for handling OpenAI Responses API requests.
- Implemented `AiSdkToOpenAIResponsesSSE` adapter for streaming responses.
- Added `OpenAIResponsesSSEFormatter` for formatting SSE events.
- Enhanced `MessageConverterFactory` to support 'openai-responses' format.
- Updated `StreamAdapterFactory` to include OpenAI Responses streaming.
- Created `ResponsesService` for direct passthrough to OpenAI Responses API.
- Added validation and processing logic for OpenAI Responses API requests.
- Implemented model compatibility checks for OpenAI Responses API.
This commit is contained in:
suyao 2026-01-04 21:13:56 +08:00
parent 1220adad9b
commit 932808dc96
No known key found for this signature in database
13 changed files with 1419 additions and 4 deletions

View File

@ -17,7 +17,7 @@ const extraProviderConfig = <P extends MinimalProvider>(provider: P) => {
}
}
function isOpenAILLMModel<M extends MinimalModel>(model: M): boolean {
export function isOpenAILLMModel<M extends MinimalModel>(model: M): boolean {
const modelId = getLowerBaseModelName(model.id)
const reasonings = ['o1', 'o3', 'o4', 'gpt-oss']
if (reasonings.some((r) => modelId.includes(r))) {

View File

@ -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<ResponsesCreateParams> {
/**
* 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<string, string>()
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<string, string>
): 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<string, string>
): 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<string, AiSdkTool> | undefined {
const tools = params.tools
if (!tools || tools.length === 0) return undefined
const aiSdkTools: Record<string, AiSdkTool> = {}
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

View File

@ -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'

View File

@ -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<InputParamsMap[T]>
}
if (format === 'openai-responses') {
return new OpenAIResponsesMessageConverter() as IMessageConverter<InputParamsMap[T]>
}
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']
}
}

View File

@ -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
}
]
])

View File

@ -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<ResponseStreamEvent> {
/**
* 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

View File

@ -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

View File

@ -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

View File

@ -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<Response, 'id' | 'object' | 'created_at' | 'status' | 'model' | 'output'>
/**
* Partial response type for streaming that includes optional usage.
* During streaming, we only emit a subset of fields.
*/
type PartialStreamingResponse = StreamingResponseFields & {
usage?: Partial<ResponseUsage>
}
/**
* 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<ResponseUsage, 'input_tokens' | 'output_tokens' | 'total_tokens'>
/**
* 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<ResponseStreamEvent> {
private createdAt: number
private sequenceNumber = 0
private toolCalls: Map<string, ToolCallState> = 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<ToolSet>): 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

View File

@ -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)

View File

@ -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<void> {
// 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<void> {
// 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<void> {
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 }

View File

@ -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<OpenAI> {
// 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<void> {
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<ResponseStreamEvent>
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<ResponseObject> {
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<ProcessResponseResult> {
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()

View File

@ -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)
}