forked from boos/BoosAPI
feat: 增强聊天代理流行为,优化错误处理和工具调用逻辑
This commit is contained in:
@@ -1096,6 +1096,105 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(targetUrl).toContain('/v1/messages');
|
expect(targetUrl).toContain('/v1/messages');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers /v1/messages on openai platform for claude-family models on /v1/chat/completions', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'claude-opus-4-6',
|
||||||
|
supportedEndpointTypes: ['/v1/chat/completions', 'openai'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
|
||||||
|
account: { id: 33, username: 'demo-user' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'sk-openai',
|
||||||
|
actualModel: 'claude-opus-4-6',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'msg_openai_platform_claude',
|
||||||
|
type: 'message',
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
content: [{ type: 'text', text: 'messages endpoint selected' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toContain('/v1/messages');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back from /v1/messages to /v1/chat/completions on openai platform when messages endpoint is unavailable', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
|
||||||
|
account: { id: 33, username: 'demo-user' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'sk-openai',
|
||||||
|
actualModel: 'claude-opus-4-6',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: { message: 'Not Found', type: 'not_found_error' },
|
||||||
|
}), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl_openai_fallback_chat',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1_706_000_777,
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'fallback to chat completed' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toContain('/v1/messages');
|
||||||
|
expect(secondUrl).toContain('/v1/chat/completions');
|
||||||
|
});
|
||||||
|
|
||||||
it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => {
|
it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
@@ -1359,6 +1458,71 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(forwardedBody.tool_choice).toEqual({ type: 'tool', name: 'Glob' });
|
expect(forwardedBody.tool_choice).toEqual({ type: 'tool', name: 'Glob' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('groups consecutive tool messages into one anthropic user turn when routing /v1/chat/completions to /v1/messages', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
|
||||||
|
account: { id: 33, username: 'demo-user' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'sk-claude',
|
||||||
|
actualModel: 'claude-sonnet-4-5-20250929',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'msg_claude_grouped_tool_results',
|
||||||
|
type: 'message',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
stream: false,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_one',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'Glob', arguments: '{"pattern":"README*"}' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'call_two',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'Read', arguments: '{"file":"README.md"}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: 'tool', tool_call_id: 'call_one', content: '{"matches":["README.md"]}' },
|
||||||
|
{ role: 'tool', tool_call_id: 'call_two', content: '{"content":"hello"}' },
|
||||||
|
{ role: 'user', content: 'continue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const forwardedBody = JSON.parse(options.body);
|
||||||
|
expect(Array.isArray(forwardedBody.messages)).toBe(true);
|
||||||
|
|
||||||
|
const userMessages = forwardedBody.messages.filter((item: any) => item?.role === 'user');
|
||||||
|
expect(userMessages.length).toBe(1);
|
||||||
|
expect(Array.isArray(userMessages[0]?.content)).toBe(true);
|
||||||
|
expect(userMessages[0].content.filter((part: any) => part?.type === 'tool_result').length).toBe(2);
|
||||||
|
expect(userMessages[0].content.some((part: any) => part?.type === 'tool_result' && part?.tool_use_id === 'call_one')).toBe(true);
|
||||||
|
expect(userMessages[0].content.some((part: any) => part?.type === 'tool_result' && part?.tool_use_id === 'call_two')).toBe(true);
|
||||||
|
expect(userMessages[0].content.some((part: any) => part?.type === 'text' && part?.text === 'continue')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('converts /v1/responses function_call SSE to OpenAI tool_calls on /v1/chat/completions', async () => {
|
it('converts /v1/responses function_call SSE to OpenAI tool_calls on /v1/chat/completions', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
@@ -1493,6 +1657,47 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(matches.length).toBe(1);
|
expect(matches.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits finish_reason error when /v1/chat/completions receives response.failed from /v1/responses upstream', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
supportedEndpointTypes: ['/v1/responses'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_fail_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: response.failed\ndata: {"type":"response.failed","response":{"id":"resp_fail_1","model":"upstream-gpt","status":"failed","error":{"message":"tool execution failed"}}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-haiku-4-5-20251001',
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: 'user', content: 'find readme' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain('"finish_reason":"error"');
|
||||||
|
expect(response.body).toContain('[DONE]');
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => {
|
it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
@@ -1569,7 +1774,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/v1/chat/completions',
|
url: '/v1/chat/completions',
|
||||||
payload: {
|
payload: {
|
||||||
model: 'claude-haiku-4-5-20251001',
|
model: 'gpt-4o-mini',
|
||||||
stream: false,
|
stream: false,
|
||||||
tools: [{
|
tools: [{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ export function normalizeStopReason(raw: unknown): string | null {
|
|||||||
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
|
if (value === 'failed' || value === 'error') {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
value === 'end_turn'
|
value === 'end_turn'
|
||||||
|| value === 'stop'
|
|| value === 'stop'
|
||||||
@@ -803,6 +807,19 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') {
|
||||||
|
const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null;
|
||||||
|
const finishReason = normalizeStopReason(
|
||||||
|
(responsePayload as any)?.status
|
||||||
|
?? (payload as any).status
|
||||||
|
?? (payload as any).type,
|
||||||
|
) || 'error';
|
||||||
|
return {
|
||||||
|
finishReason,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
|
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
|
||||||
const item = (payload as any).item as Record<string, unknown>;
|
const item = (payload as any).item as Record<string, unknown>;
|
||||||
if (item.type === 'function_call') {
|
if (item.type === 'function_call') {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
|||||||
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
|
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js';
|
import { isEndpointDowngradeError, resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const baseContext = {
|
const baseContext = {
|
||||||
site: {
|
site: {
|
||||||
@@ -104,7 +104,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
'gpt-5.3',
|
'gpt-5.3',
|
||||||
'openai',
|
'openai',
|
||||||
);
|
);
|
||||||
expect(openaiOrder).toEqual(['chat', 'responses']);
|
expect(openaiOrder).toEqual(['chat', 'responses', 'messages']);
|
||||||
|
|
||||||
const openaiResponsesOrder = await resolveUpstreamEndpointCandidates(
|
const openaiResponsesOrder = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
@@ -114,7 +114,17 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
'gpt-5.3',
|
'gpt-5.3',
|
||||||
'responses',
|
'responses',
|
||||||
);
|
);
|
||||||
expect(openaiResponsesOrder).toEqual(['responses', 'chat']);
|
expect(openaiResponsesOrder).toEqual(['responses', 'chat', 'messages']);
|
||||||
|
|
||||||
|
const openaiClaudeOrder = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'openai' },
|
||||||
|
},
|
||||||
|
'claude-opus-4-6',
|
||||||
|
'openai',
|
||||||
|
);
|
||||||
|
expect(openaiClaudeOrder).toEqual(['messages', 'chat', 'responses']);
|
||||||
|
|
||||||
const claudeOrder = await resolveUpstreamEndpointCandidates(
|
const claudeOrder = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
@@ -127,6 +137,29 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
expect(claudeOrder).toEqual(['messages']);
|
expect(claudeOrder).toEqual(['messages']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'claude-opus-4-6',
|
||||||
|
supportedEndpointTypes: ['/v1/chat/completions', 'openai'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'openai' },
|
||||||
|
},
|
||||||
|
'claude-opus-4-6',
|
||||||
|
'openai',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(order).toEqual(['messages', 'chat', 'responses']);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps anyrouter messages-first special case', async () => {
|
it('keeps anyrouter messages-first special case', async () => {
|
||||||
const openaiOrder = await resolveUpstreamEndpointCandidates(
|
const openaiOrder = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
@@ -148,4 +181,10 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
);
|
);
|
||||||
expect(responsesOrder).toEqual(['responses', 'messages', 'chat']);
|
expect(responsesOrder).toEqual(['responses', 'messages', 'chat']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats endpoint-not-found responses as downgrade candidates', () => {
|
||||||
|
expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true);
|
||||||
|
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
|
||||||
|
expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -394,7 +394,63 @@ function convertOpenAiBodyToMessagesBody(
|
|||||||
content: string | Array<Record<string, unknown>>;
|
content: string | Array<Record<string, unknown>>;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const item of rawMessages) {
|
const appendAssistantMessage = (item: Record<string, unknown>) => {
|
||||||
|
const role = asTrimmedString(item.role).toLowerCase() || 'assistant';
|
||||||
|
if (role !== 'assistant') return;
|
||||||
|
|
||||||
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
const textContent = normalizeContentText(item.content).trim();
|
||||||
|
if (textContent) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: 'text',
|
||||||
|
text: textContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||||
|
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||||
|
const toolCall = rawToolCalls[index];
|
||||||
|
if (!isRecord(toolCall)) continue;
|
||||||
|
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||||
|
|
||||||
|
const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`;
|
||||||
|
const name = (
|
||||||
|
asTrimmedString(functionPart.name)
|
||||||
|
|| asTrimmedString(toolCall.name)
|
||||||
|
|| `tool_${index}`
|
||||||
|
);
|
||||||
|
const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments);
|
||||||
|
|
||||||
|
contentBlocks.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentBlocks.length <= 0) return;
|
||||||
|
|
||||||
|
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
|
||||||
|
const singleText = asTrimmedString(contentBlocks[0].text);
|
||||||
|
if (singleText) {
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: singleText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: contentBlocks,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let messageIndex = 0; messageIndex < rawMessages.length; messageIndex += 1) {
|
||||||
|
const item = rawMessages[messageIndex];
|
||||||
if (!isRecord(item)) continue;
|
if (!isRecord(item)) continue;
|
||||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||||
|
|
||||||
@@ -406,73 +462,59 @@ function convertOpenAiBodyToMessagesBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'tool') {
|
if (role === 'tool') {
|
||||||
const toolUseId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
const toolResultBlocks: Array<Record<string, unknown>> = [];
|
||||||
if (!toolUseId) continue;
|
let cursor = messageIndex;
|
||||||
|
while (cursor < rawMessages.length) {
|
||||||
|
const toolCandidate = rawMessages[cursor];
|
||||||
|
if (!isRecord(toolCandidate)) break;
|
||||||
|
const toolRole = asTrimmedString(toolCandidate.role).toLowerCase();
|
||||||
|
if (toolRole !== 'tool') break;
|
||||||
|
|
||||||
const toolResultContent = normalizeToolMessageContent(item.content).trim();
|
const toolUseId = (
|
||||||
if (!toolResultContent) continue;
|
asTrimmedString(toolCandidate.tool_call_id)
|
||||||
|
|| asTrimmedString(toolCandidate.id)
|
||||||
|
);
|
||||||
|
const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim();
|
||||||
|
if (toolUseId && toolResultContent) {
|
||||||
|
toolResultBlocks.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: toolUseId,
|
||||||
|
content: toolResultContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
messages.push({
|
if (toolResultBlocks.length > 0 && cursor < rawMessages.length) {
|
||||||
role: 'user',
|
const nextItem = rawMessages[cursor];
|
||||||
content: [{
|
if (isRecord(nextItem)) {
|
||||||
type: 'tool_result',
|
const nextRole = asTrimmedString(nextItem.role).toLowerCase();
|
||||||
tool_use_id: toolUseId,
|
if (nextRole === 'user') {
|
||||||
content: toolResultContent,
|
const userText = normalizeContentText(nextItem.content).trim();
|
||||||
}],
|
if (userText) {
|
||||||
});
|
toolResultBlocks.push({
|
||||||
|
type: 'text',
|
||||||
|
text: userText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolResultBlocks.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: toolResultBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIndex = cursor - 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
appendAssistantMessage(item);
|
||||||
|
|
||||||
const textContent = normalizeContentText(item.content).trim();
|
|
||||||
if (textContent) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'text',
|
|
||||||
text: textContent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
|
||||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
|
||||||
const toolCall = rawToolCalls[index];
|
|
||||||
if (!isRecord(toolCall)) continue;
|
|
||||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
|
||||||
|
|
||||||
const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`;
|
|
||||||
const name = (
|
|
||||||
asTrimmedString(functionPart.name)
|
|
||||||
|| asTrimmedString(toolCall.name)
|
|
||||||
|| `tool_${index}`
|
|
||||||
);
|
|
||||||
const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments);
|
|
||||||
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'tool_use',
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentBlocks.length <= 0) continue;
|
|
||||||
|
|
||||||
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
|
|
||||||
const singleText = asTrimmedString(contentBlocks[0].text);
|
|
||||||
if (singleText) {
|
|
||||||
messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: singleText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: contentBlocks,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,9 +740,14 @@ function preferredEndpointOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'openai') {
|
if (platform === 'openai') {
|
||||||
|
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
|
||||||
|
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
|
||||||
|
// Keep chat/responses as fallbacks when messages is unavailable.
|
||||||
|
return ['messages', 'chat', 'responses'];
|
||||||
|
}
|
||||||
return downstreamFormat === 'responses'
|
return downstreamFormat === 'responses'
|
||||||
? ['responses', 'chat']
|
? ['responses', 'chat', 'messages']
|
||||||
: ['chat', 'responses'];
|
: ['chat', 'responses', 'messages'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'claude') {
|
if (platform === 'claude') {
|
||||||
@@ -781,6 +828,14 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
);
|
);
|
||||||
if (!matched) return preferred;
|
if (!matched) return preferred;
|
||||||
|
|
||||||
|
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
||||||
|
preferMessagesForClaudeModel
|
||||||
|
&& downstreamFormat !== 'responses'
|
||||||
|
);
|
||||||
|
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
||||||
const normalizedSupportedRaw = supportedRaw
|
const normalizedSupportedRaw = supportedRaw
|
||||||
.map((item) => asTrimmedString(item).toLowerCase())
|
.map((item) => asTrimmedString(item).toLowerCase())
|
||||||
@@ -864,6 +919,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sitePlatform === 'openai') {
|
if (sitePlatform === 'openai') {
|
||||||
|
if (endpoint === 'messages') return '/v1/messages';
|
||||||
if (endpoint === 'responses') return '/v1/responses';
|
if (endpoint === 'responses') return '/v1/responses';
|
||||||
return '/v1/chat/completions';
|
return '/v1/chat/completions';
|
||||||
}
|
}
|
||||||
@@ -961,6 +1017,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||||
if (status < 400) return false;
|
if (status < 400) return false;
|
||||||
const text = (upstreamErrorText || '').toLowerCase();
|
const text = (upstreamErrorText || '').toLowerCase();
|
||||||
|
if (status === 404 || status === 405 || status === 501) return true;
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
|
|
||||||
let parsedCode = '';
|
let parsedCode = '';
|
||||||
@@ -982,12 +1039,34 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
text.includes('convert_request_failed')
|
text.includes('convert_request_failed')
|
||||||
|
|| text.includes('not found')
|
||||||
|
|| text.includes('unknown endpoint')
|
||||||
|
|| text.includes('unsupported endpoint')
|
||||||
|
|| text.includes('unsupported path')
|
||||||
|
|| text.includes('unrecognized request url')
|
||||||
|
|| text.includes('no route matched')
|
||||||
|
|| text.includes('does not exist')
|
||||||
|
|| (status === 400 && text.includes('unsupported'))
|
||||||
|| text.includes('not implemented')
|
|| text.includes('not implemented')
|
||||||
|| text.includes('api not implemented')
|
|| text.includes('api not implemented')
|
||||||
|| text.includes('unsupported legacy protocol')
|
|| text.includes('unsupported legacy protocol')
|
||||||
|| parsedCode === 'convert_request_failed'
|
|| parsedCode === 'convert_request_failed'
|
||||||
|
|| parsedCode === 'not_found'
|
||||||
|
|| parsedCode === 'endpoint_not_found'
|
||||||
|
|| parsedCode === 'unknown_endpoint'
|
||||||
|
|| parsedCode === 'unsupported_endpoint'
|
||||||
|| parsedCode === 'bad_response_status_code'
|
|| parsedCode === 'bad_response_status_code'
|
||||||
|
|| parsedType === 'not_found_error'
|
||||||
|
|| parsedType === 'invalid_request_error'
|
||||||
|
|| parsedType === 'unsupported_endpoint'
|
||||||
|
|| parsedType === 'unsupported_path'
|
||||||
|| parsedType === 'bad_response_status_code'
|
|| parsedType === 'bad_response_status_code'
|
||||||
|
|| parsedMessage.includes('unknown endpoint')
|
||||||
|
|| parsedMessage.includes('unsupported endpoint')
|
||||||
|
|| parsedMessage.includes('unsupported path')
|
||||||
|
|| parsedMessage.includes('unrecognized request url')
|
||||||
|
|| parsedMessage.includes('no route matched')
|
||||||
|
|| parsedMessage.includes('does not exist')
|
||||||
|| parsedMessage.includes('bad_response_status_code')
|
|| parsedMessage.includes('bad_response_status_code')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user