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({
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
+7 -2
View File
@@ -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' },
+7
View File
@@ -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);
+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;
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) => ({
choices: choices.map((choice) => {
const toolCalls = Array.isArray(choice.toolCalls) ? choice.toolCalls : [];
return {
index: choice.index,
delta: {},
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
})).sort((left, right) => left.index - right.index),
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;
}