forked from boos/BoosAPI
feat(server): add proactive channel probes and load-aware stable routing / 服务端新增主动探活与负载感知稳定路由 (#330)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * feat(proxy): add sticky session channel coordination * feat(server): add proactive channel probes and load-aware stable routing * test(server): stabilize session load routing selection assertion * fix(proxy): harden codex forwarding stability * fix(router): rotate stable-first channels by healthy order * fix(server): address probe review feedback * fix(server): address probe review follow-ups * fix(ci): address CodeQL and core test failures * feat(server): refine stable-first routing recovery and observation * Finish PR 330 settings and review fixes * Fix chat site endpoint CI mock * Address latest PR 330 review comments --------- Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -1,5 +1,57 @@
|
||||
import { type DownstreamFormat } from '../../shared/normalized.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function hasAnthropicContinuationHint(body: Record<string, unknown>): boolean {
|
||||
if (asTrimmedString(body.previous_response_id)) return true;
|
||||
if (asTrimmedString(body.prompt_cache_key)) return true;
|
||||
if (isRecord(body.metadata) && asTrimmedString(body.metadata.user_id)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOrphanAnthropicToolResult(body: Record<string, unknown>): boolean {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const seenToolUseIds = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || !Array.isArray(message.content)) continue;
|
||||
|
||||
const role = asTrimmedString(message.role).toLowerCase();
|
||||
for (const block of message.content) {
|
||||
if (!isRecord(block)) continue;
|
||||
const type = asTrimmedString(block.type).toLowerCase();
|
||||
if (!type) continue;
|
||||
|
||||
if (role === 'assistant' && type === 'tool_use') {
|
||||
const toolUseId = asTrimmedString(block.id);
|
||||
if (toolUseId) seenToolUseIds.add(toolUseId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type !== 'tool_result') continue;
|
||||
const toolUseId = asTrimmedString(block.tool_use_id ?? block.toolUseId);
|
||||
if (toolUseId && !seenToolUseIds.has(toolUseId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldPreferResponsesForAnthropicContinuation(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
): boolean {
|
||||
if (!body) return false;
|
||||
return hasAnthropicContinuationHint(body) && hasOrphanAnthropicToolResult(body);
|
||||
}
|
||||
|
||||
export function shouldRetryNormalizedMessagesBody(input: {
|
||||
downstreamFormat: DownstreamFormat;
|
||||
endpointPath: string;
|
||||
|
||||
@@ -62,6 +62,30 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes primitive content arrays into text blocks and drops unsupported entries', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: ['hello', true, 42, null, { type: 'text', text: 'done' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'text', text: 'true' },
|
||||
{ type: 'text', text: '42' },
|
||||
{ type: 'text', text: 'done', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops output_config.effort unless thinking stays adaptive', () => {
|
||||
const enabled = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
|
||||
@@ -425,6 +425,43 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
function sanitizeAnthropicMessage(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const next: Record<string, unknown> = { ...message };
|
||||
const rawContent = next.content;
|
||||
|
||||
if (Array.isArray(rawContent)) {
|
||||
const content = rawContent
|
||||
.map((item) => {
|
||||
if (isRecord(item)) return sanitizeAnthropicContentBlock(item);
|
||||
if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
|
||||
return sanitizeAnthropicContentBlock(toAnthropicTextBlock(String(item)) ?? {});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
if (content.length <= 0) return null;
|
||||
next.content = content;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (rawContent === null || rawContent === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRecord(rawContent)) {
|
||||
const block = sanitizeAnthropicContentBlock(rawContent);
|
||||
if (!block) return null;
|
||||
next.content = [block];
|
||||
return next;
|
||||
}
|
||||
|
||||
if (typeof rawContent === 'number' || typeof rawContent === 'boolean') {
|
||||
next.content = String(rawContent);
|
||||
}
|
||||
|
||||
return typeof next.content === 'string' ? next : null;
|
||||
}
|
||||
|
||||
function isCacheableAnthropicMessageBlock(item: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'thinking' || type === 'redacted_thinking') return false;
|
||||
@@ -710,15 +747,9 @@ export function sanitizeAnthropicMessagesBody(
|
||||
const rawMessages = Array.isArray(sanitized.messages) ? sanitized.messages : [];
|
||||
if (rawMessages.length > 0) {
|
||||
sanitized.messages = rawMessages.map((message) => {
|
||||
if (!isRecord(message)) return message;
|
||||
const next = { ...message };
|
||||
if (Array.isArray(next.content)) {
|
||||
next.content = next.content
|
||||
.map((item) => (isRecord(item) ? sanitizeAnthropicContentBlock(item) : item))
|
||||
.filter((item) => item !== null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!isRecord(message)) return null;
|
||||
return sanitizeAnthropicMessage(message);
|
||||
}).filter((message): message is Record<string, unknown> => message !== null);
|
||||
}
|
||||
|
||||
if (autoOptimizeCacheControls) {
|
||||
|
||||
@@ -43,6 +43,66 @@ describe('anthropicMessagesInbound', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops null-content turns instead of rejecting the whole Claude request', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: null },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.parsed.upstreamBody.messages).toEqual([
|
||||
{ role: 'user', content: 'hello' },
|
||||
]);
|
||||
expect(result.value?.parsed.claudeOriginalBody).toMatchObject({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves continuation hints for tool_result-only follow-up turns', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key-1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_missing',
|
||||
content: [{ type: 'text', text: '{"matches":1}' }],
|
||||
},
|
||||
{ type: 'text', text: 'continue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.parsed.upstreamBody).toMatchObject({
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key-1',
|
||||
messages: [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_missing',
|
||||
content: '{"matches":1}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'continue',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes adaptive effort and tool choice at the inbound boundary', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractResponsesTerminalResponseId,
|
||||
isResponsesPreviousResponseNotFoundError,
|
||||
shouldInferResponsesPreviousResponseId,
|
||||
stripResponsesPreviousResponseId,
|
||||
withResponsesPreviousResponseId,
|
||||
} from './continuation.js';
|
||||
|
||||
describe('responses continuation helpers', () => {
|
||||
it('infers previous_response_id only for tool-output follow-up turns that omit it', () => {
|
||||
expect(shouldInferResponsesPreviousResponseId({
|
||||
input: [{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: '{"ok":true}',
|
||||
}],
|
||||
}, 'resp_prev_1')).toBe(true);
|
||||
|
||||
expect(shouldInferResponsesPreviousResponseId({
|
||||
previous_response_id: 'resp_existing',
|
||||
input: [{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: '{"ok":true}',
|
||||
}],
|
||||
}, 'resp_prev_1')).toBe(false);
|
||||
|
||||
expect(shouldInferResponsesPreviousResponseId({
|
||||
input: [{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello' }],
|
||||
}],
|
||||
}, 'resp_prev_1')).toBe(false);
|
||||
});
|
||||
|
||||
it('adds and removes previous_response_id without mutating other fields', () => {
|
||||
const body = {
|
||||
model: 'gpt-5.4',
|
||||
input: [{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: '{"ok":true}',
|
||||
}],
|
||||
};
|
||||
|
||||
const withPrevious = withResponsesPreviousResponseId(body, 'resp_prev_1');
|
||||
expect(withPrevious).toMatchObject({
|
||||
model: 'gpt-5.4',
|
||||
previous_response_id: 'resp_prev_1',
|
||||
});
|
||||
|
||||
const stripped = stripResponsesPreviousResponseId(withPrevious);
|
||||
expect(stripped.removed).toBe(true);
|
||||
expect(stripped.body.previous_response_id).toBeUndefined();
|
||||
expect(stripped.body.input).toEqual(body.input);
|
||||
});
|
||||
|
||||
it('detects previous_response_not_found from either raw text or structured payloads', () => {
|
||||
expect(isResponsesPreviousResponseNotFoundError({
|
||||
rawErrText: JSON.stringify({
|
||||
error: {
|
||||
code: 'previous_response_not_found',
|
||||
message: 'previous_response_not_found',
|
||||
},
|
||||
}),
|
||||
})).toBe(true);
|
||||
|
||||
expect(isResponsesPreviousResponseNotFoundError({
|
||||
payload: {
|
||||
type: 'response.failed',
|
||||
response: {
|
||||
error: {
|
||||
message: 'Previous response id not found',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
})).toBe(true);
|
||||
|
||||
expect(isResponsesPreviousResponseNotFoundError({
|
||||
rawErrText: JSON.stringify({
|
||||
error: {
|
||||
code: 'rate_limit_exceeded',
|
||||
message: 'too many requests',
|
||||
},
|
||||
}),
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts terminal response ids from responses payloads and websocket terminal events', () => {
|
||||
expect(extractResponsesTerminalResponseId({
|
||||
id: 'resp_1',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
})).toBe('resp_1');
|
||||
|
||||
expect(extractResponsesTerminalResponseId({
|
||||
type: 'response.failed',
|
||||
response: {
|
||||
id: 'resp_2',
|
||||
status: 'failed',
|
||||
error: {
|
||||
message: 'tool failed',
|
||||
},
|
||||
},
|
||||
})).toBe('resp_2');
|
||||
|
||||
expect(extractResponsesTerminalResponseId({
|
||||
type: 'response.created',
|
||||
response: {
|
||||
id: 'resp_3',
|
||||
status: 'in_progress',
|
||||
},
|
||||
})).toBeNull();
|
||||
|
||||
expect(extractResponsesTerminalResponseId({
|
||||
id: 'resp_in_progress',
|
||||
object: 'response',
|
||||
status: 'in_progress',
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
const RESPONSES_TOOL_OUTPUT_TYPES = new Set([
|
||||
'function_call_output',
|
||||
'custom_tool_call_output',
|
||||
]);
|
||||
|
||||
const RESPONSES_TERMINAL_STATUSES = new Set([
|
||||
'completed',
|
||||
'failed',
|
||||
'incomplete',
|
||||
]);
|
||||
|
||||
function collectResponsesErrorFragments(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
if (typeof value === 'string') return [value];
|
||||
if (!isRecord(value)) return [];
|
||||
|
||||
const fragments = [
|
||||
value.type,
|
||||
value.code,
|
||||
value.message,
|
||||
value.reason,
|
||||
]
|
||||
.map((entry) => asTrimmedString(entry))
|
||||
.filter(Boolean);
|
||||
|
||||
if (isRecord(value.error)) {
|
||||
fragments.push(...collectResponsesErrorFragments(value.error));
|
||||
}
|
||||
|
||||
if (isRecord(value.response)) {
|
||||
fragments.push(...collectResponsesErrorFragments(value.response));
|
||||
}
|
||||
|
||||
if (isRecord(value.incomplete_details)) {
|
||||
fragments.push(...collectResponsesErrorFragments(value.incomplete_details));
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
function hasResponsesToolOutput(input: unknown): boolean {
|
||||
if (!Array.isArray(input)) return false;
|
||||
return input.some((item) => {
|
||||
if (!isRecord(item)) return false;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!RESPONSES_TOOL_OUTPUT_TYPES.has(type)) return false;
|
||||
return asTrimmedString(item.call_id ?? item.id).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldInferResponsesPreviousResponseId(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
candidatePreviousResponseId: unknown,
|
||||
): candidatePreviousResponseId is string {
|
||||
if (!body) return false;
|
||||
if (asTrimmedString(body.previous_response_id)) return false;
|
||||
const candidate = asTrimmedString(candidatePreviousResponseId);
|
||||
if (!candidate) return false;
|
||||
return hasResponsesToolOutput(body.input);
|
||||
}
|
||||
|
||||
export function withResponsesPreviousResponseId(
|
||||
body: Record<string, unknown>,
|
||||
previousResponseId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...body,
|
||||
previous_response_id: previousResponseId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function stripResponsesPreviousResponseId(
|
||||
body: Record<string, unknown>,
|
||||
): { body: Record<string, unknown>; removed: boolean } {
|
||||
if (!Object.prototype.hasOwnProperty.call(body, 'previous_response_id')) {
|
||||
return { body, removed: false };
|
||||
}
|
||||
const next = { ...body };
|
||||
delete next.previous_response_id;
|
||||
return { body: next, removed: true };
|
||||
}
|
||||
|
||||
export function isResponsesPreviousResponseNotFoundError(input: {
|
||||
rawErrText?: string | null;
|
||||
payload?: unknown;
|
||||
}): boolean {
|
||||
const fragments = [
|
||||
...collectResponsesErrorFragments(input.payload),
|
||||
];
|
||||
const rawErrText = asTrimmedString(input.rawErrText);
|
||||
if (rawErrText) fragments.push(rawErrText);
|
||||
const combined = fragments.join(' ').toLowerCase();
|
||||
if (!combined) return false;
|
||||
return (
|
||||
combined.includes('previous_response_not_found')
|
||||
|| /previous[\s_-]*response(?:[\s_-]*(?:id|identifier))?[\s_-]*not[\s_-]*found/i.test(combined)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractResponsesTerminalResponseId(payload: unknown): string | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
|
||||
const type = asTrimmedString(payload.type).toLowerCase();
|
||||
if (
|
||||
type === 'response.completed'
|
||||
|| type === 'response.failed'
|
||||
|| type === 'response.incomplete'
|
||||
) {
|
||||
if (isRecord(payload.response)) {
|
||||
const responseId = asTrimmedString(payload.response.id);
|
||||
return responseId || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const object = asTrimmedString(payload.object).toLowerCase();
|
||||
const status = asTrimmedString(payload.status).toLowerCase();
|
||||
if (
|
||||
(object === 'response' && RESPONSES_TERMINAL_STATUSES.has(status))
|
||||
|| RESPONSES_TERMINAL_STATUSES.has(status)
|
||||
) {
|
||||
const responseId = asTrimmedString(payload.id);
|
||||
return responseId || null;
|
||||
}
|
||||
|
||||
if (isRecord(payload.response)) {
|
||||
const nestedStatus = asTrimmedString(payload.response.status).toLowerCase();
|
||||
if (RESPONSES_TERMINAL_STATUSES.has(nestedStatus)) {
|
||||
const responseId = asTrimmedString(payload.response.id);
|
||||
return responseId || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1234,6 +1234,12 @@ export function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>):
|
||||
if (body.tools !== undefined) payload.tools = convertClaudeToolsToOpenAiChat(body.tools);
|
||||
if (body.tool_choice !== undefined) payload.tool_choice = convertClaudeToolChoiceToOpenAiChat(body.tool_choice);
|
||||
|
||||
const promptCacheKey = asTrimmedString(body.prompt_cache_key);
|
||||
if (promptCacheKey) payload.prompt_cache_key = promptCacheKey;
|
||||
|
||||
const previousResponseId = asTrimmedString(body.previous_response_id);
|
||||
if (previousResponseId) payload.previous_response_id = previousResponseId;
|
||||
|
||||
return { model, stream, messages, payload };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user