mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-14 06:07:23 +08:00
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:
parent
1220adad9b
commit
932808dc96
@ -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))) {
|
||||
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
420
src/main/apiServer/adapters/stream/AiSdkToOpenAIResponsesSSE.ts
Normal file
420
src/main/apiServer/adapters/stream/AiSdkToOpenAIResponsesSSE.ts
Normal 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
|
||||
@ -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)
|
||||
|
||||
306
src/main/apiServer/routes/responses.ts
Normal file
306
src/main/apiServer/routes/responses.ts
Normal 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 }
|
||||
299
src/main/apiServer/services/responses.ts
Normal file
299
src/main/apiServer/services/responses.ts
Normal 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()
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user