forked from boos/BoosAPI
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:
@@ -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({
|
||||
Accept: 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
Accept: 'application/json',
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated platforms', () => {
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'openai',
|
||||
})).toEqual({});
|
||||
|
||||
@@ -42,10 +42,11 @@ export function ensureCompactResponsesJsonAcceptHeader(
|
||||
},
|
||||
): Record<string, string> {
|
||||
if (!shouldForceCompactResponsesJsonAccept(options?.sitePlatform)) return headers;
|
||||
const existingAccept = asTrimmedString(headers.accept) || asTrimmedString(headers.Accept);
|
||||
if (existingAccept) return headers;
|
||||
const nextHeaders = { ...headers };
|
||||
delete (nextHeaders as Record<string, unknown>).Accept;
|
||||
delete (nextHeaders as Record<string, unknown>).accept;
|
||||
return {
|
||||
...headers,
|
||||
...nextHeaders,
|
||||
accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,15 +105,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function getCodexSessionHeaderValue(headers: Record<string, string>): string {
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const normalizedKey = rawKey.trim().toLowerCase();
|
||||
if (
|
||||
normalizedKey === 'session_id'
|
||||
|| normalizedKey === 'session-id'
|
||||
|| normalizedKey === 'conversation_id'
|
||||
|| normalizedKey === 'conversation-id'
|
||||
) {
|
||||
return String(rawValue || '').trim();
|
||||
const normalizedEntries = Object.entries(headers).map(([rawKey, rawValue]) => [
|
||||
rawKey.trim().toLowerCase(),
|
||||
String(rawValue || '').trim(),
|
||||
] as const);
|
||||
for (const preferredKey of ['session_id', 'session-id', 'conversation_id', 'conversation-id']) {
|
||||
const match = normalizedEntries.find(([normalizedKey, normalizedValue]) => (
|
||||
normalizedKey === preferredKey && normalizedValue
|
||||
));
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -626,7 +627,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error');
|
||||
}
|
||||
}
|
||||
const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled || !isCodexSite;
|
||||
const compactFallbackEnabled = config.responsesCompactFallbackToResponsesEnabled;
|
||||
if (
|
||||
isCompactRequest
|
||||
&& compactFallbackEnabled
|
||||
|
||||
@@ -905,6 +905,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const body = parsedBody.data as RuntimeSettingsBody;
|
||||
const changedLabels: string[] = [];
|
||||
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 nextWebhookUrl = body.webhookUrl !== undefined
|
||||
@@ -1149,8 +1150,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
if (previousRules !== nextRules) {
|
||||
changedLabels.push('Payload 规则');
|
||||
}
|
||||
config.payloadRules = parsedPayloadRules.normalized;
|
||||
await upsertSetting('payload_rules', config.payloadRules);
|
||||
pendingPayloadRules = parsedPayloadRules.normalized;
|
||||
}
|
||||
|
||||
if (body.modelAvailabilityProbeEnabled !== undefined) {
|
||||
@@ -1718,6 +1718,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upsertSetting('token_router_failure_cooldown_max_sec', normalized);
|
||||
}
|
||||
|
||||
if (pendingPayloadRules !== undefined) {
|
||||
config.payloadRules = pendingPayloadRules;
|
||||
await upsertSetting('payload_rules', pendingPayloadRules);
|
||||
}
|
||||
|
||||
if (changedLabels.length > 0) {
|
||||
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
|
||||
if (changedLabels.length === 1) {
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('responses proxy compact upstream routing', () => {
|
||||
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({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
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' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_123',
|
||||
object: 'response',
|
||||
output_text: 'hello from fallback',
|
||||
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
||||
error: {
|
||||
message: 'Invalid URL (POST /v1/responses/compact)',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
@@ -217,19 +217,9 @@ describe('responses proxy compact upstream routing', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
config.responsesCompactFallbackToResponsesEnabled = true;
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
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 () => {
|
||||
config.responsesCompactFallbackToResponsesEnabled = true;
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
|
||||
|
||||
@@ -23,6 +23,13 @@ describe('brandMatcher', () => {
|
||||
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', () => {
|
||||
const providerRules = getBlockedBrandRules(['OpenRouter', 'Groq', '百炼', 'LongCat']);
|
||||
expect(isModelBlockedByBrand('openrouter/openrouter-auto', providerRules)).toBe(true);
|
||||
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
|
||||
function normalizeBrandKey(value: string): string {
|
||||
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function getAllBrandNames(): string[] {
|
||||
return KNOWN_BRAND_LIST;
|
||||
}
|
||||
@@ -13,16 +20,16 @@ export function getBlockedBrandRules(blockedBrands: string[]): BlockedBrandRule[
|
||||
const seen = new Set<string>();
|
||||
const rules: string[] = [];
|
||||
for (const brand of blockedBrands) {
|
||||
const normalized = String(brand || '').trim();
|
||||
if (!normalized || seen.has(normalized) || !KNOWN_BRANDS.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
rules.push(normalized);
|
||||
const canonical = CANONICAL_BRAND_BY_KEY.get(normalizeBrandKey(brand));
|
||||
if (!canonical || seen.has(canonical)) continue;
|
||||
seen.add(canonical);
|
||||
rules.push(canonical);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function isModelBlockedByBrand(modelName: string, rules: BlockedBrandRule[]): boolean {
|
||||
if (!modelName || rules.length === 0) return false;
|
||||
const brand = getBrand(modelName);
|
||||
return !!brand && rules.includes(brand.name);
|
||||
const matchedBrands = new Set(getMatchingBrandNames(modelName));
|
||||
return rules.some((rule) => matchedBrands.has(rule));
|
||||
}
|
||||
|
||||
@@ -68,6 +68,47 @@ describe('anthropic messages stream bridge', () => {
|
||||
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', () => {
|
||||
const streamContext = anthropicMessagesStream.createContext('claude-test');
|
||||
const downstreamContext = anthropicMessagesStream.createDownstreamContext();
|
||||
|
||||
@@ -619,7 +619,7 @@ export function serializeAnthropicFinalAsStream(
|
||||
},
|
||||
},
|
||||
));
|
||||
const thinkingText = asTrimmedString(block.thinking);
|
||||
const thinkingText = asUntouchedString(block.thinking);
|
||||
if (thinkingText) {
|
||||
lines.push(...anthropicMessagesStream.serializeEvent(
|
||||
{ reasoningDelta: thinkingText },
|
||||
@@ -655,7 +655,7 @@ export function serializeAnthropicFinalAsStream(
|
||||
kind: 'redacted_thinking',
|
||||
index,
|
||||
},
|
||||
redactedThinkingData: asTrimmedString(block.data),
|
||||
redactedThinkingData: asUntouchedString(block.data),
|
||||
},
|
||||
},
|
||||
));
|
||||
@@ -762,6 +762,10 @@ function toClaudeStopReason(finishReason: string | null | undefined): string {
|
||||
return 'end_turn';
|
||||
}
|
||||
|
||||
function asUntouchedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function normalizeAnthropicRawEvent(
|
||||
payload: AnthropicStreamPayload,
|
||||
context: StreamTransformContext,
|
||||
@@ -812,7 +816,7 @@ function normalizeAnthropicRawEvent(
|
||||
kind: 'redacted_thinking',
|
||||
index,
|
||||
},
|
||||
redactedThinkingData: asTrimmedString(contentBlock.data),
|
||||
redactedThinkingData: asUntouchedString(contentBlock.data),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -841,7 +845,7 @@ function normalizeAnthropicRawEvent(
|
||||
|
||||
if (deltaType === 'thinking_delta') {
|
||||
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') {
|
||||
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', () => {
|
||||
expect(openAiChatOutbound).toBe(openAiChatResponseBridge);
|
||||
});
|
||||
|
||||
@@ -58,10 +58,24 @@ export function buildNormalizedFinalToOpenAiChatPayload(
|
||||
usage?: unknown,
|
||||
) {
|
||||
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(
|
||||
'openai',
|
||||
normalized,
|
||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
normalizedUsage,
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
@@ -136,13 +150,14 @@ export function buildNormalizedFinalToOpenAiChatChunks(normalized: NormalizedFin
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => {
|
||||
const toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : [];
|
||||
const delta: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content || '',
|
||||
};
|
||||
if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent;
|
||||
if (choice.toolCalls.length > 0) {
|
||||
delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({
|
||||
if (toolCalls.length > 0) {
|
||||
delta.tool_calls = toolCalls.map((toolCall, toolIndex) => ({
|
||||
index: toolIndex,
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
@@ -167,11 +182,14 @@ export function buildNormalizedFinalToOpenAiChatChunks(normalized: NormalizedFin
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
})).sort((left, right) => left.index - right.index),
|
||||
choices: choices.map((choice) => {
|
||||
const toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : [];
|
||||
return {
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finishReason || (toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
]
|
||||
: buildSyntheticOpenAiChunks(normalized);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeCodexResponsesBodyForProxy } from './codexCompatibility.js';
|
||||
|
||||
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
|
||||
import {
|
||||
CODEX_DEFAULT_INSTRUCTIONS,
|
||||
normalizeCodexResponsesBodyForProxy,
|
||||
} from './codexCompatibility.js';
|
||||
|
||||
describe('normalizeCodexResponsesBodyForProxy', () => {
|
||||
it('extracts codex system input into top-level instructions before proxying upstream', () => {
|
||||
@@ -22,6 +23,7 @@ describe('normalizeCodexResponsesBodyForProxy', () => {
|
||||
max_output_tokens: 512,
|
||||
max_completion_tokens: 256,
|
||||
max_tokens: 128,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0.3,
|
||||
store: true,
|
||||
}, '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', () => {
|
||||
const body = normalizeCodexResponsesBodyForProxy({
|
||||
input: 'hello',
|
||||
|
||||
@@ -6,17 +6,9 @@ function asTrimmedString(value: unknown): string {
|
||||
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 {
|
||||
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 '';
|
||||
function extractTextValue(content: Record<string, unknown>): string {
|
||||
if (typeof content.text === 'string') return content.text;
|
||||
if (typeof content.content === 'string') return content.content;
|
||||
if (typeof content.output_text === 'string') return content.output_text;
|
||||
@@ -24,6 +16,75 @@ function extractTextFromContent(content: unknown): string {
|
||||
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(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
@@ -46,10 +107,19 @@ function extractSystemMessagesToInstructions(
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = extractTextFromContent(item.content);
|
||||
if (text.trim()) {
|
||||
extractedSystemTexts.push(text);
|
||||
const { extractedTexts, remainingContent } = splitExtractedSystemContent(item.content);
|
||||
if (extractedTexts.length > 0) {
|
||||
extractedSystemTexts.push(extractedTexts.join('\n'));
|
||||
if (remainingContent !== undefined) {
|
||||
remainingInput.push({
|
||||
...item,
|
||||
content: remainingContent,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
remainingInput.push(item);
|
||||
}
|
||||
|
||||
if (extractedSystemTexts.length <= 0) return body;
|
||||
@@ -99,6 +169,7 @@ function stripCodexUnsupportedResponsesFields(
|
||||
delete next.max_output_tokens;
|
||||
delete next.max_completion_tokens;
|
||||
delete next.max_tokens;
|
||||
delete next.stream_options;
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user