[codex] improve proxy stability, routing, and site subscription visibility (#194)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy
This commit is contained in:
@@ -2,7 +2,7 @@ services:
|
|||||||
metapi:
|
metapi:
|
||||||
image: 1467078763/metapi:latest
|
image: 1467078763/metapi:latest
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "127.0.0.1:4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
|||||||
import {
|
import {
|
||||||
buildClaudeCountTokensUpstreamRequest,
|
buildClaudeCountTokensUpstreamRequest,
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
@@ -155,6 +157,15 @@ export async function handleChatSurfaceRequest(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
const endpointRuntimeContext = {
|
||||||
|
siteId: selected.site.id,
|
||||||
|
modelName,
|
||||||
|
downstreamFormat,
|
||||||
|
requestedModelHint: requestedModel,
|
||||||
|
requestCapabilities: {
|
||||||
|
hasNonImageFileInput,
|
||||||
|
},
|
||||||
|
};
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
buildOauthProviderHeaders({
|
buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||||
@@ -257,6 +268,20 @@ export async function handleChatSurfaceRequest(
|
|||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
tryRecover,
|
tryRecover,
|
||||||
|
onAttemptFailure: (ctx) => {
|
||||||
|
recordUpstreamEndpointFailure({
|
||||||
|
...endpointRuntimeContext,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
status: ctx.response.status,
|
||||||
|
errorText: ctx.rawErrText,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onAttemptSuccess: (ctx) => {
|
||||||
|
recordUpstreamEndpointSuccess({
|
||||||
|
...endpointRuntimeContext,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
});
|
||||||
|
},
|
||||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
logProxy(
|
||||||
@@ -284,7 +309,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
const status = endpointResult.status || 502;
|
const status = endpointResult.status || 502;
|
||||||
const errText = endpointResult.errText || 'unknown error';
|
const errText = endpointResult.errText || 'unknown error';
|
||||||
const rawErrText = endpointResult.rawErrText || errText;
|
const rawErrText = endpointResult.rawErrText || errText;
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status,
|
||||||
|
errorText: rawErrText,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -387,7 +416,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
);
|
);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, modelName);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -423,7 +452,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -461,7 +494,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
startSseResponse();
|
startSseResponse();
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -511,7 +548,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -564,7 +605,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
@@ -615,7 +656,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -676,7 +721,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
@@ -699,7 +744,10 @@ export async function handleChatSurfaceRequest(
|
|||||||
|
|
||||||
return reply.send(downstreamResponse);
|
return reply.send(downstreamResponse);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
errorText: err?.message,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -901,7 +949,11 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: upstream.status,
|
||||||
|
errorText: typeof payload === 'string' ? payload : text,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -927,7 +979,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
return reply.code(upstream.status).type(contentType).send(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
|
||||||
recordDownstreamCostUsage(request, 0);
|
recordDownstreamCostUsage(request, 0);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
@@ -949,7 +1001,10 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
);
|
);
|
||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
return reply.code(upstream.status).type(contentType).send(payload);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
errorText: error?.message,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/pro
|
|||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
@@ -296,7 +298,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
lastStatus = upstream.status;
|
lastStatus = upstream.status;
|
||||||
lastText = text;
|
lastText = text;
|
||||||
lastContentType = upstream.headers.get('content-type') || 'application/json';
|
lastContentType = upstream.headers.get('content-type') || 'application/json';
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
status: upstream.status,
|
||||||
|
errorText: text,
|
||||||
|
});
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -311,7 +316,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
errorText: error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||||
|
});
|
||||||
lastStatus = 502;
|
lastStatus = 502;
|
||||||
lastContentType = 'application/json';
|
lastContentType = 'application/json';
|
||||||
lastText = JSON.stringify({
|
lastText = JSON.stringify({
|
||||||
@@ -410,7 +417,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
type: 'server_error',
|
type: 'server_error',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
status: 500,
|
||||||
|
errorText: 'Gemini CLI OAuth project is missing',
|
||||||
|
});
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -494,7 +504,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
lastStatus = upstream.status;
|
lastStatus = upstream.status;
|
||||||
lastContentType = contentType;
|
lastContentType = contentType;
|
||||||
lastText = await upstream.text();
|
lastText = await upstream.text();
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
status: upstream.status,
|
||||||
|
errorText: lastText,
|
||||||
|
});
|
||||||
await logProxy(
|
await logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -681,6 +694,12 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
'openai',
|
'openai',
|
||||||
requestedModel,
|
requestedModel,
|
||||||
);
|
);
|
||||||
|
const endpointRuntimeContext = {
|
||||||
|
siteId: selected.site.id,
|
||||||
|
modelName: actualModel,
|
||||||
|
downstreamFormat: 'openai' as const,
|
||||||
|
requestedModelHint: requestedModel,
|
||||||
|
};
|
||||||
const buildEndpointRequest = (
|
const buildEndpointRequest = (
|
||||||
endpoint: 'chat' | 'messages' | 'responses',
|
endpoint: 'chat' | 'messages' | 'responses',
|
||||||
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
|
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
|
||||||
@@ -742,6 +761,20 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
tryRecover: endpointStrategy.tryRecover,
|
tryRecover: endpointStrategy.tryRecover,
|
||||||
|
onAttemptFailure: (ctx) => {
|
||||||
|
recordUpstreamEndpointFailure({
|
||||||
|
...endpointRuntimeContext,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
status: ctx.response.status,
|
||||||
|
errorText: ctx.rawErrText,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onAttemptSuccess: (ctx) => {
|
||||||
|
recordUpstreamEndpointSuccess({
|
||||||
|
...endpointRuntimeContext,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
});
|
||||||
|
},
|
||||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
});
|
});
|
||||||
if (!endpointResult.ok) {
|
if (!endpointResult.ok) {
|
||||||
@@ -753,7 +786,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
type: 'upstream_error',
|
type: 'upstream_error',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
status: endpointResult.status,
|
||||||
|
errorText: endpointResult.rawErrText || endpointResult.errText,
|
||||||
|
});
|
||||||
await logProxy(
|
await logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -821,7 +857,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
type: 'upstream_error',
|
type: 'upstream_error',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
errorText: error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||||
|
});
|
||||||
await logProxy(
|
await logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../se
|
|||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
||||||
@@ -263,6 +265,16 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
if (endpointCandidates.length === 0) {
|
if (endpointCandidates.length === 0) {
|
||||||
endpointCandidates.push('responses', 'chat', 'messages');
|
endpointCandidates.push('responses', 'chat', 'messages');
|
||||||
}
|
}
|
||||||
|
const endpointRuntimeContext = {
|
||||||
|
siteId: selected.site.id,
|
||||||
|
modelName,
|
||||||
|
downstreamFormat: 'responses' as const,
|
||||||
|
requestedModelHint: requestedModel,
|
||||||
|
requestCapabilities: {
|
||||||
|
hasNonImageFileInput,
|
||||||
|
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||||
|
},
|
||||||
|
};
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
buildOauthProviderHeaders({
|
buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||||
@@ -357,6 +369,20 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
tryRecover,
|
tryRecover,
|
||||||
|
onAttemptFailure: (ctx) => {
|
||||||
|
recordUpstreamEndpointFailure({
|
||||||
|
...endpointRuntimeContext,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
status: ctx.response.status,
|
||||||
|
errorText: ctx.rawErrText,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onAttemptSuccess: (ctx) => {
|
||||||
|
recordUpstreamEndpointSuccess({
|
||||||
|
...endpointRuntimeContext,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
});
|
||||||
|
},
|
||||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
logProxy(
|
||||||
@@ -384,7 +410,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const status = endpointResult.status || 502;
|
const status = endpointResult.status || 502;
|
||||||
const errText = endpointResult.errText || 'unknown error';
|
const errText = endpointResult.errText || 'unknown error';
|
||||||
const rawErrText = endpointResult.rawErrText || errText;
|
const rawErrText = endpointResult.rawErrText || errText;
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status,
|
||||||
|
errorText: rawErrText,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -476,7 +506,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||||
@@ -539,7 +569,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
);
|
);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, modelName);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -578,7 +608,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -613,7 +647,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
startSseResponse();
|
startSseResponse();
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -669,7 +707,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -733,7 +775,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -797,7 +843,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||||
@@ -808,7 +854,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
);
|
);
|
||||||
return reply.send(downstreamData);
|
return reply.send(downstreamData);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
errorText: err?.message,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
buildStoredSub2ApiSubscriptionSummary,
|
||||||
|
mergeAccountExtraConfig,
|
||||||
|
} from '../../services/accountExtraConfig.js';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('sites route subscription summary aggregation', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./sites.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.sitesRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(schema.accounts).run();
|
||||||
|
await db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates stored sub2api subscription summaries into /api/sites', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'sub2api-site',
|
||||||
|
url: 'https://sub2api.example.com',
|
||||||
|
platform: 'sub2api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.accounts).values([
|
||||||
|
{
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'user-a',
|
||||||
|
accessToken: 'token-a',
|
||||||
|
balance: 10,
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: mergeAccountExtraConfig(null, {
|
||||||
|
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 3,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
groupName: 'Pro',
|
||||||
|
expiresAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 3,
|
||||||
|
monthlyLimitUsd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, 1760000000000),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'user-b',
|
||||||
|
accessToken: 'token-b',
|
||||||
|
balance: 5,
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: mergeAccountExtraConfig(null, {
|
||||||
|
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 2,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
groupName: 'Lite',
|
||||||
|
expiresAt: '2026-03-25T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 2,
|
||||||
|
monthlyLimitUsd: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, 1760000002000),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sites',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as Array<{
|
||||||
|
totalBalance: number;
|
||||||
|
subscriptionSummary: {
|
||||||
|
activeCount: number;
|
||||||
|
totalUsedUsd: number;
|
||||||
|
totalMonthlyLimitUsd: number | null;
|
||||||
|
totalRemainingUsd: number | null;
|
||||||
|
nextExpiresAt: string | null;
|
||||||
|
planNames: string[];
|
||||||
|
updatedAt: number | null;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0]?.totalBalance).toBe(15);
|
||||||
|
expect(body[0]?.subscriptionSummary).toEqual({
|
||||||
|
activeCount: 2,
|
||||||
|
totalUsedUsd: 5,
|
||||||
|
totalMonthlyLimitUsd: 30,
|
||||||
|
totalRemainingUsd: 25,
|
||||||
|
nextExpiresAt: '2026-03-25T00:00:00.000Z',
|
||||||
|
planNames: ['Pro', 'Lite'],
|
||||||
|
updatedAt: 1760000002000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
import { detectSite } from '../../services/siteDetector.js';
|
||||||
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||||
|
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -75,6 +76,65 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
|||||||
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SiteSubscriptionAggregate = {
|
||||||
|
activeCount: number;
|
||||||
|
totalUsedUsd: number;
|
||||||
|
totalMonthlyLimitUsd: number | null;
|
||||||
|
totalRemainingUsd: number | null;
|
||||||
|
nextExpiresAt: string | null;
|
||||||
|
planNames: string[];
|
||||||
|
updatedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function roundMetric(value: number): number {
|
||||||
|
return Math.round(value * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null {
|
||||||
|
if (!current) return next || null;
|
||||||
|
if (!next) return current;
|
||||||
|
const currentMs = Date.parse(current);
|
||||||
|
const nextMs = Date.parse(next);
|
||||||
|
if (!Number.isFinite(currentMs)) return next;
|
||||||
|
if (!Number.isFinite(nextMs)) return current;
|
||||||
|
return nextMs < currentMs ? next : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateSiteSubscription(
|
||||||
|
current: SiteSubscriptionAggregate | undefined,
|
||||||
|
extraConfig?: string | null,
|
||||||
|
): SiteSubscriptionAggregate | undefined {
|
||||||
|
const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig);
|
||||||
|
if (!stored) return current;
|
||||||
|
|
||||||
|
const planNames = new Set(current?.planNames || []);
|
||||||
|
let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null;
|
||||||
|
let nextExpiresAt = current?.nextExpiresAt ?? null;
|
||||||
|
|
||||||
|
for (const item of stored.subscriptions) {
|
||||||
|
if (item.groupName) planNames.add(item.groupName);
|
||||||
|
if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) {
|
||||||
|
totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd);
|
||||||
|
}
|
||||||
|
nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd);
|
||||||
|
const totalRemainingUsd = totalMonthlyLimitUsd == null
|
||||||
|
? null
|
||||||
|
: roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd));
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount: (current?.activeCount || 0) + stored.activeCount,
|
||||||
|
totalUsedUsd,
|
||||||
|
totalMonthlyLimitUsd,
|
||||||
|
totalRemainingUsd,
|
||||||
|
nextExpiresAt,
|
||||||
|
planNames: Array.from(planNames),
|
||||||
|
updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function sitesRoutes(app: FastifyInstance) {
|
export async function sitesRoutes(app: FastifyInstance) {
|
||||||
function invalidateSiteCaches() {
|
function invalidateSiteCaches() {
|
||||||
invalidateSiteProxyCache();
|
invalidateSiteProxyCache();
|
||||||
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
// List all sites
|
// List all sites
|
||||||
app.get('/api/sites', async () => {
|
app.get('/api/sites', async () => {
|
||||||
const siteRows = await db.select().from(schema.sites).all();
|
const siteRows = await db.select().from(schema.sites).all();
|
||||||
const accountBalanceRows = await db.select({
|
const accountRows = await db.select({
|
||||||
siteId: schema.accounts.siteId,
|
siteId: schema.accounts.siteId,
|
||||||
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
|
balance: schema.accounts.balance,
|
||||||
}).from(schema.accounts)
|
extraConfig: schema.accounts.extraConfig,
|
||||||
.groupBy(schema.accounts.siteId)
|
}).from(schema.accounts).all();
|
||||||
.all();
|
|
||||||
|
|
||||||
const totalBalanceBySiteId: Record<number, number> = {};
|
const totalBalanceBySiteId: Record<number, number> = {};
|
||||||
for (const row of accountBalanceRows) {
|
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
|
||||||
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
|
for (const row of accountRows) {
|
||||||
|
totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0));
|
||||||
|
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
return siteRows.map((site) => ({
|
return siteRows.map((site) => ({
|
||||||
...site,
|
...site,
|
||||||
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
||||||
|
subscriptionSummary: subscriptionBySiteId[site.id] || null,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
@@ -102,6 +103,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
fetchModelPricingCatalogMock.mockReset();
|
fetchModelPricingCatalogMock.mockReset();
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
|
resetUpstreamEndpointRuntimeState();
|
||||||
|
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
@@ -113,6 +115,17 @@ describe('chat proxy stream behavior', () => {
|
|||||||
});
|
});
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
||||||
|
(config as any).codexHeaderDefaults = {
|
||||||
|
userAgent: '',
|
||||||
|
betaFeatures: '',
|
||||||
|
};
|
||||||
|
(config as any).payloadRules = {
|
||||||
|
default: [],
|
||||||
|
defaultRaw: [],
|
||||||
|
override: [],
|
||||||
|
overrideRaw: [],
|
||||||
|
filter: [],
|
||||||
|
};
|
||||||
config.proxyEmptyContentFailEnabled = false;
|
config.proxyEmptyContentFailEnabled = false;
|
||||||
config.proxyErrorKeywords = [];
|
config.proxyErrorKeywords = [];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,8 +79,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const errText = await upstream.text().catch(() => 'unknown error');
|
const errText = await upstream.text().catch(() => 'unknown error');
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
status: upstream.status,
|
||||||
|
errorText: errText,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
upstream.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
errText,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
|
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
@@ -178,11 +198,24 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
|
selected,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
requestedModel,
|
||||||
|
'success',
|
||||||
|
200,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
resolvedUsage.promptTokens,
|
||||||
|
resolvedUsage.completionTokens,
|
||||||
|
resolvedUsage.totalTokens,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -199,7 +232,11 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
const errText = failure.reason;
|
const errText = failure.reason;
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: errText,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -256,16 +293,49 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
|
selected,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
requestedModel,
|
||||||
|
'success',
|
||||||
|
200,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
resolvedUsage.promptTokens,
|
||||||
|
resolvedUsage.completionTokens,
|
||||||
|
resolvedUsage.totalTokens,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
);
|
);
|
||||||
return reply.send(data);
|
return reply.send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
status: 0,
|
||||||
|
errorText: err.message,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
Date.now() - startTime,
|
||||||
|
err.message,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -75,8 +75,28 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
status: upstream.status,
|
||||||
|
errorText: text,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
upstream.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
text,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
|
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
@@ -126,7 +146,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
|
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
|
||||||
@@ -134,8 +154,28 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
status: 0,
|
||||||
|
errorText: err.message,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
Date.now() - startTime,
|
||||||
|
err.message,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -155,6 +155,40 @@ describe('executeEndpointFlow', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits attempt callbacks for failed and successful endpoint probes', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
|
error: { message: 'unsupported endpoint', type: 'invalid_request_error' },
|
||||||
|
}), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})))
|
||||||
|
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const onAttemptFailure = vi.fn();
|
||||||
|
const onAttemptSuccess = vi.fn();
|
||||||
|
|
||||||
|
const result = await executeEndpointFlow({
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
endpointCandidates: ['responses', 'chat'],
|
||||||
|
buildRequest: (endpoint) => endpoint === 'responses'
|
||||||
|
? requestFor('/v1/responses')
|
||||||
|
: { ...requestFor('/v1/chat/completions'), endpoint },
|
||||||
|
shouldDowngrade: () => true,
|
||||||
|
onAttemptFailure,
|
||||||
|
onAttemptSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(onAttemptFailure).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAttemptFailure.mock.calls[0]?.[0]?.request?.path).toBe('/v1/responses');
|
||||||
|
expect(onAttemptSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts recovered response from tryRecover hook', async () => {
|
it('accepts recovered response from tryRecover hook', async () => {
|
||||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export type EndpointAttemptContext = {
|
|||||||
rawErrText: string;
|
rawErrText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EndpointAttemptSuccessContext = {
|
||||||
|
endpointIndex: number;
|
||||||
|
endpointCount: number;
|
||||||
|
request: BuiltEndpointRequest;
|
||||||
|
targetUrl: string;
|
||||||
|
response: Awaited<ReturnType<typeof fetch>>;
|
||||||
|
};
|
||||||
|
|
||||||
export type EndpointRecoverResult = {
|
export type EndpointRecoverResult = {
|
||||||
upstream: Awaited<ReturnType<typeof fetch>>;
|
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||||
upstreamPath: string;
|
upstreamPath: string;
|
||||||
@@ -61,6 +69,8 @@ type ExecuteEndpointFlowInput = {
|
|||||||
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
||||||
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
||||||
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||||
|
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||||
|
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildAbsoluteUrl(base: string, path: string): string {
|
function buildAbsoluteUrl(base: string, path: string): string {
|
||||||
@@ -100,6 +110,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
await input.onAttemptSuccess?.({
|
||||||
|
endpointIndex,
|
||||||
|
endpointCount,
|
||||||
|
request,
|
||||||
|
targetUrl,
|
||||||
|
response,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
upstream: response,
|
upstream: response,
|
||||||
@@ -120,6 +137,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
if (input.tryRecover) {
|
if (input.tryRecover) {
|
||||||
const recovered = await input.tryRecover(baseContext);
|
const recovered = await input.tryRecover(baseContext);
|
||||||
if (recovered?.upstream?.ok) {
|
if (recovered?.upstream?.ok) {
|
||||||
|
await input.onAttemptSuccess?.({
|
||||||
|
endpointIndex,
|
||||||
|
endpointCount,
|
||||||
|
request: baseContext.request,
|
||||||
|
targetUrl: baseContext.targetUrl,
|
||||||
|
response: recovered.upstream,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
upstream: recovered.upstream,
|
upstream: recovered.upstream,
|
||||||
@@ -135,6 +159,10 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
baseContext.request.path,
|
baseContext.request.path,
|
||||||
summarizeUpstreamError(response.status, rawErrText),
|
summarizeUpstreamError(response.status, rawErrText),
|
||||||
);
|
);
|
||||||
|
await input.onAttemptFailure?.({
|
||||||
|
...baseContext,
|
||||||
|
errText,
|
||||||
|
});
|
||||||
|
|
||||||
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
||||||
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const fetchModelPricingCatalogMock = vi.fn();
|
const fetchModelPricingCatalogMock = vi.fn();
|
||||||
@@ -188,6 +189,7 @@ describe('gemini native proxy routes', () => {
|
|||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
recordSuccessMock.mockResolvedValue(undefined);
|
recordSuccessMock.mockResolvedValue(undefined);
|
||||||
recordFailureMock.mockResolvedValue(undefined);
|
recordFailureMock.mockResolvedValue(undefined);
|
||||||
|
resetUpstreamEndpointRuntimeState();
|
||||||
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
|
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
|
||||||
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
|
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
@@ -261,7 +263,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
|
||||||
|
status: 500,
|
||||||
|
errorText: JSON.stringify({ error: { message: 'first channel failed' } }),
|
||||||
|
}));
|
||||||
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
expect(firstUrl).toContain('key=gemini-key');
|
expect(firstUrl).toContain('key=gemini-key');
|
||||||
@@ -1417,7 +1422,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
|
||||||
|
status: 400,
|
||||||
|
errorText: JSON.stringify({ error: { message: 'bad request on first channel' } }),
|
||||||
|
}));
|
||||||
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
expect(firstUrl).toContain('key=gemini-key');
|
expect(firstUrl).toContain('key=gemini-key');
|
||||||
@@ -1467,7 +1475,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
|
||||||
|
status: 403,
|
||||||
|
errorText: JSON.stringify({ error: { message: 'forbidden on first channel' } }),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the next channel when first Gemini channel returns 500 before any bytes are written', async () => {
|
it('falls back to the next channel when first Gemini channel returns 500 before any bytes are written', async () => {
|
||||||
@@ -1510,7 +1521,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
|
||||||
|
status: 500,
|
||||||
|
errorText: 'upstream crash',
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the next channel when first Gemini channel throws before any bytes are written', async () => {
|
it('falls back to the next channel when first Gemini channel throws before any bytes are written', async () => {
|
||||||
@@ -1550,7 +1564,9 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
|
||||||
|
errorText: 'socket hang up',
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the next channel for SSE requests before any bytes are written', async () => {
|
it('falls back to the next channel for SSE requests before any bytes are written', async () => {
|
||||||
@@ -1596,7 +1612,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(recordFailureMock).toHaveBeenCalledWith(11);
|
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
|
||||||
|
status: 500,
|
||||||
|
errorText: JSON.stringify({ error: { message: 'upstream unavailable' } }),
|
||||||
|
}));
|
||||||
expect(response.body).toContain('hello from fallback sse');
|
expect(response.body).toContain('hello from fallback sse');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
status: upstream.status,
|
||||||
|
errorText: text,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
upstream.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
text,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
downstreamPath,
|
||||||
|
clientContext,
|
||||||
|
);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -107,13 +123,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
status: 0,
|
||||||
|
errorText: err.message,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
Date.now() - startTime,
|
||||||
|
err.message,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
downstreamPath,
|
||||||
|
clientContext,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -200,8 +232,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
const upstream = await fetch(targetUrl, requestInit);
|
const upstream = await fetch(targetUrl, requestInit);
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
status: upstream.status,
|
||||||
|
errorText: text,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
upstream.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
text,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
downstreamPath,
|
||||||
|
clientContext,
|
||||||
|
);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -233,13 +281,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
status: 0,
|
||||||
|
errorText: err.message,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
Date.now() - startTime,
|
||||||
|
err.message,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
0,
|
||||||
|
downstreamPath,
|
||||||
|
clientContext,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -105,8 +105,23 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
status: upstream.status,
|
||||||
|
errorText: text,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
upstream.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
text,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -130,13 +145,28 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
try { data = JSON.parse(text); } catch { data = { data: [] }; }
|
try { data = JSON.parse(text); } catch { data = { data: [] }; }
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
tokenRouter.recordSuccess(selected.channel.id, latency, 0, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, 0);
|
recordDownstreamCostUsage(request, 0);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
status: 0,
|
||||||
|
errorText: error?.message || 'network error',
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
Date.now() - startTime,
|
||||||
|
error?.message || 'network error',
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
isUnsupportedMediaTypeError,
|
isUnsupportedMediaTypeError,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
|
resetUpstreamEndpointRuntimeState,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchModelPricingCatalogMock.mockReset();
|
fetchModelPricingCatalogMock.mockReset();
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
||||||
|
resetUpstreamEndpointRuntimeState();
|
||||||
(config as any).codexHeaderDefaults = {
|
(config as any).codexHeaderDefaults = {
|
||||||
userAgent: '',
|
userAgent: '',
|
||||||
betaFeatures: '',
|
betaFeatures: '',
|
||||||
@@ -192,6 +196,48 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('remembers the last successful endpoint per site capability profile', async () => {
|
||||||
|
recordUpstreamEndpointSuccess({
|
||||||
|
siteId: baseContext.site.id,
|
||||||
|
endpoint: 'responses',
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
modelName: 'gpt-5.3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'new-api' },
|
||||||
|
},
|
||||||
|
'gpt-5.3',
|
||||||
|
'openai',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(order).toEqual(['responses', 'chat', 'messages']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('learns a better endpoint from explicit upstream protocol errors', async () => {
|
||||||
|
recordUpstreamEndpointFailure({
|
||||||
|
siteId: baseContext.site.id,
|
||||||
|
endpoint: 'chat',
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
modelName: 'gpt-5.3',
|
||||||
|
status: 400,
|
||||||
|
errorText: 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'new-api' },
|
||||||
|
},
|
||||||
|
'gpt-5.3',
|
||||||
|
'openai',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(order).toEqual(['responses', 'messages']);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
|
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildGeminiGenerateContentRequestFromOpenAi,
|
buildGeminiGenerateContentRequestFromOpenAi,
|
||||||
} from './geminiCliCompat.js';
|
} from './geminiCliCompat.js';
|
||||||
export {
|
import {
|
||||||
buildMinimalJsonHeadersForCompatibility,
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
isEndpointDispatchDeniedError,
|
isEndpointDispatchDeniedError,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
@@ -27,10 +27,30 @@ export {
|
|||||||
promoteResponsesCandidateAfterLegacyChatError,
|
promoteResponsesCandidateAfterLegacyChatError,
|
||||||
shouldPreferResponsesAfterLegacyChatError,
|
shouldPreferResponsesAfterLegacyChatError,
|
||||||
} from '../../transformers/shared/endpointCompatibility.js';
|
} from '../../transformers/shared/endpointCompatibility.js';
|
||||||
|
export {
|
||||||
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
|
isEndpointDispatchDeniedError,
|
||||||
|
isEndpointDowngradeError,
|
||||||
|
isUnsupportedMediaTypeError,
|
||||||
|
promoteResponsesCandidateAfterLegacyChatError,
|
||||||
|
shouldPreferResponsesAfterLegacyChatError,
|
||||||
|
};
|
||||||
|
|
||||||
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
||||||
export type EndpointPreference = DownstreamFormat | 'responses';
|
export type EndpointPreference = DownstreamFormat | 'responses';
|
||||||
|
|
||||||
|
type EndpointCapabilityProfile = {
|
||||||
|
preferMessagesForClaudeModel: boolean;
|
||||||
|
hasNonImageFileInput: boolean;
|
||||||
|
wantsNativeResponsesReasoning: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EndpointRuntimeState = {
|
||||||
|
preferredEndpoint: UpstreamEndpoint | null;
|
||||||
|
preferredUpdatedAtMs: number;
|
||||||
|
blockedUntilMsByEndpoint: Partial<Record<UpstreamEndpoint, number>>;
|
||||||
|
};
|
||||||
|
|
||||||
type ChannelContext = {
|
type ChannelContext = {
|
||||||
site: {
|
site: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,6 +65,10 @@ type ChannelContext = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ENDPOINT_RUNTIME_PREFERRED_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const ENDPOINT_RUNTIME_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const endpointRuntimeStates = new Map<string, EndpointRuntimeState>();
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
@@ -577,6 +601,197 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
|||||||
return Array.from(normalized);
|
return Array.from(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEndpointCapabilityProfile(input?: {
|
||||||
|
modelName?: string;
|
||||||
|
requestedModelHint?: string;
|
||||||
|
requestCapabilities?: {
|
||||||
|
hasNonImageFileInput?: boolean;
|
||||||
|
conversationFileSummary?: ConversationFileInputSummary;
|
||||||
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
|
};
|
||||||
|
}): EndpointCapabilityProfile {
|
||||||
|
return {
|
||||||
|
preferMessagesForClaudeModel: (
|
||||||
|
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|
||||||
|
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
|
||||||
|
),
|
||||||
|
hasNonImageFileInput: (
|
||||||
|
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|
||||||
|
|| input?.requestCapabilities?.hasNonImageFileInput === true
|
||||||
|
),
|
||||||
|
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEndpointRuntimeStateKey(input: {
|
||||||
|
siteId: number;
|
||||||
|
downstreamFormat: EndpointPreference;
|
||||||
|
capabilityProfile: EndpointCapabilityProfile;
|
||||||
|
}): string {
|
||||||
|
const capabilityProfile = input.capabilityProfile;
|
||||||
|
return [
|
||||||
|
String(input.siteId),
|
||||||
|
input.downstreamFormat,
|
||||||
|
capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic',
|
||||||
|
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
|
||||||
|
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateEndpointRuntimeState(key: string, nowMs = Date.now()): EndpointRuntimeState {
|
||||||
|
const existing = endpointRuntimeStates.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const initial: EndpointRuntimeState = {
|
||||||
|
preferredEndpoint: null,
|
||||||
|
preferredUpdatedAtMs: nowMs,
|
||||||
|
blockedUntilMsByEndpoint: {},
|
||||||
|
};
|
||||||
|
endpointRuntimeStates.set(key, initial);
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeDeleteEndpointRuntimeState(key: string, nowMs = Date.now()): void {
|
||||||
|
const state = endpointRuntimeStates.get(key);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const hasActiveBlock = Object.values(state.blockedUntilMsByEndpoint).some((untilMs) => (
|
||||||
|
typeof untilMs === 'number' && untilMs > nowMs
|
||||||
|
));
|
||||||
|
const preferredFresh = (
|
||||||
|
!!state.preferredEndpoint
|
||||||
|
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
|
||||||
|
);
|
||||||
|
if (!hasActiveBlock && !preferredFresh) {
|
||||||
|
endpointRuntimeStates.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEndpointRuntimePreference(
|
||||||
|
candidates: UpstreamEndpoint[],
|
||||||
|
key: string,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
): UpstreamEndpoint[] {
|
||||||
|
const state = endpointRuntimeStates.get(key);
|
||||||
|
if (!state || candidates.length <= 1) return candidates;
|
||||||
|
|
||||||
|
const blocked = new Set<UpstreamEndpoint>();
|
||||||
|
for (const endpoint of candidates) {
|
||||||
|
const untilMs = state.blockedUntilMsByEndpoint[endpoint];
|
||||||
|
if (typeof untilMs === 'number' && untilMs > nowMs) {
|
||||||
|
blocked.add(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = candidates.filter((endpoint) => !blocked.has(endpoint));
|
||||||
|
if (next.length === 0) {
|
||||||
|
next = [...candidates];
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredFresh = (
|
||||||
|
!!state.preferredEndpoint
|
||||||
|
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
|
||||||
|
);
|
||||||
|
if (preferredFresh && state.preferredEndpoint && next.includes(state.preferredEndpoint)) {
|
||||||
|
next = [
|
||||||
|
state.preferredEndpoint,
|
||||||
|
...next.filter((endpoint) => endpoint !== state.preferredEndpoint),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeDeleteEndpointRuntimeState(key, nowMs);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEndpoint | null {
|
||||||
|
const text = (errorText || '').toLowerCase();
|
||||||
|
if (!text) return null;
|
||||||
|
if (text.includes('/v1/responses')) return 'responses';
|
||||||
|
if (text.includes('/v1/messages')) return 'messages';
|
||||||
|
if (text.includes('/v1/chat/completions')) return 'chat';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean {
|
||||||
|
if (isEndpointDispatchDeniedError(status, errorText)) return true;
|
||||||
|
if (isEndpointDowngradeError(status, errorText)) return true;
|
||||||
|
const text = (errorText || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes('unsupported legacy protocol')
|
||||||
|
|| text.includes('please use /v1/')
|
||||||
|
|| text.includes('does not allow /v1/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetUpstreamEndpointRuntimeState(): void {
|
||||||
|
endpointRuntimeStates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordUpstreamEndpointSuccess(input: {
|
||||||
|
siteId: number;
|
||||||
|
endpoint: UpstreamEndpoint;
|
||||||
|
downstreamFormat: EndpointPreference;
|
||||||
|
modelName?: string;
|
||||||
|
requestedModelHint?: string;
|
||||||
|
requestCapabilities?: {
|
||||||
|
hasNonImageFileInput?: boolean;
|
||||||
|
conversationFileSummary?: ConversationFileInputSummary;
|
||||||
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
|
};
|
||||||
|
}): void {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const key = buildEndpointRuntimeStateKey({
|
||||||
|
siteId: input.siteId,
|
||||||
|
downstreamFormat: input.downstreamFormat,
|
||||||
|
capabilityProfile: buildEndpointCapabilityProfile({
|
||||||
|
modelName: input.modelName,
|
||||||
|
requestedModelHint: input.requestedModelHint,
|
||||||
|
requestCapabilities: input.requestCapabilities,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
||||||
|
state.preferredEndpoint = input.endpoint;
|
||||||
|
state.preferredUpdatedAtMs = nowMs;
|
||||||
|
delete state.blockedUntilMsByEndpoint[input.endpoint];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordUpstreamEndpointFailure(input: {
|
||||||
|
siteId: number;
|
||||||
|
endpoint: UpstreamEndpoint;
|
||||||
|
downstreamFormat: EndpointPreference;
|
||||||
|
status: number;
|
||||||
|
errorText?: string | null;
|
||||||
|
modelName?: string;
|
||||||
|
requestedModelHint?: string;
|
||||||
|
requestCapabilities?: {
|
||||||
|
hasNonImageFileInput?: boolean;
|
||||||
|
conversationFileSummary?: ConversationFileInputSummary;
|
||||||
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
|
};
|
||||||
|
}): void {
|
||||||
|
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const key = buildEndpointRuntimeStateKey({
|
||||||
|
siteId: input.siteId,
|
||||||
|
downstreamFormat: input.downstreamFormat,
|
||||||
|
capabilityProfile: buildEndpointCapabilityProfile({
|
||||||
|
modelName: input.modelName,
|
||||||
|
requestedModelHint: input.requestedModelHint,
|
||||||
|
requestCapabilities: input.requestCapabilities,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
||||||
|
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
|
||||||
|
|
||||||
|
const suggestedEndpoint = inferSuggestedEndpointFromError(input.errorText);
|
||||||
|
if (suggestedEndpoint && suggestedEndpoint !== input.endpoint) {
|
||||||
|
state.preferredEndpoint = suggestedEndpoint;
|
||||||
|
state.preferredUpdatedAtMs = nowMs;
|
||||||
|
delete state.blockedUntilMsByEndpoint[suggestedEndpoint];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function preferredEndpointOrder(
|
function preferredEndpointOrder(
|
||||||
downstreamFormat: EndpointPreference,
|
downstreamFormat: EndpointPreference,
|
||||||
sitePlatform?: string,
|
sitePlatform?: string,
|
||||||
@@ -651,29 +866,39 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
},
|
},
|
||||||
): Promise<UpstreamEndpoint[]> {
|
): Promise<UpstreamEndpoint[]> {
|
||||||
const sitePlatform = normalizePlatformName(context.site.platform);
|
const sitePlatform = normalizePlatformName(context.site.platform);
|
||||||
const preferMessagesForClaudeModel = (
|
const capabilityProfile = buildEndpointCapabilityProfile({
|
||||||
isClaudeFamilyModel(modelName)
|
modelName,
|
||||||
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
|
requestedModelHint,
|
||||||
|
requestCapabilities,
|
||||||
|
});
|
||||||
|
const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel;
|
||||||
|
const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput;
|
||||||
|
const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning;
|
||||||
|
const runtimeStateKey = buildEndpointRuntimeStateKey({
|
||||||
|
siteId: context.site.id,
|
||||||
|
downstreamFormat,
|
||||||
|
capabilityProfile,
|
||||||
|
});
|
||||||
|
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
|
||||||
|
applyEndpointRuntimePreference(candidates, runtimeStateKey)
|
||||||
);
|
);
|
||||||
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
|
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
hasDocument: requestCapabilities?.hasNonImageFileInput === true,
|
hasDocument: hasNonImageFileInput,
|
||||||
hasRemoteDocumentUrl: false,
|
hasRemoteDocumentUrl: false,
|
||||||
};
|
};
|
||||||
const hasNonImageFileInput = conversationFileSummary.hasDocument;
|
|
||||||
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
|
|
||||||
if (sitePlatform === 'anyrouter') {
|
if (sitePlatform === 'anyrouter') {
|
||||||
// anyrouter deployments are effectively anthropic-protocol first.
|
// anyrouter deployments are effectively anthropic-protocol first.
|
||||||
if (hasNonImageFileInput) {
|
if (hasNonImageFileInput) {
|
||||||
return downstreamFormat === 'responses'
|
return applyRuntimePreference(downstreamFormat === 'responses'
|
||||||
? ['responses', 'messages', 'chat']
|
? ['responses', 'messages', 'chat']
|
||||||
: ['messages', 'responses', 'chat'];
|
: ['messages', 'responses', 'chat']);
|
||||||
}
|
}
|
||||||
if (downstreamFormat === 'responses') {
|
if (downstreamFormat === 'responses') {
|
||||||
return ['responses', 'messages', 'chat'];
|
return applyRuntimePreference(['responses', 'messages', 'chat']);
|
||||||
}
|
}
|
||||||
return ['messages', 'chat', 'responses'];
|
return applyRuntimePreference(['messages', 'chat', 'responses']);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferred = preferredEndpointOrder(
|
const preferred = preferredEndpointOrder(
|
||||||
@@ -732,20 +957,20 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
|
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = catalog.models.find((item) =>
|
const matched = catalog.models.find((item) =>
|
||||||
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
|
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
|
||||||
);
|
);
|
||||||
if (!matched) return prioritizedPreferredEndpoints;
|
if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
|
|
||||||
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
||||||
preferMessagesForClaudeModel
|
preferMessagesForClaudeModel
|
||||||
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
|
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
|
||||||
);
|
);
|
||||||
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
||||||
@@ -765,7 +990,7 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
|
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
|
||||||
// Generic labels like openai/anthropic are too coarse for Claude models;
|
// Generic labels like openai/anthropic are too coarse for Claude models;
|
||||||
// keep messages-first order in this case.
|
// keep messages-first order in this case.
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supported = new Set<UpstreamEndpoint>();
|
const supported = new Set<UpstreamEndpoint>();
|
||||||
@@ -776,19 +1001,19 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supported.size === 0) return prioritizedPreferredEndpoints;
|
if (supported.size === 0) return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
|
|
||||||
const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint));
|
const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint));
|
||||||
if (!firstSupported) return prioritizedPreferredEndpoints;
|
if (!firstSupported) return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
|
|
||||||
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
|
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
|
||||||
// the first attempt. Keep downstream-driven fallback order unchanged.
|
// the first attempt. Keep downstream-driven fallback order unchanged.
|
||||||
return [
|
return applyRuntimePreference([
|
||||||
firstSupported,
|
firstSupported,
|
||||||
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
|
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
|
||||||
];
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,5 +1420,3 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
const upstream = await fetch(targetUrl, requestInit);
|
const upstream = await fetch(targetUrl, requestInit);
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -153,11 +153,11 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
|
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
buildStoredSub2ApiSubscriptionSummary,
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
getPlatformUserIdFromExtraConfig,
|
getPlatformUserIdFromExtraConfig,
|
||||||
getSub2ApiAuthFromExtraConfig,
|
getSub2ApiAuthFromExtraConfig,
|
||||||
|
getSub2ApiSubscriptionFromExtraConfig,
|
||||||
guessPlatformUserIdFromUsername,
|
guessPlatformUserIdFromUsername,
|
||||||
mergeAccountExtraConfig,
|
mergeAccountExtraConfig,
|
||||||
normalizeCredentialMode,
|
normalizeCredentialMode,
|
||||||
@@ -105,4 +107,37 @@ describe('accountExtraConfig', () => {
|
|||||||
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
||||||
})).toBe(true);
|
})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses stored sub2api subscription summary from extra config', () => {
|
||||||
|
const extraConfig = mergeAccountExtraConfig(null, {
|
||||||
|
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 3.5,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
groupName: 'Pro',
|
||||||
|
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 3.5,
|
||||||
|
monthlyLimitUsd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, 1760000000000),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 3.5,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
groupName: 'Pro',
|
||||||
|
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 3.5,
|
||||||
|
monthlyLimitUsd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: 1760000000000,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
|
||||||
|
|
||||||
type AutoReloginConfig = {
|
type AutoReloginConfig = {
|
||||||
username?: unknown;
|
username?: unknown;
|
||||||
passwordCipher?: unknown;
|
passwordCipher?: unknown;
|
||||||
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
|
|||||||
tokenExpiresAt?: unknown;
|
tokenExpiresAt?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Sub2ApiSubscriptionConfig = {
|
||||||
|
updatedAt?: unknown;
|
||||||
|
activeCount?: unknown;
|
||||||
|
totalUsedUsd?: unknown;
|
||||||
|
subscriptions?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
|
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
|
||||||
|
|
||||||
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
||||||
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
|
|||||||
};
|
};
|
||||||
autoRelogin?: AutoReloginConfig;
|
autoRelogin?: AutoReloginConfig;
|
||||||
sub2apiAuth?: Sub2ApiAuthConfig;
|
sub2apiAuth?: Sub2ApiAuthConfig;
|
||||||
|
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeNumber(raw: unknown): number | undefined {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
||||||
|
return Math.round(raw * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const parsed = Number(raw.trim());
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||||
|
return Math.round(parsed * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIsoDateTime(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||||
|
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const numeric = Number(trimmed);
|
||||||
|
if (Number.isFinite(numeric) && numeric > 0) {
|
||||||
|
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
|
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
|
||||||
if (typeof raw !== 'string') return undefined;
|
if (typeof raw !== 'string') return undefined;
|
||||||
const normalized = raw.trim().toLowerCase();
|
const normalized = raw.trim().toLowerCase();
|
||||||
@@ -129,6 +172,10 @@ export type ManagedSub2ApiAuth = {
|
|||||||
tokenExpiresAt?: number;
|
tokenExpiresAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
|
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
|
||||||
const parsed = parseExtraConfig(extraConfig);
|
const parsed = parseExtraConfig(extraConfig);
|
||||||
const raw = parsed.sub2apiAuth;
|
const raw = parsed.sub2apiAuth;
|
||||||
@@ -141,6 +188,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
|
|||||||
: { refreshToken };
|
: { refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
|
||||||
|
const item = raw as Record<string, unknown>;
|
||||||
|
const normalized: SubscriptionPlanSummary = {};
|
||||||
|
|
||||||
|
const id = normalizeUserId(item.id);
|
||||||
|
if (id) normalized.id = id;
|
||||||
|
|
||||||
|
const groupId = normalizeUserId(item.groupId ?? item.group_id);
|
||||||
|
if (groupId) normalized.groupId = groupId;
|
||||||
|
|
||||||
|
const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name);
|
||||||
|
if (groupName) normalized.groupName = groupName;
|
||||||
|
|
||||||
|
const status = normalizeNonEmptyString(item.status);
|
||||||
|
if (status) normalized.status = status;
|
||||||
|
|
||||||
|
const expiresAt = normalizeIsoDateTime(
|
||||||
|
item.expiresAt
|
||||||
|
?? item.expires_at
|
||||||
|
?? item.expiredAt
|
||||||
|
?? item.expired_at
|
||||||
|
?? item.endAt
|
||||||
|
?? item.end_at,
|
||||||
|
);
|
||||||
|
if (expiresAt) normalized.expiresAt = expiresAt;
|
||||||
|
|
||||||
|
const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd);
|
||||||
|
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
|
||||||
|
|
||||||
|
const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd);
|
||||||
|
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
|
||||||
|
|
||||||
|
const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd);
|
||||||
|
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
|
||||||
|
|
||||||
|
const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd);
|
||||||
|
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
|
||||||
|
|
||||||
|
const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd);
|
||||||
|
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
|
||||||
|
|
||||||
|
const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd);
|
||||||
|
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
|
||||||
|
|
||||||
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw
|
||||||
|
.map((item) => normalizeSubscriptionItem(item))
|
||||||
|
.filter((item): item is SubscriptionPlanSummary => !!item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSub2ApiSubscriptionSummary(
|
||||||
|
raw: unknown,
|
||||||
|
): StoredSub2ApiSubscriptionSummary | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
|
||||||
|
const body = raw as Record<string, unknown>;
|
||||||
|
const subscriptions = normalizeSubscriptionItems(body.subscriptions);
|
||||||
|
const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count);
|
||||||
|
const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd);
|
||||||
|
const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount: Math.trunc(activeCount ?? subscriptions.length),
|
||||||
|
totalUsedUsd: totalUsedUsd ?? 0,
|
||||||
|
subscriptions,
|
||||||
|
updatedAt: updatedAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStoredSub2ApiSubscriptionSummary(
|
||||||
|
summary: SubscriptionSummary,
|
||||||
|
updatedAt = Date.now(),
|
||||||
|
): StoredSub2ApiSubscriptionSummary {
|
||||||
|
return normalizeSub2ApiSubscriptionSummary({
|
||||||
|
...summary,
|
||||||
|
updatedAt,
|
||||||
|
}) || {
|
||||||
|
activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)),
|
||||||
|
totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0,
|
||||||
|
subscriptions: normalizeSubscriptionItems(summary.subscriptions),
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSub2ApiSubscriptionFromExtraConfig(
|
||||||
|
extraConfig?: string | null,
|
||||||
|
): StoredSub2ApiSubscriptionSummary | null {
|
||||||
|
const parsed = parseExtraConfig(extraConfig);
|
||||||
|
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
|
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
|
||||||
const text = (username || '').trim();
|
const text = (username || '').trim();
|
||||||
if (!text) return undefined;
|
if (!text) return undefined;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
||||||
import { reportTokenExpired } from './alertService.js';
|
import { reportTokenExpired } from './alertService.js';
|
||||||
import {
|
import {
|
||||||
|
buildStoredSub2ApiSubscriptionSummary,
|
||||||
getAutoReloginConfig,
|
getAutoReloginConfig,
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
getSub2ApiAuthFromExtraConfig,
|
getSub2ApiAuthFromExtraConfig,
|
||||||
@@ -477,6 +478,11 @@ export async function refreshBalance(accountId: number) {
|
|||||||
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
||||||
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
|
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
|
||||||
}
|
}
|
||||||
|
if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) {
|
||||||
|
nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, {
|
||||||
|
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
|
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
|
||||||
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
|
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
|
||||||
|
|||||||
@@ -8,12 +8,33 @@ export interface CheckinResult {
|
|||||||
reward?: string;
|
reward?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionPlanSummary {
|
||||||
|
id?: number;
|
||||||
|
groupId?: number;
|
||||||
|
groupName?: string;
|
||||||
|
status?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
dailyUsedUsd?: number;
|
||||||
|
dailyLimitUsd?: number;
|
||||||
|
weeklyUsedUsd?: number;
|
||||||
|
weeklyLimitUsd?: number;
|
||||||
|
monthlyUsedUsd?: number;
|
||||||
|
monthlyLimitUsd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSummary {
|
||||||
|
activeCount: number;
|
||||||
|
totalUsedUsd: number;
|
||||||
|
subscriptions: SubscriptionPlanSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BalanceInfo {
|
export interface BalanceInfo {
|
||||||
balance: number;
|
balance: number;
|
||||||
used: number;
|
used: number;
|
||||||
quota: number;
|
quota: number;
|
||||||
todayIncome?: number;
|
todayIncome?: number;
|
||||||
todayQuotaConsumption?: number;
|
todayQuotaConsumption?: number;
|
||||||
|
subscriptionSummary?: SubscriptionSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResult {
|
interface LoginResult {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Sub2ApiAdapter } from './sub2api.js';
|
||||||
|
|
||||||
|
class MockSub2ApiAdapter extends Sub2ApiAdapter {
|
||||||
|
constructor(private readonly responses: Record<string, unknown>) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const key = `${parsed.pathname}${parsed.search}`;
|
||||||
|
const value = this.responses[key];
|
||||||
|
if (value instanceof Error) throw value;
|
||||||
|
if (value === undefined) throw new Error(`Unexpected request: ${key}`);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sub2ApiAdapter subscription summary parsing', () => {
|
||||||
|
it('returns summary data from /api/v1/subscriptions/summary', async () => {
|
||||||
|
const adapter = new MockSub2ApiAdapter({
|
||||||
|
'/api/v1/auth/me': {
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
|
||||||
|
},
|
||||||
|
'/api/v1/subscriptions/summary': {
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
active_count: 1,
|
||||||
|
total_used_usd: 3.2,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
group_name: 'Pro',
|
||||||
|
status: 'active',
|
||||||
|
expires_at: '2026-04-01T00:00:00Z',
|
||||||
|
monthly_used_usd: 3.2,
|
||||||
|
monthly_limit_usd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
|
||||||
|
expect(balance.subscriptionSummary).toEqual({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 3.2,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
groupName: 'Pro',
|
||||||
|
status: 'active',
|
||||||
|
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 3.2,
|
||||||
|
monthlyLimitUsd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to /api/v1/subscriptions/active when summary endpoint fails', async () => {
|
||||||
|
const adapter = new MockSub2ApiAdapter({
|
||||||
|
'/api/v1/auth/me': {
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
|
||||||
|
},
|
||||||
|
'/api/v1/subscriptions/summary': new Error('HTTP 404'),
|
||||||
|
'/api/v1/subscriptions/active': {
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
group_name: 'Fallback',
|
||||||
|
expires_at: '2026-05-01T00:00:00Z',
|
||||||
|
monthly_used_usd: 1.5,
|
||||||
|
monthly_limit_usd: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
|
||||||
|
expect(balance.subscriptionSummary).toEqual({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 1.5,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
groupName: 'Fallback',
|
||||||
|
expiresAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 1.5,
|
||||||
|
monthlyLimitUsd: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,6 +101,111 @@ describe('Sub2ApiAdapter', () => {
|
|||||||
expect(balance.used).toBe(0);
|
expect(balance.used).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes subscription summary from /api/v1/subscriptions/summary when available', async () => {
|
||||||
|
await startServer((req, res) => {
|
||||||
|
if (req.url === '/api/v1/auth/me') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/v1/subscriptions/summary') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
active_count: 1,
|
||||||
|
total_used_usd: 3.75,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
group_name: 'Pro',
|
||||||
|
status: 'active',
|
||||||
|
expires_at: '2026-04-01T00:00:00Z',
|
||||||
|
monthly_used_usd: 3.75,
|
||||||
|
monthly_limit_usd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
|
||||||
|
expect(balance.subscriptionSummary).toEqual({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 3.75,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
groupName: 'Pro',
|
||||||
|
status: 'active',
|
||||||
|
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 3.75,
|
||||||
|
monthlyLimitUsd: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to active subscriptions when summary endpoint is unavailable', async () => {
|
||||||
|
await startServer((req, res) => {
|
||||||
|
if (req.url === '/api/v1/auth/me') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/v1/subscriptions/summary') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: 404, message: 'not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/v1/subscriptions/active') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
group_name: 'Fallback',
|
||||||
|
expires_at: '2026-05-01T00:00:00Z',
|
||||||
|
monthly_used_usd: 2.5,
|
||||||
|
monthly_limit_usd: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
|
||||||
|
expect(balance.subscriptionSummary).toEqual({
|
||||||
|
activeCount: 1,
|
||||||
|
totalUsedUsd: 2.5,
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
groupName: 'Fallback',
|
||||||
|
expiresAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
monthlyUsedUsd: 2.5,
|
||||||
|
monthlyLimitUsd: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('fetches user info from /api/v1/auth/me', async () => {
|
it('fetches user info from /api/v1/auth/me', async () => {
|
||||||
await startServer((req, res) => {
|
await startServer((req, res) => {
|
||||||
if (req.url === '/api/v1/auth/me') {
|
if (req.url === '/api/v1/auth/me') {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
CheckinResult,
|
CheckinResult,
|
||||||
BalanceInfo,
|
BalanceInfo,
|
||||||
CreateApiTokenOptions,
|
CreateApiTokenOptions,
|
||||||
|
SubscriptionPlanSummary,
|
||||||
|
SubscriptionSummary,
|
||||||
type SiteAnnouncement,
|
type SiteAnnouncement,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
} from './base.js';
|
} from './base.js';
|
||||||
@@ -22,6 +24,193 @@ function normalizeBaseUrl(baseUrl: string): string {
|
|||||||
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||||
readonly platformName = 'sub2api';
|
readonly platformName = 'sub2api';
|
||||||
|
|
||||||
|
private roundCurrency(value: number): number {
|
||||||
|
return Math.round(value * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePositiveInteger(raw: unknown): number | undefined {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const parsed = Number.parseInt(raw.trim(), 10);
|
||||||
|
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseNonNegativeNumber(raw: unknown): number | undefined {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
||||||
|
return this.roundCurrency(raw);
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const parsed = Number(raw.trim());
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||||
|
return this.roundCurrency(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDateTime(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||||
|
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const numeric = Number(trimmed);
|
||||||
|
if (Number.isFinite(numeric) && numeric > 0) {
|
||||||
|
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
|
||||||
|
const item = raw as Record<string, unknown>;
|
||||||
|
const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group)
|
||||||
|
? item.group as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const normalized: SubscriptionPlanSummary = {};
|
||||||
|
|
||||||
|
const id = this.parsePositiveInteger(item.id);
|
||||||
|
if (id) normalized.id = id;
|
||||||
|
|
||||||
|
const groupId = this.parsePositiveInteger(item.group_id ?? item.groupId ?? group?.id);
|
||||||
|
if (groupId) normalized.groupId = groupId;
|
||||||
|
|
||||||
|
const groupNameCandidates = [
|
||||||
|
item.group_name,
|
||||||
|
item.groupName,
|
||||||
|
item.name,
|
||||||
|
item.title,
|
||||||
|
group?.name,
|
||||||
|
group?.title,
|
||||||
|
];
|
||||||
|
for (const candidate of groupNameCandidates) {
|
||||||
|
if (typeof candidate !== 'string') continue;
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
normalized.groupName = trimmed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.status === 'string' && item.status.trim()) {
|
||||||
|
normalized.status = item.status.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = this.parseDateTime(
|
||||||
|
item.expires_at
|
||||||
|
?? item.expiresAt
|
||||||
|
?? item.expired_at
|
||||||
|
?? item.expiredAt
|
||||||
|
?? item.end_at
|
||||||
|
?? item.endAt
|
||||||
|
?? item.end_time
|
||||||
|
?? item.endTime
|
||||||
|
?? item.current_period_end
|
||||||
|
?? item.currentPeriodEnd,
|
||||||
|
);
|
||||||
|
if (expiresAt) normalized.expiresAt = expiresAt;
|
||||||
|
|
||||||
|
const dailyUsedUsd = this.parseNonNegativeNumber(item.daily_used_usd ?? item.dailyUsedUsd);
|
||||||
|
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
|
||||||
|
|
||||||
|
const dailyLimitUsd = this.parseNonNegativeNumber(item.daily_limit_usd ?? item.dailyLimitUsd);
|
||||||
|
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
|
||||||
|
|
||||||
|
const weeklyUsedUsd = this.parseNonNegativeNumber(item.weekly_used_usd ?? item.weeklyUsedUsd);
|
||||||
|
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
|
||||||
|
|
||||||
|
const weeklyLimitUsd = this.parseNonNegativeNumber(item.weekly_limit_usd ?? item.weeklyLimitUsd);
|
||||||
|
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
|
||||||
|
|
||||||
|
const monthlyUsedUsd = this.parseNonNegativeNumber(
|
||||||
|
item.monthly_used_usd
|
||||||
|
?? item.monthlyUsedUsd
|
||||||
|
?? item.used_usd
|
||||||
|
?? item.usedUsd
|
||||||
|
?? item.total_used_usd
|
||||||
|
?? item.totalUsedUsd,
|
||||||
|
);
|
||||||
|
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
|
||||||
|
|
||||||
|
const monthlyLimitUsd = this.parseNonNegativeNumber(
|
||||||
|
item.monthly_limit_usd
|
||||||
|
?? item.monthlyLimitUsd
|
||||||
|
?? item.limit_usd
|
||||||
|
?? item.limitUsd
|
||||||
|
?? item.total_limit_usd
|
||||||
|
?? item.totalLimitUsd,
|
||||||
|
);
|
||||||
|
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
|
||||||
|
|
||||||
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
|
||||||
|
const rawItems = (() => {
|
||||||
|
if (Array.isArray(raw)) return raw;
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const body = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(body.subscriptions)) return body.subscriptions;
|
||||||
|
if (Array.isArray(body.items)) return body.items;
|
||||||
|
if (Array.isArray(body.list)) return body.list;
|
||||||
|
if (Array.isArray(body.data)) return body.data;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
return rawItems
|
||||||
|
.map((item) => this.parseSubscriptionItem(item))
|
||||||
|
.filter((item): item is SubscriptionPlanSummary => !!item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSubscriptionSummary(payload: unknown): SubscriptionSummary {
|
||||||
|
const body = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||||
|
? payload as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const subscriptions = this.parseSubscriptionItems(payload);
|
||||||
|
const activeCount = this.parsePositiveInteger(body.active_count ?? body.activeCount);
|
||||||
|
const totalUsedUsd = this.parseNonNegativeNumber(body.total_used_usd ?? body.totalUsedUsd);
|
||||||
|
const inferredUsedUsd = subscriptions.reduce((sum, item) => sum + (item.monthlyUsedUsd || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount: activeCount ?? subscriptions.length,
|
||||||
|
totalUsedUsd: totalUsedUsd ?? this.roundCurrency(inferredUsedUsd),
|
||||||
|
subscriptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchSubscriptionSummary(baseUrl: string, accessToken: string): Promise<SubscriptionSummary | undefined> {
|
||||||
|
const headers = this.buildAuthHeader(accessToken);
|
||||||
|
const summaryEndpoint = '/api/v1/subscriptions/summary';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(`${baseUrl}${summaryEndpoint}`, { headers });
|
||||||
|
const data = this.parseSub2ApiEnvelope<any>(res, summaryEndpoint);
|
||||||
|
return this.buildSubscriptionSummary(data);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const fallbackEndpoints = ['/api/v1/subscriptions/active'];
|
||||||
|
for (const endpoint of fallbackEndpoints) {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, { headers });
|
||||||
|
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||||
|
return this.buildSubscriptionSummary(data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private stripBearerPrefix(value?: string | null): string {
|
private stripBearerPrefix(value?: string | null): string {
|
||||||
const trimmed = (value || '').trim();
|
const trimmed = (value || '').trim();
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
@@ -462,13 +651,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
|
|
||||||
// --- Balance ---
|
// --- Balance ---
|
||||||
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
|
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
|
||||||
const user = await this.fetchAuthMe(baseUrl, accessToken);
|
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||||
|
const [user, subscriptionSummary] = await Promise.all([
|
||||||
|
this.fetchAuthMe(normalizedBase, accessToken),
|
||||||
|
this.fetchSubscriptionSummary(normalizedBase, accessToken),
|
||||||
|
]);
|
||||||
const quotaValue = this.usdToQuota(user.balance);
|
const quotaValue = this.usdToQuota(user.balance);
|
||||||
// Sub2API only provides current balance, no usage breakdown
|
// Sub2API only provides current balance, no usage breakdown
|
||||||
return {
|
return {
|
||||||
balance: quotaValue / 500000,
|
balance: quotaValue / 500000,
|
||||||
used: 0,
|
used: 0,
|
||||||
quota: quotaValue / 500000,
|
quota: quotaValue / 500000,
|
||||||
|
subscriptionSummary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,19 @@ describe('proxyRetryPolicy', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries on generic client request errors for cross-channel fallback', () => {
|
it('does not retry obvious request-shape errors that will fail on every channel', () => {
|
||||||
expect(
|
expect(
|
||||||
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
|
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
|
||||||
).toBe(true);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps retrying channel-local compatibility and auth failures', () => {
|
||||||
expect(
|
expect(
|
||||||
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
|
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -31,10 +40,7 @@ describe('proxyRetryPolicy', () => {
|
|||||||
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
|
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
|
shouldRetryProxyRequest(400, 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.'),
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,14 +14,61 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
|
|||||||
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
|
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [
|
||||||
|
/unsupported\s+legacy\s+protocol/i,
|
||||||
|
/please\s+use\s+\/v1\/responses/i,
|
||||||
|
/please\s+use\s+\/v1\/messages/i,
|
||||||
|
/please\s+use\s+\/v1\/chat\/completions/i,
|
||||||
|
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i,
|
||||||
|
/unsupported\s+endpoint/i,
|
||||||
|
/unsupported\s+path/i,
|
||||||
|
/unknown\s+endpoint/i,
|
||||||
|
/unrecognized\s+request\s+url/i,
|
||||||
|
/no\s+route\s+matched/i,
|
||||||
|
/invalid\s+api\s+key/i,
|
||||||
|
/invalid\s+access\s+token/i,
|
||||||
|
/forbidden/i,
|
||||||
|
/rate\s+limit/i,
|
||||||
|
/quota/i,
|
||||||
|
/bad\s+gateway/i,
|
||||||
|
/gateway\s+time-?out/i,
|
||||||
|
/service\s+unavailable/i,
|
||||||
|
/cpu\s+overloaded/i,
|
||||||
|
/timeout/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
|
||||||
|
/invalid\s+request\s+body/i,
|
||||||
|
/validation/i,
|
||||||
|
/missing\s+required/i,
|
||||||
|
/required\s+parameter/i,
|
||||||
|
/unknown\s+parameter/i,
|
||||||
|
/unrecognized\s+(field|key|parameter)/i,
|
||||||
|
/malformed/i,
|
||||||
|
/invalid\s+json/i,
|
||||||
|
/cannot\s+parse/i,
|
||||||
|
/unsupported\s+media\s+type/i,
|
||||||
|
];
|
||||||
|
|
||||||
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
|
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
|
||||||
const text = (rawMessage || '').trim();
|
const text = (rawMessage || '').trim();
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
|
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
|
function matchesAnyPattern(patterns: RegExp[], rawMessage?: string | null): boolean {
|
||||||
if (status >= 400) return true;
|
const text = (rawMessage || '').trim();
|
||||||
if (!upstreamErrorText) return false;
|
if (!text) return false;
|
||||||
return isModelUnsupportedErrorMessage(upstreamErrorText);
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
|
||||||
|
if (status >= 500) return true;
|
||||||
|
if (status === 408 || status === 409 || status === 425 || status === 429) return true;
|
||||||
|
if (status === 401 || status === 403) return true;
|
||||||
|
if (isModelUnsupportedErrorMessage(upstreamErrorText)) return true;
|
||||||
|
if (matchesAnyPattern(NON_RETRYABLE_REQUEST_PATTERNS, upstreamErrorText)) return false;
|
||||||
|
if (matchesAnyPattern(RETRYABLE_CHANNEL_LOCAL_PATTERNS, upstreamErrorText)) return true;
|
||||||
|
if (status === 400 || status === 404 || status === 422) return false;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||||
|
|
||||||
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
|
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
|
||||||
|
|
||||||
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
|
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
if (normalized === 'round_robin') return 'round_robin';
|
if (normalized === 'round_robin') return 'round_robin';
|
||||||
|
if (normalized === 'stable_first') return 'stable_first';
|
||||||
return DEFAULT_ROUTE_ROUTING_STRATEGY;
|
return DEFAULT_ROUTE_ROUTING_STRATEGY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
|
||||||
let config: ConfigModule['config'];
|
let config: ConfigModule['config'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
let originalCacheTtlMs = 0;
|
let originalCacheTtlMs = 0;
|
||||||
@@ -29,6 +30,7 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
TokenRouter = tokenRouterModule.TokenRouter;
|
TokenRouter = tokenRouterModule.TokenRouter;
|
||||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
|
||||||
config = configModule.config;
|
config = configModule.config;
|
||||||
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
|
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
|
||||||
});
|
});
|
||||||
@@ -36,16 +38,19 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await db.delete(schema.routeChannels).run();
|
await db.delete(schema.routeChannels).run();
|
||||||
await db.delete(schema.tokenRoutes).run();
|
await db.delete(schema.tokenRoutes).run();
|
||||||
|
await db.delete(schema.settings).run();
|
||||||
await db.delete(schema.accountTokens).run();
|
await db.delete(schema.accountTokens).run();
|
||||||
await db.delete(schema.accounts).run();
|
await db.delete(schema.accounts).run();
|
||||||
await db.delete(schema.sites).run();
|
await db.delete(schema.sites).run();
|
||||||
config.tokenRouterCacheTtlMs = 60_000;
|
config.tokenRouterCacheTtlMs = 60_000;
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
|
resetSiteRuntimeHealthState();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
|
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
|
resetSiteRuntimeHealthState();
|
||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
|||||||
import { mkdtempSync } from 'node:fs';
|
import { mkdtempSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
type DbModule = typeof import('../db/index.js');
|
type DbModule = typeof import('../db/index.js');
|
||||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||||
@@ -24,6 +25,8 @@ describe('TokenRouter selection scoring', () => {
|
|||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
|
||||||
|
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
|
||||||
let config: ConfigModule['config'];
|
let config: ConfigModule['config'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
let idSeed = 0;
|
let idSeed = 0;
|
||||||
@@ -47,6 +50,8 @@ describe('TokenRouter selection scoring', () => {
|
|||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
TokenRouter = tokenRouterModule.TokenRouter;
|
TokenRouter = tokenRouterModule.TokenRouter;
|
||||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
|
||||||
|
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
|
||||||
config = configModule.config;
|
config = configModule.config;
|
||||||
originalRoutingWeights = { ...config.routingWeights };
|
originalRoutingWeights = { ...config.routingWeights };
|
||||||
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
|
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
|
||||||
@@ -58,16 +63,19 @@ describe('TokenRouter selection scoring', () => {
|
|||||||
mockedCatalogRoutingCost.mockReturnValue(null);
|
mockedCatalogRoutingCost.mockReturnValue(null);
|
||||||
await db.delete(schema.routeChannels).run();
|
await db.delete(schema.routeChannels).run();
|
||||||
await db.delete(schema.tokenRoutes).run();
|
await db.delete(schema.tokenRoutes).run();
|
||||||
|
await db.delete(schema.settings).run();
|
||||||
await db.delete(schema.accountTokens).run();
|
await db.delete(schema.accountTokens).run();
|
||||||
await db.delete(schema.accounts).run();
|
await db.delete(schema.accounts).run();
|
||||||
await db.delete(schema.sites).run();
|
await db.delete(schema.sites).run();
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
|
resetSiteRuntimeHealthState();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
config.routingWeights = { ...originalRoutingWeights };
|
config.routingWeights = { ...originalRoutingWeights };
|
||||||
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
|
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
|
resetSiteRuntimeHealthState();
|
||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,4 +406,400 @@ describe('TokenRouter selection scoring', () => {
|
|||||||
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
|
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
|
||||||
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
|
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('downweights a site after transient failures and restores it quickly after success', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await createRoute('gpt-5.4');
|
||||||
|
|
||||||
|
const siteA = await createSite('runtime-a');
|
||||||
|
const accountA = await createAccount(siteA.id, 'runtime-user-a');
|
||||||
|
const tokenA = await createToken(accountA.id, 'runtime-token-a');
|
||||||
|
const channelA = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteB = await createSite('runtime-b');
|
||||||
|
const accountB = await createAccount(siteB.id, 'runtime-user-b');
|
||||||
|
const tokenB = await createToken(accountB.id, 'runtime-token-b');
|
||||||
|
const channelB = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
let decision = await router.explainSelection('gpt-5.4');
|
||||||
|
let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect(candidateA?.probability).toBeCloseTo(50, 1);
|
||||||
|
expect(candidateB?.probability).toBeCloseTo(50, 1);
|
||||||
|
|
||||||
|
await router.recordFailure(channelA.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: 'Bad gateway',
|
||||||
|
});
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastFailAt: null,
|
||||||
|
failCount: 0,
|
||||||
|
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
decision = await router.explainSelection('gpt-5.4');
|
||||||
|
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect(candidateA).toBeTruthy();
|
||||||
|
expect(candidateB).toBeTruthy();
|
||||||
|
expect((candidateA?.probability || 0)).toBeLessThan(30);
|
||||||
|
expect(candidateA?.reason || '').toContain('运行时健康=');
|
||||||
|
expect((candidateB?.probability || 0)).toBeGreaterThan(70);
|
||||||
|
|
||||||
|
await router.recordSuccess(channelA.id, 800, 0);
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
decision = await router.explainSelection('gpt-5.4');
|
||||||
|
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect((candidateA?.probability || 0)).toBeGreaterThan(40);
|
||||||
|
expect((candidateB?.probability || 0)).toBeLessThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a site breaker after repeated transient failures and closes it after recovery', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await createRoute('gpt-5.3');
|
||||||
|
|
||||||
|
const siteA = await createSite('breaker-a');
|
||||||
|
const accountA = await createAccount(siteA.id, 'breaker-user-a');
|
||||||
|
const tokenA = await createToken(accountA.id, 'breaker-token-a');
|
||||||
|
const channelA = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteB = await createSite('breaker-b');
|
||||||
|
const accountB = await createAccount(siteB.id, 'breaker-user-b');
|
||||||
|
const tokenB = await createToken(accountB.id, 'breaker-token-b');
|
||||||
|
const channelB = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
await router.recordFailure(channelA.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: 'Gateway timeout',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastFailAt: null,
|
||||||
|
failCount: 0,
|
||||||
|
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
let decision = await router.explainSelection('gpt-5.3');
|
||||||
|
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect(breakerCandidateA?.reason || '').toContain('站点熔断');
|
||||||
|
expect((breakerCandidateA?.probability || 0)).toBe(0);
|
||||||
|
expect((breakerCandidateB?.probability || 0)).toBe(100);
|
||||||
|
expect(decision.summary.join(' ')).toContain('站点熔断避让');
|
||||||
|
|
||||||
|
await router.recordSuccess(channelA.id, 600, 0);
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
decision = await router.explainSelection('gpt-5.3');
|
||||||
|
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
|
||||||
|
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses persisted site success and latency history to prefer historically healthier sites', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await createRoute('claude-4-sonnet');
|
||||||
|
|
||||||
|
const siteStable = await createSite('history-stable');
|
||||||
|
const accountStable = await createAccount(siteStable.id, 'history-user-stable');
|
||||||
|
const tokenStable = await createToken(accountStable.id, 'history-token-stable');
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountStable.id,
|
||||||
|
tokenId: tokenStable.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
successCount: 90,
|
||||||
|
failCount: 10,
|
||||||
|
totalLatencyMs: 90 * 240,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const siteWeak = await createSite('history-weak');
|
||||||
|
const accountWeak = await createAccount(siteWeak.id, 'history-user-weak');
|
||||||
|
const tokenWeak = await createToken(accountWeak.id, 'history-token-weak');
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountWeak.id,
|
||||||
|
tokenId: tokenWeak.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
successCount: 20,
|
||||||
|
failCount: 30,
|
||||||
|
totalLatencyMs: 20 * 5200,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const decision = await new TokenRouter().explainSelection('claude-4-sonnet');
|
||||||
|
const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable'));
|
||||||
|
const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak'));
|
||||||
|
|
||||||
|
expect(stableCandidate).toBeTruthy();
|
||||||
|
expect(weakCandidate).toBeTruthy();
|
||||||
|
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
|
||||||
|
expect(stableCandidate?.reason || '').toContain('历史健康=');
|
||||||
|
expect(stableCandidate?.reason || '').toContain('成功率=90.0%');
|
||||||
|
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads persisted runtime health after in-memory reset', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await createRoute('gpt-4o-mini');
|
||||||
|
|
||||||
|
const siteA = await createSite('persist-a');
|
||||||
|
const accountA = await createAccount(siteA.id, 'persist-user-a');
|
||||||
|
const tokenA = await createToken(accountA.id, 'persist-token-a');
|
||||||
|
const channelA = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteB = await createSite('persist-b');
|
||||||
|
const accountB = await createAccount(siteB.id, 'persist-user-b');
|
||||||
|
const tokenB = await createToken(accountB.id, 'persist-token-b');
|
||||||
|
const channelB = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
await router.recordFailure(channelA.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: 'Gateway timeout',
|
||||||
|
modelName: 'gpt-4o-mini',
|
||||||
|
});
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastFailAt: null,
|
||||||
|
failCount: 0,
|
||||||
|
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||||
|
await flushSiteRuntimeHealthPersistence();
|
||||||
|
|
||||||
|
const persisted = await db.select().from(schema.settings)
|
||||||
|
.where(eq(schema.settings.key, 'token_router_site_runtime_health_v1'))
|
||||||
|
.get();
|
||||||
|
expect(persisted?.value).toBeTruthy();
|
||||||
|
|
||||||
|
resetSiteRuntimeHealthState();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
const decision = await new TokenRouter().explainSelection('gpt-4o-mini');
|
||||||
|
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
|
||||||
|
expect(candidateA).toBeTruthy();
|
||||||
|
expect(candidateB).toBeTruthy();
|
||||||
|
expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0));
|
||||||
|
expect(candidateA?.reason || '').toContain('运行时健康=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('penalizes the failed model more than unrelated models on the same site', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const gptRoute = await createRoute('gpt-5.4');
|
||||||
|
const claudeRoute = await createRoute('claude-sonnet-4-6');
|
||||||
|
|
||||||
|
const siteA = await createSite('model-aware-a');
|
||||||
|
const accountA = await createAccount(siteA.id, 'model-aware-user-a');
|
||||||
|
const tokenA = await createToken(accountA.id, 'model-aware-token-a');
|
||||||
|
const gptChannelA = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: gptRoute.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: claudeRoute.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const siteB = await createSite('model-aware-b');
|
||||||
|
const accountB = await createAccount(siteB.id, 'model-aware-user-b');
|
||||||
|
const tokenB = await createToken(accountB.id, 'model-aware-token-b');
|
||||||
|
await db.insert(schema.routeChannels).values([
|
||||||
|
{
|
||||||
|
routeId: gptRoute.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: claudeRoute.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
await router.recordFailure(gptChannelA.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: 'Bad gateway',
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastFailAt: null,
|
||||||
|
failCount: 0,
|
||||||
|
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
const gptDecision = await router.explainSelection('gpt-5.4');
|
||||||
|
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
|
||||||
|
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
|
||||||
|
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
|
||||||
|
|
||||||
|
expect(gptCandidateA).toBeTruthy();
|
||||||
|
expect(claudeCandidateA).toBeTruthy();
|
||||||
|
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
|
||||||
|
expect(gptCandidateA?.reason || '').toContain('模型=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stable_first deterministically chooses the healthiest candidate', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.1',
|
||||||
|
routingStrategy: 'stable_first',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteA = await createSite('stable-first-a');
|
||||||
|
const accountA = await createAccount(siteA.id, 'stable-first-user-a');
|
||||||
|
const tokenA = await createToken(accountA.id, 'stable-first-token-a');
|
||||||
|
const channelA = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteB = await createSite('stable-first-b');
|
||||||
|
const accountB = await createAccount(siteB.id, 'stable-first-user-b');
|
||||||
|
const tokenB = await createToken(accountB.id, 'stable-first-token-b');
|
||||||
|
const channelB = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
await router.recordFailure(channelA.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: 'Gateway timeout',
|
||||||
|
modelName: 'gpt-5.1',
|
||||||
|
});
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastFailAt: null,
|
||||||
|
failCount: 0,
|
||||||
|
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
const preview = await router.previewSelectedChannel('gpt-5.1');
|
||||||
|
const decision = await router.explainSelection('gpt-5.1');
|
||||||
|
|
||||||
|
expect(preview?.channel.id).toBe(channelB.id);
|
||||||
|
expect(decision.summary.join(' ')).toContain('稳定优先');
|
||||||
|
expect(decision.selectedChannelId).toBe(channelB.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1917,6 +1917,52 @@ body {
|
|||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-balance-cell {
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-balance-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-balance-inline.align-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-balance-primary {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-balance-divider {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-balance-subscription {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: normal;
|
||||||
|
text-wrap: pretty;
|
||||||
|
cursor: help;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-balance-subscription:hover,
|
||||||
|
.site-balance-subscription:focus-visible {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.sites-row-actions {
|
.sites-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
+132
-3
@@ -23,6 +23,16 @@ import {
|
|||||||
type SiteForm,
|
type SiteForm,
|
||||||
} from './helpers/sitesEditor.js';
|
} from './helpers/sitesEditor.js';
|
||||||
|
|
||||||
|
type SiteSubscriptionSummary = {
|
||||||
|
activeCount: number;
|
||||||
|
totalUsedUsd: number;
|
||||||
|
totalMonthlyLimitUsd?: number | null;
|
||||||
|
totalRemainingUsd?: number | null;
|
||||||
|
nextExpiresAt?: string | null;
|
||||||
|
planNames?: string[];
|
||||||
|
updatedAt?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
type SiteRow = {
|
type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,6 +46,7 @@ type SiteRow = {
|
|||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
totalBalance?: number;
|
totalBalance?: number;
|
||||||
|
subscriptionSummary?: SiteSubscriptionSummary | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +54,105 @@ function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
|
|||||||
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
|
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUsd(value?: number | null): string {
|
||||||
|
return `$${(value || 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubscriptionDate(value?: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (!Number.isFinite(parsed)) return value;
|
||||||
|
return new Date(parsed).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRemainingDuration(value?: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const targetMs = Date.parse(value);
|
||||||
|
if (!Number.isFinite(targetMs)) return null;
|
||||||
|
const deltaMs = targetMs - Date.now();
|
||||||
|
if (deltaMs <= 0) return '已到期';
|
||||||
|
|
||||||
|
const minuteMs = 60 * 1000;
|
||||||
|
const hourMs = 60 * minuteMs;
|
||||||
|
const dayMs = 24 * hourMs;
|
||||||
|
if (deltaMs >= dayMs) return `剩余${Math.ceil(deltaMs / dayMs)}天`;
|
||||||
|
if (deltaMs >= hourMs) return `剩余${Math.ceil(deltaMs / hourMs)}小时`;
|
||||||
|
if (deltaMs >= minuteMs) return `剩余${Math.ceil(deltaMs / minuteMs)}分钟`;
|
||||||
|
return `剩余${Math.max(1, Math.ceil(deltaMs / 1000))}秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubscriptionInlineValue(summary?: SiteSubscriptionSummary | null): string | null {
|
||||||
|
if (!summary) return null;
|
||||||
|
const remainingValue = typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)
|
||||||
|
? formatUsd(summary.totalRemainingUsd)
|
||||||
|
: '--';
|
||||||
|
const usedValue = formatUsd(summary.totalUsedUsd);
|
||||||
|
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
|
||||||
|
const remainingSuffix = remainingDuration ? `(${remainingDuration})` : '';
|
||||||
|
if (usedValue === '$0.00' && remainingValue === '--' && !remainingSuffix) return null;
|
||||||
|
return `${remainingValue}${remainingSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubscriptionTooltip(summary?: SiteSubscriptionSummary | null): string | null {
|
||||||
|
if (!summary) return null;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (summary.activeCount > 0) parts.push(`生效订阅 ${summary.activeCount} 个`);
|
||||||
|
|
||||||
|
const planNames = Array.isArray(summary.planNames)
|
||||||
|
? summary.planNames.filter((item) => typeof item === 'string' && item.trim())
|
||||||
|
: [];
|
||||||
|
if (planNames.length > 0) parts.push(`套餐 ${planNames.join(' / ')}`);
|
||||||
|
|
||||||
|
if (typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)) {
|
||||||
|
parts.push(`订阅余额 ${formatUsd(summary.totalRemainingUsd)}`);
|
||||||
|
}
|
||||||
|
parts.push(`已用 ${formatUsd(summary.totalUsedUsd)}`);
|
||||||
|
|
||||||
|
if (typeof summary.totalMonthlyLimitUsd === 'number' && Number.isFinite(summary.totalMonthlyLimitUsd)) {
|
||||||
|
parts.push(`总额度 ${formatUsd(summary.totalMonthlyLimitUsd)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
|
||||||
|
if (remainingDuration) parts.push(remainingDuration);
|
||||||
|
|
||||||
|
if (summary.nextExpiresAt) parts.push(`到期 ${formatSubscriptionDate(summary.nextExpiresAt)}`);
|
||||||
|
|
||||||
|
return parts.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiteBalanceDisplay(props: {
|
||||||
|
balance?: number | null;
|
||||||
|
summary?: SiteSubscriptionSummary | null;
|
||||||
|
align?: 'start' | 'end';
|
||||||
|
}) {
|
||||||
|
const { balance, summary, align = 'start' } = props;
|
||||||
|
const walletBalanceText = formatUsd(balance);
|
||||||
|
const subscriptionValue = buildSubscriptionInlineValue(summary);
|
||||||
|
const tooltip = buildSubscriptionTooltip(summary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`site-balance-inline ${align === 'end' ? 'align-end' : ''}`.trim()}
|
||||||
|
>
|
||||||
|
<span className="site-balance-primary">{walletBalanceText}</span>
|
||||||
|
{subscriptionValue ? (
|
||||||
|
<>
|
||||||
|
<span className="site-balance-divider">/</span>
|
||||||
|
<span
|
||||||
|
className="site-balance-subscription"
|
||||||
|
data-tooltip={tooltip || undefined}
|
||||||
|
data-tooltip-align={align === 'end' ? 'end' : 'start'}
|
||||||
|
data-tooltip-side="top"
|
||||||
|
tabIndex={tooltip ? 0 : undefined}
|
||||||
|
>
|
||||||
|
{subscriptionValue}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const platformColors: Record<string, string> = {
|
const platformColors: Record<string, string> = {
|
||||||
'new-api': 'badge-info',
|
'new-api': 'badge-info',
|
||||||
'one-api': 'badge-success',
|
'one-api': 'badge-success',
|
||||||
@@ -873,7 +983,16 @@ export default function Sites() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<MobileField label="余额" value={`$${(site.totalBalance || 0).toFixed(2)}`} />
|
<MobileField
|
||||||
|
label="余额"
|
||||||
|
value={(
|
||||||
|
<SiteBalanceDisplay
|
||||||
|
balance={site.totalBalance}
|
||||||
|
summary={site.subscriptionSummary}
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
|
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div className="mobile-card-extra">
|
<div className="mobile-card-extra">
|
||||||
@@ -1017,6 +1136,7 @@ export default function Sites() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
|
||||||
<a
|
<a
|
||||||
href={site.url}
|
href={site.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1028,6 +1148,12 @@ export default function Sites() {
|
|||||||
>
|
>
|
||||||
{site.name}
|
{site.name}
|
||||||
</a>
|
</a>
|
||||||
|
{hasConfiguredCustomHeaders(site.customHeaders) ? (
|
||||||
|
<span className="badge badge-info" style={{ fontSize: 11 }}>
|
||||||
|
自定义头
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||||
{site.externalCheckinUrl ? (
|
{site.externalCheckinUrl ? (
|
||||||
@@ -1048,8 +1174,11 @@ export default function Sites() {
|
|||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
<td className="site-balance-cell">
|
||||||
${(site.totalBalance || 0).toFixed(2)}
|
<SiteBalanceDisplay
|
||||||
|
balance={site.totalBalance}
|
||||||
|
summary={site.subscriptionSummary}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
|
|||||||
advancedOpen: false,
|
advancedOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeRouteRoutingStrategyValue(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
|
||||||
|
if (value === 'round_robin' || value === 'stable_first') return value;
|
||||||
|
return 'weighted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
|
||||||
|
const strategy = normalizeRouteRoutingStrategyValue(value);
|
||||||
|
if (strategy === 'round_robin') return tr('轮询');
|
||||||
|
if (strategy === 'stable_first') return tr('稳定优先');
|
||||||
|
return tr('权重随机');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
|
||||||
|
if (value === 'round_robin') return '已切换为轮询策略';
|
||||||
|
if (value === 'stable_first') return '已切换为稳定优先策略';
|
||||||
|
return '已切换为权重随机策略';
|
||||||
|
}
|
||||||
|
|
||||||
export default function TokenRoutes() {
|
export default function TokenRoutes() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||||
@@ -421,7 +439,7 @@ export default function TokenRoutes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
|
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
|
||||||
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
const currentStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
|
||||||
if (routingStrategy === currentStrategy) return;
|
if (routingStrategy === currentStrategy) return;
|
||||||
|
|
||||||
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
|
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
|
||||||
@@ -432,7 +450,7 @@ export default function TokenRoutes() {
|
|||||||
)));
|
)));
|
||||||
try {
|
try {
|
||||||
await api.updateRoute(route.id, { routingStrategy });
|
await api.updateRoute(route.id, { routingStrategy });
|
||||||
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
toast.success(getRouteRoutingStrategySuccessMessage(routingStrategy));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRouteSummaries((prev) => prev.map((item) => (
|
setRouteSummaries((prev) => prev.map((item) => (
|
||||||
item.id === route.id
|
item.id === route.id
|
||||||
@@ -1186,7 +1204,7 @@ export default function TokenRoutes() {
|
|||||||
>
|
>
|
||||||
<MobileField label="模型" value={route.modelPattern} />
|
<MobileField label="模型" value={route.modelPattern} />
|
||||||
<MobileField label="通道" value={route.channelCount} />
|
<MobileField label="通道" value={route.channelCount} />
|
||||||
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
|
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
|
||||||
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
|
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
|
||||||
<div className="mobile-card-actions">
|
<div className="mobile-card-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ type RouteCardProps = {
|
|||||||
onToggleSourceGroup: (groupKey: string) => void;
|
onToggleSourceGroup: (groupKey: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeRoutingStrategy(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
|
||||||
|
if (value === 'round_robin' || value === 'stable_first') return value;
|
||||||
|
return 'weighted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
|
||||||
|
const strategy = normalizeRoutingStrategy(value);
|
||||||
|
if (strategy === 'round_robin') return tr('轮询');
|
||||||
|
if (strategy === 'stable_first') return tr('稳定优先');
|
||||||
|
return tr('权重随机');
|
||||||
|
}
|
||||||
|
|
||||||
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
|
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
|
||||||
const presence = useAnimatedVisibility(open, 220);
|
const presence = useAnimatedVisibility(open, 220);
|
||||||
if (!presence.shouldRender) return null;
|
if (!presence.shouldRender) return null;
|
||||||
@@ -120,7 +132,7 @@ function RouteCardInner({
|
|||||||
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||||
const channelManagementDisabled = explicitGroupRoute;
|
const channelManagementDisabled = explicitGroupRoute;
|
||||||
const title = resolveRouteTitle(route);
|
const title = resolveRouteTitle(route);
|
||||||
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
const routingStrategy = normalizeRoutingStrategy(route.routingStrategy);
|
||||||
const routingStrategyOptions = [
|
const routingStrategyOptions = [
|
||||||
{
|
{
|
||||||
value: 'weighted',
|
value: 'weighted',
|
||||||
@@ -132,6 +144,11 @@ function RouteCardInner({
|
|||||||
label: tr('轮询'),
|
label: tr('轮询'),
|
||||||
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
|
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'stable_first',
|
||||||
|
label: tr('稳定优先'),
|
||||||
|
description: tr('按优先级优先选择当前最稳、最快、成功率更高的通道,不做随机分流'),
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -228,7 +245,7 @@ function RouteCardInner({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
|
{getRoutingStrategyLabel(routingStrategy)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
|
|||||||
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||||
export type RouteSortDir = 'asc' | 'desc';
|
export type RouteSortDir = 'asc' | 'desc';
|
||||||
export type GroupFilter = null | '__all__' | number;
|
export type GroupFilter = null | '__all__' | number;
|
||||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||||
export type RouteRowKind = 'persisted' | 'zero_channel';
|
export type RouteRowKind = 'persisted' | 'zero_channel';
|
||||||
export type RouteMode = 'pattern' | 'explicit_group';
|
export type RouteMode = 'pattern' | 'explicit_group';
|
||||||
|
|
||||||
|
|||||||
@@ -127,4 +127,53 @@ describe('TokenRoutes routing strategy updates', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports switching to stable_first and keeps the optimistic label when refresh fails', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandButton = root.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
expandButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const stableFirstOption = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('modern-select-option')
|
||||||
|
&& collectText(node).includes('稳定优先')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stableFirstOption.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'stable_first' });
|
||||||
|
|
||||||
|
const strategyTrigger = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('modern-select-trigger')
|
||||||
|
&& collectText(node).includes('稳定优先')
|
||||||
|
));
|
||||||
|
expect(collectText(strategyTrigger)).toContain('稳定优先');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker/docker-compose.yml"
|
||||||
|
COMPOSE_OVERRIDE_FILE="$ROOT_DIR/docker/docker-compose.override.yml"
|
||||||
|
ENV_FILE="$ROOT_DIR/docker/.env"
|
||||||
|
DATA_DIR="$ROOT_DIR/docker/data"
|
||||||
|
|
||||||
|
DO_PULL=0
|
||||||
|
SKIP_BACKUP=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: ./update-and-restart.sh [--pull] [--skip-backup]
|
||||||
|
|
||||||
|
默认行为:
|
||||||
|
1. 保留现有 docker/data 数据
|
||||||
|
2. 先备份数据目录
|
||||||
|
3. 基于当前本地代码重新构建并重启容器
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
--pull 先拉取最新 Git 代码后再构建。若工作区有未提交修改会直接退出,避免覆盖本地改动
|
||||||
|
--skip-backup 跳过启动前数据备份
|
||||||
|
-h, --help 显示帮助
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
if [ ! -f "$1" ]; then
|
||||||
|
echo "Missing required file: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--pull)
|
||||||
|
DO_PULL=1
|
||||||
|
;;
|
||||||
|
--skip-backup)
|
||||||
|
SKIP_BACKUP=1
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
require_cmd docker
|
||||||
|
require_cmd tar
|
||||||
|
require_cmd git
|
||||||
|
require_file "$COMPOSE_FILE"
|
||||||
|
require_file "$COMPOSE_OVERRIDE_FILE"
|
||||||
|
require_file "$ENV_FILE"
|
||||||
|
|
||||||
|
COMPOSE_ARGS=(
|
||||||
|
-f "$COMPOSE_FILE"
|
||||||
|
-f "$COMPOSE_OVERRIDE_FILE"
|
||||||
|
--env-file "$ENV_FILE"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$DO_PULL" -eq 1 ]; then
|
||||||
|
echo "[0/5] Checking git worktree..."
|
||||||
|
if [ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]; then
|
||||||
|
echo "Git worktree has uncommitted changes. Commit/stash them first, or rerun without --pull to rebuild the current local code." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/5] Pulling latest git changes..."
|
||||||
|
git -C "$ROOT_DIR" pull --rebase
|
||||||
|
else
|
||||||
|
echo "[1/4] Using current local source code..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_FILE=""
|
||||||
|
if [ "$SKIP_BACKUP" -eq 0 ]; then
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
BACKUP_FILE="$ROOT_DIR/docker/data-backup-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||||
|
if [ "$DO_PULL" -eq 1 ]; then
|
||||||
|
echo "[2/5] Backing up data directory..."
|
||||||
|
else
|
||||||
|
echo "[2/4] Backing up data directory..."
|
||||||
|
fi
|
||||||
|
tar -czf "$BACKUP_FILE" -C "$DATA_DIR" .
|
||||||
|
else
|
||||||
|
if [ "$DO_PULL" -eq 1 ]; then
|
||||||
|
echo "[2/5] Skipping data backup by request..."
|
||||||
|
else
|
||||||
|
echo "[2/4] Skipping data backup by request..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DO_PULL" -eq 1 ]; then
|
||||||
|
echo "[3/5] Stopping current containers..."
|
||||||
|
else
|
||||||
|
echo "[3/4] Stopping current containers..."
|
||||||
|
fi
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" down --remove-orphans
|
||||||
|
|
||||||
|
if [ "$DO_PULL" -eq 1 ]; then
|
||||||
|
echo "[4/5] Rebuilding and starting containers..."
|
||||||
|
else
|
||||||
|
echo "[4/4] Rebuilding and starting containers..."
|
||||||
|
fi
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" up -d --build --force-recreate
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Container status:"
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" ps
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Recent logs:"
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" logs --tail 20
|
||||||
|
|
||||||
|
if [ -n "$BACKUP_FILE" ]; then
|
||||||
|
echo
|
||||||
|
echo "Data backup saved to: $BACKUP_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if PORT_MAPPING="$(docker compose "${COMPOSE_ARGS[@]}" port metapi 4000 2>/dev/null)"; then
|
||||||
|
echo
|
||||||
|
echo "Service is available at: $PORT_MAPPING"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user