Address high-priority review gaps before merging the dev snapshot

The release branch still had unresolved review comments around payload-rule
atomicity, brand blocking, compact fallback controls, and response
compatibility edge-cases. This patch pulls those behaviors back in line with
the intended contracts and adds focused regression coverage for the fragile
bridges.

Constraint: The fixes must stay narrow to the PR branch so review/merge can continue without reopening the larger dev snapshot conflict set
Rejected: Leave outdated-looking threads unresolved until after merge | reviewers still see concrete unfixed regressions in the current release branch
Rejected: Re-run another broad merge from dev/main | too risky while the PR branch is already green and mergeable
Confidence: medium
Scope-risk: moderate
Directive: If these release-branch fixes need to persist for future release cuts, backport them onto dev instead of re-fighting the same review threads next time
Tested: Focused vitest for settings events, brand matcher, compact responses, codex compatibility, anthropic stream bridge, openai chat response bridge; typecheck; repo drift check; git diff --check
Not-tested: Full npm test; live PR thread resolution state after push
This commit is contained in:
cita-777
2026-04-18 13:05:52 +08:00
parent 57db7fd107
commit 4b5d045b00
13 changed files with 338 additions and 72 deletions
@@ -86,14 +86,17 @@ describe('ensureCompactResponsesJsonAcceptHeader', () => {
}); });
}); });
it('preserves existing accept values and unrelated platforms', () => { it('forces compact requests back to application/json even when callers asked for SSE', () => {
expect(ensureCompactResponsesJsonAcceptHeader({ expect(ensureCompactResponsesJsonAcceptHeader({
Accept: 'application/json', Accept: 'text/event-stream',
}, { }, {
sitePlatform: 'codex', sitePlatform: 'codex',
})).toEqual({ })).toEqual({
Accept: 'application/json', accept: 'application/json',
}); });
});
it('preserves unrelated platforms', () => {
expect(ensureCompactResponsesJsonAcceptHeader({}, { expect(ensureCompactResponsesJsonAcceptHeader({}, {
sitePlatform: 'openai', sitePlatform: 'openai',
})).toEqual({}); })).toEqual({});
@@ -42,10 +42,11 @@ export function ensureCompactResponsesJsonAcceptHeader(
}, },
): Record<string, string> { ): Record<string, string> {
if (!shouldForceCompactResponsesJsonAccept(options?.sitePlatform)) return headers; if (!shouldForceCompactResponsesJsonAccept(options?.sitePlatform)) return headers;
const existingAccept = asTrimmedString(headers.accept) || asTrimmedString(headers.Accept); const nextHeaders = { ...headers };
if (existingAccept) return headers; delete (nextHeaders as Record<string, unknown>).Accept;
delete (nextHeaders as Record<string, unknown>).accept;
return { return {
...headers, ...nextHeaders,
accept: 'application/json', accept: 'application/json',
}; };
} }
@@ -105,15 +105,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
function getCodexSessionHeaderValue(headers: Record<string, string>): string { function getCodexSessionHeaderValue(headers: Record<string, string>): string {
for (const [rawKey, rawValue] of Object.entries(headers)) { const normalizedEntries = Object.entries(headers).map(([rawKey, rawValue]) => [
const normalizedKey = rawKey.trim().toLowerCase(); rawKey.trim().toLowerCase(),
if ( String(rawValue || '').trim(),
normalizedKey === 'session_id' ] as const);
|| normalizedKey === 'session-id' for (const preferredKey of ['session_id', 'session-id', 'conversation_id', 'conversation-id']) {
|| normalizedKey === 'conversation_id' const match = normalizedEntries.find(([normalizedKey, normalizedValue]) => (
|| normalizedKey === 'conversation-id' normalizedKey === preferredKey && normalizedValue
) { ));
return String(rawValue || '').trim(); if (match) {
return match[1];
} }
} }
return ''; return '';
@@ -626,7 +627,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error'); ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error');
} }
} }
const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled || !isCodexSite; const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled;
if ( if (
isCompactRequest isCompactRequest
&& compactFallbackEnabled && compactFallbackEnabled
+7 -2
View File
@@ -905,6 +905,7 @@ export async function settingsRoutes(app: FastifyInstance) {
const body = parsedBody.data as RuntimeSettingsBody; const body = parsedBody.data as RuntimeSettingsBody;
const changedLabels: string[] = []; const changedLabels: string[] = [];
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']); const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
let pendingPayloadRules: typeof config.payloadRules | undefined;
const webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined; const webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined;
const nextWebhookUrl = body.webhookUrl !== undefined const nextWebhookUrl = body.webhookUrl !== undefined
@@ -1149,8 +1150,7 @@ export async function settingsRoutes(app: FastifyInstance) {
if (previousRules !== nextRules) { if (previousRules !== nextRules) {
changedLabels.push('Payload 规则'); changedLabels.push('Payload 规则');
} }
config.payloadRules = parsedPayloadRules.normalized; pendingPayloadRules = parsedPayloadRules.normalized;
await upsertSetting('payload_rules', config.payloadRules);
} }
if (body.modelAvailabilityProbeEnabled !== undefined) { if (body.modelAvailabilityProbeEnabled !== undefined) {
@@ -1718,6 +1718,11 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('token_router_failure_cooldown_max_sec', normalized); upsertSetting('token_router_failure_cooldown_max_sec', normalized);
} }
if (pendingPayloadRules !== undefined) {
config.payloadRules = pendingPayloadRules;
await upsertSetting('payload_rules', pendingPayloadRules);
}
if (changedLabels.length > 0) { if (changedLabels.length > 0) {
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status'; let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
if (changedLabels.length === 1) { if (changedLabels.length === 1) {
@@ -179,7 +179,7 @@ describe('responses proxy compact upstream routing', () => {
expect(targetUrl).toContain('/v1/responses/compact'); expect(targetUrl).toContain('/v1/responses/compact');
}); });
it('automatically falls back from compact to ordinary responses for generic openai-compatible upstreams when compact is unsupported', async () => { it('does not fall back from compact to ordinary responses for generic openai-compatible upstreams when the fallback toggle is off', async () => {
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' }, site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
@@ -199,12 +199,12 @@ describe('responses proxy compact upstream routing', () => {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
})) }))
.mockResolvedValueOnce(new Response(JSON.stringify({ .mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_123', error: {
object: 'response', message: 'Invalid URL (POST /v1/responses/compact)',
output_text: 'hello from fallback', type: 'invalid_request_error',
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, },
}), { }), {
status: 200, status: 404,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
})); }));
@@ -217,19 +217,9 @@ describe('responses proxy compact upstream routing', () => {
}, },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(404);
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact'); expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact');
expect(response.json()).toMatchObject({
object: 'response.compaction',
usage: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
});
}); });
it('optionally falls back from compact to ordinary responses when compact is explicitly unsupported', async () => { it('optionally falls back from compact to ordinary responses when compact is explicitly unsupported', async () => {
@@ -282,6 +272,7 @@ describe('responses proxy compact upstream routing', () => {
}); });
it('automatically falls back from compact when a generic upstream rejects the stream parameter on the compact endpoint', async () => { it('automatically falls back from compact when a generic upstream rejects the stream parameter on the compact endpoint', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' }, site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
@@ -331,6 +322,7 @@ describe('responses proxy compact upstream routing', () => {
}); });
it('rebuilds compact fallback as an SSE-capable non-compact /v1/responses request for sub2api', async () => { it('rebuilds compact fallback as an SSE-capable non-compact /v1/responses request for sub2api', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' }, site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
+7
View File
@@ -23,6 +23,13 @@ describe('brandMatcher', () => {
expect(getBlockedBrandRules(['OpenRouter', 'OpenRouter', 'Unknown Brand'])).toEqual(['OpenRouter']); expect(getBlockedBrandRules(['OpenRouter', 'OpenRouter', 'Unknown Brand'])).toEqual(['OpenRouter']);
}); });
it('canonicalizes blocked brand names before filtering', () => {
expect(getBlockedBrandRules([' openrouter ', 'OPENROUTER', 'together ai'])).toEqual([
'OpenRouter',
'Together AI',
]);
});
it('uses the shared frontend detection result for global brand blocking', () => { it('uses the shared frontend detection result for global brand blocking', () => {
const providerRules = getBlockedBrandRules(['OpenRouter', 'Groq', '百炼', 'LongCat']); const providerRules = getBlockedBrandRules(['OpenRouter', 'Groq', '百炼', 'LongCat']);
expect(isModelBlockedByBrand('openrouter/openrouter-auto', providerRules)).toBe(true); expect(isModelBlockedByBrand('openrouter/openrouter-auto', providerRules)).toBe(true);
+14 -7
View File
@@ -1,10 +1,17 @@
import { getAllBrandNames as getSharedBrandNames, getBrand } from '../shared/modelBrand.js'; import { getAllBrandNames as getSharedBrandNames, getMatchingBrandNames } from '../shared/modelBrand.js';
export type BlockedBrandRule = string; export type BlockedBrandRule = string;
const KNOWN_BRANDS = new Set(getSharedBrandNames()); const KNOWN_BRANDS = new Set(getSharedBrandNames());
const CANONICAL_BRAND_BY_KEY = new Map(
getSharedBrandNames().map((brand) => [normalizeBrandKey(brand), brand] as const),
);
const KNOWN_BRAND_LIST = Array.from(KNOWN_BRANDS); const KNOWN_BRAND_LIST = Array.from(KNOWN_BRANDS);
function normalizeBrandKey(value: string): string {
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
}
export function getAllBrandNames(): string[] { export function getAllBrandNames(): string[] {
return KNOWN_BRAND_LIST; return KNOWN_BRAND_LIST;
} }
@@ -13,16 +20,16 @@ export function getBlockedBrandRules(blockedBrands: string[]): BlockedBrandRule[
const seen = new Set<string>(); const seen = new Set<string>();
const rules: string[] = []; const rules: string[] = [];
for (const brand of blockedBrands) { for (const brand of blockedBrands) {
const normalized = String(brand || '').trim(); const canonical = CANONICAL_BRAND_BY_KEY.get(normalizeBrandKey(brand));
if (!normalized || seen.has(normalized) || !KNOWN_BRANDS.has(normalized)) continue; if (!canonical || seen.has(canonical)) continue;
seen.add(normalized); seen.add(canonical);
rules.push(normalized); rules.push(canonical);
} }
return rules; return rules;
} }
export function isModelBlockedByBrand(modelName: string, rules: BlockedBrandRule[]): boolean { export function isModelBlockedByBrand(modelName: string, rules: BlockedBrandRule[]): boolean {
if (!modelName || rules.length === 0) return false; if (!modelName || rules.length === 0) return false;
const brand = getBrand(modelName); const matchedBrands = new Set(getMatchingBrandNames(modelName));
return !!brand && rules.includes(brand.name); return rules.some((rule) => matchedBrands.has(rule));
} }
@@ -68,6 +68,47 @@ describe('anthropic messages stream bridge', () => {
expect(lines.join('')).toContain('sig-native'); expect(lines.join('')).toContain('sig-native');
}); });
it('preserves whitespace in anthropic streaming text and thinking deltas', () => {
const streamContext = anthropicMessagesStream.createContext('claude-test');
const downstreamContext = anthropicMessagesStream.createDownstreamContext();
const textResult = consumeAnthropicSseEvent(
{
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 0,
delta: {
type: 'text_delta',
text: ' padded text ',
},
}),
},
streamContext,
downstreamContext,
'claude-test',
);
const thinkingResult = consumeAnthropicSseEvent(
{
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 0,
delta: {
type: 'thinking_delta',
thinking: ' padded thinking ',
},
}),
},
streamContext,
downstreamContext,
'claude-test',
);
expect(textResult.lines.join('')).toContain(' padded text ');
expect(thinkingResult.lines.join('')).toContain(' padded thinking ');
});
it('preserves responses output-item ordering when streaming anthropic fallback blocks', () => { it('preserves responses output-item ordering when streaming anthropic fallback blocks', () => {
const streamContext = anthropicMessagesStream.createContext('claude-test'); const streamContext = anthropicMessagesStream.createContext('claude-test');
const downstreamContext = anthropicMessagesStream.createDownstreamContext(); const downstreamContext = anthropicMessagesStream.createDownstreamContext();
@@ -619,7 +619,7 @@ export function serializeAnthropicFinalAsStream(
}, },
}, },
)); ));
const thinkingText = asTrimmedString(block.thinking); const thinkingText = asUntouchedString(block.thinking);
if (thinkingText) { if (thinkingText) {
lines.push(...anthropicMessagesStream.serializeEvent( lines.push(...anthropicMessagesStream.serializeEvent(
{ reasoningDelta: thinkingText }, { reasoningDelta: thinkingText },
@@ -655,7 +655,7 @@ export function serializeAnthropicFinalAsStream(
kind: 'redacted_thinking', kind: 'redacted_thinking',
index, index,
}, },
redactedThinkingData: asTrimmedString(block.data), redactedThinkingData: asUntouchedString(block.data),
}, },
}, },
)); ));
@@ -762,6 +762,10 @@ function toClaudeStopReason(finishReason: string | null | undefined): string {
return 'end_turn'; return 'end_turn';
} }
function asUntouchedString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeAnthropicRawEvent( function normalizeAnthropicRawEvent(
payload: AnthropicStreamPayload, payload: AnthropicStreamPayload,
context: StreamTransformContext, context: StreamTransformContext,
@@ -812,7 +816,7 @@ function normalizeAnthropicRawEvent(
kind: 'redacted_thinking', kind: 'redacted_thinking',
index, index,
}, },
redactedThinkingData: asTrimmedString(contentBlock.data), redactedThinkingData: asUntouchedString(contentBlock.data),
}, },
}; };
} }
@@ -841,7 +845,7 @@ function normalizeAnthropicRawEvent(
if (deltaType === 'thinking_delta') { if (deltaType === 'thinking_delta') {
return { return {
reasoningDelta: asTrimmedString(delta.thinking ?? delta.text) || undefined, reasoningDelta: asUntouchedString(delta.thinking ?? delta.text) || undefined,
}; };
} }
@@ -855,7 +859,7 @@ function normalizeAnthropicRawEvent(
if (deltaType === 'text_delta') { if (deltaType === 'text_delta') {
return { return {
contentDelta: asTrimmedString(delta.text) || undefined, contentDelta: asUntouchedString(delta.text) || undefined,
}; };
} }
@@ -155,6 +155,64 @@ describe('openai chat response bridge', () => {
}); });
}); });
it('builds synthetic chunks for multi-choice finals even when some choices omit toolCalls', () => {
const chunks = buildNormalizedFinalToOpenAiChatChunks({
id: 'chatcmpl-multi-1',
model: 'gpt-5',
created: 123,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
choices: [
{
index: 0,
role: 'assistant',
content: '',
toolCalls: [{
id: 'call_multi_1',
name: 'lookup_weather',
arguments: '{"city":"Paris"}',
}],
finishReason: 'tool_calls',
},
{
index: 1,
role: 'assistant',
content: 'done',
finishReason: 'stop',
},
],
} as any);
expect(chunks[0]).toMatchObject({
choices: [
{
index: 0,
delta: {
tool_calls: [{
id: 'call_multi_1',
type: 'function',
}],
},
},
{
index: 1,
delta: {
role: 'assistant',
content: 'done',
},
},
],
});
expect(chunks[1]).toMatchObject({
choices: [
{ index: 0, finish_reason: 'tool_calls' },
{ index: 1, finish_reason: 'stop' },
],
});
});
it('keeps the outbound facade pointed at the response bridge object', () => { it('keeps the outbound facade pointed at the response bridge object', () => {
expect(openAiChatOutbound).toBe(openAiChatResponseBridge); expect(openAiChatOutbound).toBe(openAiChatResponseBridge);
}); });
@@ -58,10 +58,24 @@ export function buildNormalizedFinalToOpenAiChatPayload(
usage?: unknown, usage?: unknown,
) { ) {
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse; const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
const normalizedUsage = isRecord(usage)
&& typeof usage.promptTokens === 'number'
&& typeof usage.completionTokens === 'number'
&& typeof usage.totalTokens === 'number'
? {
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
}
: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
};
const payload = serializeFinalResponse( const payload = serializeFinalResponse(
'openai', 'openai',
normalized, normalized,
usage as { promptTokens: number; completionTokens: number; totalTokens: number }, normalizedUsage,
) as Record<string, unknown>; ) as Record<string, unknown>;
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0 const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
@@ -136,13 +150,14 @@ export function buildNormalizedFinalToOpenAiChatChunks(normalized: NormalizedFin
created: normalized.created, created: normalized.created,
model: normalized.model, model: normalized.model,
choices: choices.map((choice) => { choices: choices.map((choice) => {
const toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : [];
const delta: Record<string, unknown> = { const delta: Record<string, unknown> = {
role: choice.role || 'assistant', role: choice.role || 'assistant',
content: choice.content || '', content: choice.content || '',
}; };
if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent; if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent;
if (choice.toolCalls.length > 0) { if (toolCalls.length > 0) {
delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({ delta.tool_calls = toolCalls.map((toolCall, toolIndex) => ({
index: toolIndex, index: toolIndex,
id: toolCall.id, id: toolCall.id,
type: 'function', type: 'function',
@@ -167,11 +182,14 @@ export function buildNormalizedFinalToOpenAiChatChunks(normalized: NormalizedFin
object: 'chat.completion.chunk', object: 'chat.completion.chunk',
created: normalized.created, created: normalized.created,
model: normalized.model, model: normalized.model,
choices: choices.map((choice) => ({ choices: choices.map((choice) => {
index: choice.index, const toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : [];
delta: {}, return {
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'), index: choice.index,
})).sort((left, right) => left.index - right.index), delta: {},
finish_reason: choice.finishReason || (toolCalls.length > 0 ? 'tool_calls' : 'stop'),
};
}).sort((left, right) => left.index - right.index),
}, },
] ]
: buildSyntheticOpenAiChunks(normalized); : buildSyntheticOpenAiChunks(normalized);
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { normalizeCodexResponsesBodyForProxy } from './codexCompatibility.js'; import {
CODEX_DEFAULT_INSTRUCTIONS,
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; normalizeCodexResponsesBodyForProxy,
} from './codexCompatibility.js';
describe('normalizeCodexResponsesBodyForProxy', () => { describe('normalizeCodexResponsesBodyForProxy', () => {
it('extracts codex system input into top-level instructions before proxying upstream', () => { it('extracts codex system input into top-level instructions before proxying upstream', () => {
@@ -22,6 +23,7 @@ describe('normalizeCodexResponsesBodyForProxy', () => {
max_output_tokens: 512, max_output_tokens: 512,
max_completion_tokens: 256, max_completion_tokens: 256,
max_tokens: 128, max_tokens: 128,
stream_options: { include_usage: true },
temperature: 0.3, temperature: 0.3,
store: true, store: true,
}, 'codex'); }, 'codex');
@@ -40,6 +42,62 @@ describe('normalizeCodexResponsesBodyForProxy', () => {
}); });
}); });
it('keeps non-text system content in input while lifting only the text blocks into instructions', () => {
const imageBlock = {
type: 'input_image',
image_url: 'https://example.com/reference.png',
};
const body = normalizeCodexResponsesBodyForProxy({
input: [
{
type: 'message',
role: 'system',
content: [
{ type: 'input_text', text: 'keep the image context' },
imageBlock,
],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
],
}, 'codex');
expect(body.instructions).toBe('keep the image context');
expect(body.input).toEqual([
{
type: 'message',
role: 'system',
content: [imageBlock],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
]);
});
it('joins multiple text blocks with separators instead of squashing them together', () => {
const body = normalizeCodexResponsesBodyForProxy({
input: [
{
type: 'message',
role: 'system',
content: [
{ type: 'input_text', text: 'first line' },
{ type: 'input_text', text: 'second line' },
],
},
],
}, 'codex');
expect(body.instructions).toBe('first line\nsecond line');
});
it('supplies a non-empty default instructions string for codex when missing', () => { it('supplies a non-empty default instructions string for codex when missing', () => {
const body = normalizeCodexResponsesBodyForProxy({ const body = normalizeCodexResponsesBodyForProxy({
input: 'hello', input: 'hello',
@@ -6,17 +6,9 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.'; export const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
function extractTextFromContent(content: unknown): string { function extractTextValue(content: Record<string, unknown>): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((item) => extractTextFromContent(item))
.filter((item) => item.length > 0)
.join('');
}
if (!isRecord(content)) return '';
if (typeof content.text === 'string') return content.text; if (typeof content.text === 'string') return content.text;
if (typeof content.content === 'string') return content.content; if (typeof content.content === 'string') return content.content;
if (typeof content.output_text === 'string') return content.output_text; if (typeof content.output_text === 'string') return content.output_text;
@@ -24,6 +16,75 @@ function extractTextFromContent(content: unknown): string {
return ''; return '';
} }
function isTextOnlyContentRecord(content: Record<string, unknown>): boolean {
const blockType = asTrimmedString(content.type).toLowerCase();
if (blockType === 'input_text' || blockType === 'output_text' || blockType === 'text') {
return true;
}
const keys = Object.keys(content);
return keys.length > 0 && keys.every((key) => (
key === 'type'
|| key === 'text'
|| key === 'content'
|| key === 'output_text'
|| key === 'input_text'
));
}
function splitExtractedSystemContent(content: unknown): {
extractedTexts: string[];
remainingContent?: unknown;
} {
if (typeof content === 'string') {
const trimmed = content.trim();
return trimmed ? { extractedTexts: [trimmed] } : { extractedTexts: [] };
}
if (Array.isArray(content)) {
const extractedTexts: string[] = [];
const remainingItems: unknown[] = [];
for (const item of content) {
if (typeof item === 'string') {
const trimmed = item.trim();
if (trimmed) extractedTexts.push(trimmed);
else remainingItems.push(item);
continue;
}
if (!isRecord(item)) {
remainingItems.push(item);
continue;
}
const textValue = extractTextValue(item).trim();
if (textValue && isTextOnlyContentRecord(item)) {
extractedTexts.push(textValue);
continue;
}
remainingItems.push(item);
}
return {
extractedTexts,
...(remainingItems.length > 0 ? { remainingContent: remainingItems } : {}),
};
}
if (!isRecord(content)) return { extractedTexts: [] };
const textValue = extractTextValue(content).trim();
if (textValue && isTextOnlyContentRecord(content)) {
return { extractedTexts: [textValue] };
}
return {
extractedTexts: [],
remainingContent: content,
};
}
function extractSystemMessagesToInstructions( function extractSystemMessagesToInstructions(
body: Record<string, unknown>, body: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
@@ -46,10 +107,19 @@ function extractSystemMessagesToInstructions(
continue; continue;
} }
const text = extractTextFromContent(item.content); const { extractedTexts, remainingContent } = splitExtractedSystemContent(item.content);
if (text.trim()) { if (extractedTexts.length > 0) {
extractedSystemTexts.push(text); extractedSystemTexts.push(extractedTexts.join('\n'));
if (remainingContent !== undefined) {
remainingInput.push({
...item,
content: remainingContent,
});
}
continue;
} }
remainingInput.push(item);
} }
if (extractedSystemTexts.length <= 0) return body; if (extractedSystemTexts.length <= 0) return body;
@@ -99,6 +169,7 @@ function stripCodexUnsupportedResponsesFields(
delete next.max_output_tokens; delete next.max_output_tokens;
delete next.max_completion_tokens; delete next.max_completion_tokens;
delete next.max_tokens; delete next.max_tokens;
delete next.stream_options;
return next; return next;
} }