feat(proxy): add OpenAI Responses API compatibility for Open CoDesign

- Normalize Responses API tool format ({name, parameters} at top level)
  to Chat Completions format ({function: {name, parameters}})
- Handle 'developer' role as system prompt (OpenAI's newer equivalent)
- Add null/non-array guards to convertOpenAIPartsToAnthropicContent and
  extractOpenAITextContent to prevent 'content is not iterable' crashes
- Support input_text, output_text, and reasoning item types in Responses
  API input parsing
- Forward reasoning.effort to Claude adaptive thinking config
- Extend JSON schema sanitization blacklist with draft-2020 keywords
  (patternProperties, , unevaluatedProperties, etc.)
- Add fallback for untyped items with role/content in Responses API input
- Add diagnostic logging for /v1/responses input conversion
This commit is contained in:
Gemini Nexus Bot committed 2026-04-29 04:39:15 -03:00
1 parent 9bf7c46ff6
commit 68474b56dc
3 files changed
+253 -33

No files matched your search

+58 -8
View File
@@ -55,7 +55,10 @@ export interface ProxyConfigOverride {
@Injectable()
export class ProxyService {
private readonly logger = new Logger(ProxyService.name);
private readonly responseCache = new Map<string, { response: OpenAIChatResponse; expiry: number }>();
private readonly responseCache = new Map<
string,
{ response: OpenAIChatResponse; expiry: number }
>();
private readonly CACHE_TTL_MS = 60000; // 1 minute
constructor(
@@ -79,7 +82,7 @@ export class ProxyService {
}
const prompt = usage.promptTokenCount ?? 0;
const completion = usage.candidatesTokenCount ?? 0;
const total = usage.totalTokenCount ?? (prompt + completion);
const total = usage.totalTokenCount ?? prompt + completion;
this.tokenUsageService.recordUsage({
accountId,
model,
@@ -995,7 +998,10 @@ export class ProxyService {
`Transformed Claude response snippet: ${JSON.stringify(claudeResponse).substring(0, 500)}`,
);
const openaiResponse = this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
const transformedResponse = this.applyResponseFormatTransformation(openaiResponse, request);
const transformedResponse = this.applyResponseFormatTransformation(
openaiResponse,
request,
);
this.setCachedResponse(this.buildCacheKey(request), transformedResponse);
if (context) {
context.cacheStatus = 'MISS';
@@ -1058,8 +1064,14 @@ export class ProxyService {
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
const claudeResponse = transformResponse(response);
const openaiResponse = this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
const transformedResponse = this.applyResponseFormatTransformation(openaiResponse, request);
const openaiResponse = this.convertClaudeToOpenAIResponse(
claudeResponse,
request.model,
);
const transformedResponse = this.applyResponseFormatTransformation(
openaiResponse,
request,
);
this.setCachedResponse(this.buildCacheKey(request), transformedResponse);
if (context) {
context.cacheStatus = 'MISS';
@@ -1636,7 +1648,8 @@ export class ProxyService {
const anthropicMessages: ClaudeRequest['messages'] = [];
for (const msg of messages) {
if (msg.role === 'system') {
// 'developer' is OpenAI's newer equivalent of 'system'
if (msg.role === 'system' || msg.role === 'developer') {
const systemText = this.extractOpenAITextContent(msg.content);
if (systemText) {
systemPromptParts.push(systemText);
@@ -1679,12 +1692,31 @@ export class ProxyService {
});
}
if (request.response_format?.type === 'json_object' || request.response_format?.type === 'json_schema') {
systemPromptParts.push('You must format your entire response as a single valid JSON object. Do not include any markdown formatting, explanations, or text outside the JSON structure.');
if (
request.response_format?.type === 'json_object' ||
request.response_format?.type === 'json_schema'
) {
systemPromptParts.push(
'You must format your entire response as a single valid JSON object. Do not include any markdown formatting, explanations, or text outside the JSON structure.',
);
}
const systemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join('\n') : undefined;
// Map OpenAI reasoning_effort to Claude thinking config
const reasoningEffort = String(request.extra?.reasoning_effort ?? '').toLowerCase();
let thinking: ClaudeRequest['thinking'] | undefined;
if (reasoningEffort === 'none') {
// Explicitly disable thinking when client requests no reasoning
thinking = { type: 'disabled' };
} else if (
reasoningEffort === 'low' ||
reasoningEffort === 'medium' ||
reasoningEffort === 'high'
) {
thinking = { type: 'adaptive', effort: reasoningEffort };
}
return {
model: request.model,
messages: anthropicMessages,
@@ -1694,6 +1726,7 @@ export class ProxyService {
temperature: request.temperature,
top_p: request.top_p,
stream: request.stream,
...(thinking ? { thinking } : {}),
metadata: {
...(request.extra ?? {}),
source: 'openai',
@@ -1704,10 +1737,19 @@ export class ProxyService {
private convertOpenAIPartsToAnthropicContent(
content: OpenAIChatRequest['messages'][number]['content'],
): AnthropicContent[] {
if (content == null) {
return [];
}
if (isString(content)) {
return content.trim() ? [{ type: 'text', text: content }] : [];
}
// Guard against non-array content (e.g. an object or number)
if (!Array.isArray(content)) {
return [{ type: 'text', text: String(content) }];
}
const blocks: AnthropicContent[] = [];
for (const part of content) {
if (part.type === 'text' && part.text) {
@@ -1738,10 +1780,18 @@ export class ProxyService {
private extractOpenAITextContent(
content: OpenAIChatRequest['messages'][number]['content'],
): string {
if (content == null) {
return '';
}
if (isString(content)) {
return content;
}
if (!Array.isArray(content)) {
return String(content);
}
return content
.filter((part) => part.type === 'text')
.map((part) => part.text || '')