1
0
forked from boos/BoosAPI

feat: 更新文档链接,增强客户端接入指南,优化代理日志记录和错误处理

This commit is contained in:
ciat-777
2026-03-02 23:43:29 +08:00
parent fed7ea7023
commit e2fce12e20
12 changed files with 246 additions and 405 deletions
+54 -15
View File
@@ -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' }],
},
+4 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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 {
+6 -4
View File
@@ -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);
}
}
+6 -4
View File
@@ -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);
}
}
+6 -4
View File
@@ -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);
}
}
+59 -4
View File
@@ -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(
{
+51 -2
View File
@@ -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
View File
@@ -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>