forked from boos/BoosAPI
feat: 更新文档链接,增强客户端接入指南,优化代理日志记录和错误处理
This commit is contained in:
@@ -724,6 +724,49 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('response.completed');
|
||||
});
|
||||
|
||||
it('emits response.failed without synthetic response.completed when upstream stream fails on /v1/responses', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'upstream-gpt',
|
||||
supportedEndpointTypes: ['/v1/chat/completions'],
|
||||
},
|
||||
],
|
||||
groupRatio: {},
|
||||
});
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r3","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
|
||||
controller.enqueue(encoder.encode('event: error\ndata: {"type":"error","error":{"message":"upstream stream failed","type":"upstream_error"}}\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/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('response.failed');
|
||||
expect(response.body).toContain('"status":"failed"');
|
||||
expect(response.body).not.toContain('response.completed');
|
||||
expect(response.body).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('preserves Responses-specific payload fields and forwards openai headers on /v1/responses', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_passthrough',
|
||||
@@ -1666,7 +1709,7 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('prefers OpenAI-compatible endpoint when catalog uses generic openai/anthropic labels', async () => {
|
||||
it('prefers Messages endpoint for claude-family models when catalog uses generic openai/anthropic labels', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
@@ -1678,16 +1721,12 @@ describe('chat proxy stream behavior', () => {
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'chatcmpl-openai-first',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_002,
|
||||
id: 'msg-claude-first',
|
||||
type: 'message',
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'hello from openai endpoint' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
|
||||
content: [{ type: 'text', text: 'hello from messages endpoint' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1705,11 +1744,11 @@ describe('chat proxy stream behavior', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body?.choices?.[0]?.message?.content).toContain('hello from openai endpoint');
|
||||
expect(body?.choices?.[0]?.message?.content).toContain('hello from messages endpoint');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/chat/completions');
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('falls back to /v1/messages when catalog only declares openai and chat endpoint fails', async () => {
|
||||
@@ -1750,7 +1789,7 @@ describe('chat proxy stream behavior', () => {
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
model: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
@@ -1805,7 +1844,7 @@ describe('chat proxy stream behavior', () => {
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
model: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
@@ -1860,7 +1899,7 @@ describe('chat proxy stream behavior', () => {
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
model: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
|
||||
@@ -170,6 +170,7 @@ async function handleChatProxyRequest(
|
||||
},
|
||||
modelName,
|
||||
downstreamFormat,
|
||||
requestedModel,
|
||||
);
|
||||
let startTime = Date.now();
|
||||
|
||||
@@ -664,5 +665,7 @@ function logProxy(
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
}).run();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('[proxy/chat] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ export function normalizeStopReason(raw: unknown): string | null {
|
||||
return 'tool_calls';
|
||||
}
|
||||
|
||||
return 'stop';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toClaudeStopReason(finishReason: string | null | undefined): string {
|
||||
|
||||
@@ -240,7 +240,7 @@ function logProxy(
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
) {
|
||||
try {
|
||||
try {
|
||||
db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
@@ -256,7 +256,9 @@ function logProxy(
|
||||
estimatedCost,
|
||||
errorMessage,
|
||||
retryCount,
|
||||
}).run();
|
||||
} catch {}
|
||||
}
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/completions] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ function logProxy(
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
) {
|
||||
try {
|
||||
try {
|
||||
db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
@@ -171,7 +171,9 @@ function logProxy(
|
||||
estimatedCost,
|
||||
errorMessage,
|
||||
retryCount,
|
||||
}).run();
|
||||
} catch {}
|
||||
}
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/embeddings] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ function logProxy(
|
||||
retryCount: number,
|
||||
estimatedCost = 0,
|
||||
) {
|
||||
try {
|
||||
try {
|
||||
db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
@@ -141,7 +141,9 @@ function logProxy(
|
||||
estimatedCost,
|
||||
errorMessage,
|
||||
retryCount,
|
||||
}).run();
|
||||
} catch {}
|
||||
}
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/images] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ function emitResponsesEvent(
|
||||
|
||||
function buildResponseObject(
|
||||
state: ResponsesStreamState,
|
||||
status: 'in_progress' | 'completed',
|
||||
status: 'in_progress' | 'completed' | 'failed',
|
||||
usage?: UsageSummary,
|
||||
): Record<string, unknown> {
|
||||
const response: Record<string, unknown> = {
|
||||
@@ -837,6 +837,44 @@ function completeResponsesStream(
|
||||
return events;
|
||||
}
|
||||
|
||||
function failResponsesStream(
|
||||
state: ResponsesStreamState,
|
||||
streamContext: { id: string; model: string; created: number },
|
||||
usage: UsageSummary,
|
||||
payload?: unknown,
|
||||
): string[] {
|
||||
if (state.completed) return [];
|
||||
|
||||
const events: string[] = [];
|
||||
events.push(...ensureResponsesStarted(state, streamContext));
|
||||
|
||||
const responsePayload = isRecord(payload) && isRecord((payload as any).response)
|
||||
? ((payload as any).response as Record<string, unknown>)
|
||||
: null;
|
||||
const errorPayload = isRecord(payload) && isRecord((payload as any).error)
|
||||
? ((payload as any).error as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (responsePayload && asTrimmedString(responsePayload.id)) {
|
||||
state.responseId = ensureResponseId(asTrimmedString(responsePayload.id));
|
||||
}
|
||||
if (responsePayload && asTrimmedString(responsePayload.model)) {
|
||||
state.model = asTrimmedString(responsePayload.model);
|
||||
}
|
||||
|
||||
const failedResponse = buildResponseObject(state, 'failed', usage);
|
||||
if (errorPayload) {
|
||||
failedResponse.error = errorPayload;
|
||||
}
|
||||
|
||||
events.push(emitResponsesEvent(state, 'response.failed', {
|
||||
response: failedResponse,
|
||||
}));
|
||||
events.push('data: [DONE]\n\n');
|
||||
state.completed = true;
|
||||
return events;
|
||||
}
|
||||
|
||||
function serializeConvertedResponsesEvents(input: {
|
||||
state: ResponsesStreamState;
|
||||
streamContext: { id: string; model: string; created: number };
|
||||
@@ -930,6 +968,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
},
|
||||
modelName,
|
||||
'responses',
|
||||
requestedModel,
|
||||
);
|
||||
if (endpointCandidates.length === 0) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
@@ -1046,7 +1085,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
if (eventBlock.data === '[DONE]') {
|
||||
if (passthroughResponsesStream) {
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
} else {
|
||||
} else if (!responsesState.completed) {
|
||||
writeLines(completeResponsesStream(responsesState, streamContext, parsedUsage));
|
||||
}
|
||||
continue;
|
||||
@@ -1069,6 +1108,20 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
|
||||
? parsedPayload.type
|
||||
: '';
|
||||
const isFailureEvent = (
|
||||
eventBlock.event === 'error'
|
||||
|| eventBlock.event === 'response.failed'
|
||||
|| payloadType === 'error'
|
||||
|| payloadType === 'response.failed'
|
||||
);
|
||||
if (isFailureEvent) {
|
||||
writeLines(failResponsesStream(responsesState, streamContext, parsedUsage, parsedPayload));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName);
|
||||
writeLines(serializeConvertedResponsesEvents({
|
||||
@@ -1107,7 +1160,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
if (!passthroughResponsesStream) {
|
||||
if (!passthroughResponsesStream && !responsesState.completed) {
|
||||
writeLines(completeResponsesStream(responsesState, streamContext, parsedUsage));
|
||||
}
|
||||
reply.raw.end();
|
||||
@@ -1252,5 +1305,7 @@ function logProxy(
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
}).run();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('[proxy/responses] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown) => null);
|
||||
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => null);
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
|
||||
@@ -60,6 +60,41 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
||||
expect(responsesOrder).toEqual(['responses', 'chat', 'messages']);
|
||||
});
|
||||
|
||||
it('prioritizes messages-first for claude-family models on openai downstream', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'claude-opus-4-6',
|
||||
supportedEndpointTypes: ['anthropic', 'openai'],
|
||||
},
|
||||
],
|
||||
groupRatio: {},
|
||||
});
|
||||
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'new-api' },
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
'openai',
|
||||
);
|
||||
|
||||
expect(order).toEqual(['messages', 'chat', 'responses']);
|
||||
|
||||
const aliasedOrder = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'new-api' },
|
||||
},
|
||||
'upstream-gpt',
|
||||
'openai',
|
||||
'claude-haiku-4-5-20251001',
|
||||
);
|
||||
|
||||
expect(aliasedOrder).toEqual(['messages', 'chat', 'responses']);
|
||||
});
|
||||
|
||||
it('keeps explicit platform priority rules', async () => {
|
||||
const openaiOrder = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
|
||||
@@ -30,6 +30,12 @@ function normalizePlatformName(platform: unknown): string {
|
||||
return asTrimmedString(platform).toLowerCase();
|
||||
}
|
||||
|
||||
function isClaudeFamilyModel(modelName: string): boolean {
|
||||
const normalized = asTrimmedString(modelName).toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude');
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
@@ -679,7 +685,11 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatform?: string): UpstreamEndpoint[] {
|
||||
function preferredEndpointOrder(
|
||||
downstreamFormat: EndpointPreference,
|
||||
sitePlatform?: string,
|
||||
preferMessagesForClaudeModel = false,
|
||||
): UpstreamEndpoint[] {
|
||||
const platform = normalizePlatformName(sitePlatform);
|
||||
|
||||
if (platform === 'gemini') {
|
||||
@@ -707,6 +717,11 @@ function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatfo
|
||||
return ['messages', 'chat', 'responses'];
|
||||
}
|
||||
|
||||
if (downstreamFormat === 'openai' && preferMessagesForClaudeModel) {
|
||||
// Claude-family models are most stable with native Messages semantics.
|
||||
return ['messages', 'chat', 'responses'];
|
||||
}
|
||||
|
||||
return ['chat', 'messages', 'responses'];
|
||||
}
|
||||
|
||||
@@ -714,8 +729,13 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
context: ChannelContext,
|
||||
modelName: string,
|
||||
downstreamFormat: EndpointPreference,
|
||||
requestedModelHint?: string,
|
||||
): Promise<UpstreamEndpoint[]> {
|
||||
const sitePlatform = normalizePlatformName(context.site.platform);
|
||||
const preferMessagesForClaudeModel = (
|
||||
isClaudeFamilyModel(modelName)
|
||||
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
|
||||
);
|
||||
if (sitePlatform === 'anyrouter') {
|
||||
// anyrouter deployments are effectively anthropic-protocol first.
|
||||
return downstreamFormat === 'responses'
|
||||
@@ -723,7 +743,17 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
: ['messages', 'chat'];
|
||||
}
|
||||
|
||||
const preferred = preferredEndpointOrder(downstreamFormat, context.site.platform);
|
||||
const preferred = preferredEndpointOrder(
|
||||
downstreamFormat,
|
||||
context.site.platform,
|
||||
preferMessagesForClaudeModel,
|
||||
);
|
||||
const forceMessagesFirstForClaudeModel = (
|
||||
downstreamFormat === 'openai'
|
||||
&& preferMessagesForClaudeModel
|
||||
&& sitePlatform !== 'openai'
|
||||
&& sitePlatform !== 'gemini'
|
||||
);
|
||||
|
||||
try {
|
||||
const catalog = await fetchModelPricingCatalog({
|
||||
@@ -752,6 +782,25 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
if (!matched) return preferred;
|
||||
|
||||
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
||||
const normalizedSupportedRaw = supportedRaw
|
||||
.map((item) => asTrimmedString(item).toLowerCase())
|
||||
.filter((item) => item.length > 0);
|
||||
const hasConcreteEndpointHint = normalizedSupportedRaw.some((raw) => (
|
||||
raw.includes('/v1/messages')
|
||||
|| raw.includes('/v1/chat/completions')
|
||||
|| raw.includes('/v1/responses')
|
||||
|| raw === 'messages'
|
||||
|| raw === 'chat'
|
||||
|| raw === 'chat_completions'
|
||||
|| raw === 'completions'
|
||||
|| raw === 'responses'
|
||||
));
|
||||
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
|
||||
// Generic labels like openai/anthropic are too coarse for Claude models;
|
||||
// keep messages-first order in this case.
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const supported = new Set<UpstreamEndpoint>();
|
||||
for (const endpoint of supportedRaw) {
|
||||
const normalizedList = normalizeEndpointTypes(endpoint);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user