Kode-cli/src/services/adapters/responsesAPI.ts
Radon Co be6477cca7 feat: Fix CLI crash and add OpenAI Responses API integration
WHAT: Fix critical CLI crash with content.filter() error and implement OpenAI Responses API integration with comprehensive testing

WHY: CLI was crashing with 'TypeError: undefined is not an object (evaluating "content.filter")' when using OpenAI models, preventing users from making API calls. Additionally needed proper Responses API support with reasoning tokens.

HOW:
• Fix content extraction from OpenAI response structure in legacy path
• Add JSON/Zod schema detection in responsesAPI adapter
• Create comprehensive test suite for both integration and production scenarios
• Document the new adapter architecture and usage

CRITICAL FIXES:
• claude.ts: Extract content from response.choices[0].message.content instead of undefined response.content
• responsesAPI.ts: Detect if schema is already JSON (has 'type' property) vs Zod schema before conversion

FILES:
• src/services/claude.ts - Critical bug fix for OpenAI response content extraction
• src/services/adapters/responsesAPI.ts - Robust schema detection for tool parameters
• src/test/integration-cli-flow.test.ts - Integration tests for full flow
• src/test/chat-completions-e2e.test.ts - End-to-end Chat Completions compatibility tests
• src/test/production-api-tests.test.ts - Production API tests with environment configuration
• docs/develop/modules/openai-adapters.md - New adapter system documentation
• docs/develop/README.md - Updated development documentation
2025-11-09 18:41:29 -08:00

363 lines
12 KiB
TypeScript

import { ModelAPIAdapter } from './base'
import { UnifiedRequestParams, UnifiedResponse } from '@kode-types/modelCapabilities'
import { Tool } from '@tool'
import { zodToJsonSchema } from 'zod-to-json-schema'
export class ResponsesAPIAdapter extends ModelAPIAdapter {
createRequest(params: UnifiedRequestParams): any {
const { messages, systemPrompt, tools, maxTokens, stream, reasoningEffort } = params
// Build base request
const request: any = {
model: this.modelProfile.modelName,
input: this.convertMessagesToInput(messages),
instructions: this.buildInstructions(systemPrompt)
}
// Add token limit - Responses API uses max_output_tokens
request.max_output_tokens = maxTokens
// Add streaming support - Responses API always returns streaming
request.stream = true
// Add temperature (GPT-5 only supports 1)
if (this.getTemperature() === 1) {
request.temperature = 1
}
// Add reasoning control - include array is required for reasoning content
const include: string[] = []
if (this.shouldIncludeReasoningEffort() || reasoningEffort) {
include.push('reasoning.encrypted_content')
request.reasoning = {
effort: reasoningEffort || this.modelProfile.reasoningEffort || 'medium'
}
}
// Add verbosity control - correct format for Responses API
if (this.shouldIncludeVerbosity()) {
request.text = {
verbosity: params.verbosity || 'high' // High verbosity for coding tasks
}
}
// Add tools
if (tools && tools.length > 0) {
request.tools = this.buildTools(tools)
}
// Add tool choice - use simple format like codex-cli.js
request.tool_choice = 'auto'
// Add parallel tool calls flag
request.parallel_tool_calls = this.capabilities.toolCalling.supportsParallelCalls
// Add store flag
request.store = false
// Add state management
if (params.previousResponseId && this.capabilities.stateManagement.supportsPreviousResponseId) {
request.previous_response_id = params.previousResponseId
}
// Add include array for reasoning and other content
if (include.length > 0) {
request.include = include
}
return request
}
buildTools(tools: Tool[]): any[] {
// Follow codex-cli.js format: flat structure, no nested 'function' object
return tools.map(tool => {
// Prefer pre-built JSON schema if available
let parameters = tool.inputJSONSchema
// Otherwise, check if inputSchema is already a JSON schema (not Zod)
if (!parameters && tool.inputSchema) {
// Check if it's already a JSON schema (has 'type' property) vs a Zod schema
if (tool.inputSchema.type || tool.inputSchema.properties) {
// Already a JSON schema, use directly
parameters = tool.inputSchema
} else {
// Try to convert Zod schema
try {
parameters = zodToJsonSchema(tool.inputSchema)
} catch (error) {
console.warn(`Failed to convert Zod schema for tool ${tool.name}:`, error)
// Use minimal schema as fallback
parameters = { type: 'object', properties: {} }
}
}
}
return {
type: 'function',
name: tool.name,
description: typeof tool.description === 'function'
? 'Tool with dynamic description'
: (tool.description || ''),
parameters: parameters || { type: 'object', properties: {} }
}
})
}
async parseResponse(response: any): Promise<UnifiedResponse> {
// Check if this is a streaming response (Response object with body)
if (response && typeof response === 'object' && 'body' in response && response.body) {
return await this.parseStreamingResponse(response)
}
// Process non-streaming response
return this.parseNonStreamingResponse(response)
}
private parseNonStreamingResponse(response: any): UnifiedResponse {
// Process basic text output
let content = response.output_text || ''
// Process structured output
if (response.output && Array.isArray(response.output)) {
const messageItems = response.output.filter(item => item.type === 'message')
if (messageItems.length > 0) {
content = messageItems
.map(item => {
if (item.content && Array.isArray(item.content)) {
return item.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n')
}
return item.content || ''
})
.filter(Boolean)
.join('\n\n')
}
}
// Parse tool calls
const toolCalls = this.parseToolCalls(response)
// Build unified response
// Convert content to array format for Anthropic compatibility
const contentArray = content
? [{ type: 'text', text: content, citations: [] }]
: [{ type: 'text', text: '', citations: [] }]
return {
id: response.id || `resp_${Date.now()}`,
content: contentArray, // Return as array (Anthropic format)
toolCalls,
usage: {
promptTokens: response.usage?.input_tokens || 0,
completionTokens: response.usage?.output_tokens || 0,
reasoningTokens: response.usage?.output_tokens_details?.reasoning_tokens
},
responseId: response.id // Save for state management
}
}
private async parseStreamingResponse(response: any): Promise<UnifiedResponse> {
// Handle streaming response from Responses API
// Collect all chunks and build a unified response
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullContent = ''
let toolCalls = []
let responseId = response.id || `resp_${Date.now()}`
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
const parsed = this.parseSSEChunk(line)
if (parsed) {
// Extract response ID
if (parsed.response?.id) {
responseId = parsed.response.id
}
// Handle text content
if (parsed.type === 'response.output_text.delta') {
fullContent += parsed.delta || ''
}
// Handle tool calls
if (parsed.type === 'response.output_item.done') {
const item = parsed.item || {}
if (item.type === 'function_call') {
toolCalls.push({
id: item.call_id || item.id || `tool_${Date.now()}`,
type: 'tool_call',
name: item.name,
arguments: item.arguments
})
}
}
}
}
}
}
} catch (error) {
console.error('Error reading streaming response:', error)
}
// Build unified response
return {
id: responseId,
content: fullContent,
toolCalls,
usage: {
promptTokens: 0, // Will be filled in by the caller
completionTokens: 0,
reasoningTokens: 0
},
responseId: responseId
}
}
private parseSSEChunk(line: string): any | null {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') {
return null
}
if (data) {
try {
return JSON.parse(data)
} catch (error) {
console.error('Error parsing SSE chunk:', error)
return null
}
}
}
return null
}
private convertMessagesToInput(messages: any[]): any[] {
// Convert Chat Completions messages to Response API input format
// Following reference implementation pattern
const inputItems = []
for (const message of messages) {
const role = message.role
if (role === 'tool') {
// Handle tool call results
const callId = message.tool_call_id || message.id
if (typeof callId === 'string' && callId) {
let content = message.content || ''
if (Array.isArray(content)) {
const texts = content
.filter(part => typeof part === 'object' && part !== null)
.map(part => part.text || part.content)
.filter(text => typeof text === 'string' && text)
content = texts.join('\n')
}
if (typeof content === 'string') {
inputItems.push({
type: 'function_call_output',
call_id: callId,
output: content
})
}
}
continue
}
if (role === 'assistant' && Array.isArray(message.tool_calls)) {
// Handle assistant tool calls
for (const tc of message.tool_calls) {
if (typeof tc !== 'object' || tc === null) continue
const tcType = tc.type || 'function'
if (tcType !== 'function') continue
const callId = tc.id || tc.call_id
const fn = tc.function
const name = typeof fn === 'object' && fn !== null ? fn.name : null
const args = typeof fn === 'object' && fn !== null ? fn.arguments : null
if (typeof callId === 'string' && typeof name === 'string' && typeof args === 'string') {
inputItems.push({
type: 'function_call',
name: name,
arguments: args,
call_id: callId
})
}
}
continue
}
// Handle regular text content
const content = message.content || ''
const contentItems = []
if (Array.isArray(content)) {
for (const part of content) {
if (typeof part !== 'object' || part === null) continue
const ptype = part.type
if (ptype === 'text') {
const text = part.text || part.content || ''
if (typeof text === 'string' && text) {
const kind = role === 'assistant' ? 'output_text' : 'input_text'
contentItems.push({ type: kind, text: text })
}
} else if (ptype === 'image_url') {
const image = part.image_url
const url = typeof image === 'object' && image !== null ? image.url : image
if (typeof url === 'string' && url) {
contentItems.push({ type: 'input_image', image_url: url })
}
}
}
} else if (typeof content === 'string' && content) {
const kind = role === 'assistant' ? 'output_text' : 'input_text'
contentItems.push({ type: kind, text: content })
}
if (contentItems.length) {
const roleOut = role === 'assistant' ? 'assistant' : 'user'
inputItems.push({ type: 'message', role: roleOut, content: contentItems })
}
}
return inputItems
}
private buildInstructions(systemPrompt: string[]): string {
// Join system prompts into instructions (following reference implementation)
const systemContent = systemPrompt
.filter(content => content.trim())
.join('\n\n')
return systemContent
}
private parseToolCalls(response: any): any[] {
if (!response.output || !Array.isArray(response.output)) {
return []
}
return response.output
.filter(item => item.type === 'tool_call')
.map(item => ({
id: item.id || `tool_${Date.now()}`,
type: 'tool_call',
name: item.name,
arguments: item.arguments // Can be text or JSON
}))
}
}