fix: finish review-driven single-source cleanup
This commit is contained in:
@@ -25,7 +25,7 @@ describe('provider header utils', () => {
|
|||||||
).toBe('beta-a,beta-b,beta-c');
|
).toBe('beta-a,beta-b,beta-c');
|
||||||
expect(
|
expect(
|
||||||
mergeClaudeBetaHeader('custom-a,custom-b', 'beta-a,beta-b', ['beta-c']),
|
mergeClaudeBetaHeader('custom-a,custom-b', 'beta-a,beta-b', ['beta-c']),
|
||||||
).toBe('custom-a,custom-b');
|
).toBe('beta-a,beta-b,custom-a,custom-b,beta-c');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves gemini cli runtime metadata when rebuilding user agents', async () => {
|
it('preserves gemini cli runtime metadata when rebuilding user agents', async () => {
|
||||||
@@ -79,9 +79,12 @@ describe('provider header utils', () => {
|
|||||||
const headers = buildClaudeRuntimeHeaders({
|
const headers = buildClaudeRuntimeHeaders({
|
||||||
baseHeaders: {
|
baseHeaders: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
authorization: 'Bearer stale-token',
|
||||||
},
|
},
|
||||||
claudeHeaders: {
|
claudeHeaders: {
|
||||||
'anthropic-beta': 'custom-beta',
|
'anthropic-beta': 'custom-beta',
|
||||||
|
'x-api-key': 'stale-api-key',
|
||||||
|
'user-agent': 'custom-agent',
|
||||||
},
|
},
|
||||||
anthropicVersion: '2023-06-01',
|
anthropicVersion: '2023-06-01',
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -90,9 +93,13 @@ describe('provider header utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(headers['anthropic-version']).toBe('2023-06-01');
|
expect(headers['anthropic-version']).toBe('2023-06-01');
|
||||||
expect(headers['anthropic-beta']).toBe('custom-beta');
|
expect(headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||||
|
expect(headers['anthropic-beta']).toContain('custom-beta');
|
||||||
expect(headers.Authorization).toBe('Bearer oauth-token');
|
expect(headers.Authorization).toBe('Bearer oauth-token');
|
||||||
|
expect(headers.authorization).toBeUndefined();
|
||||||
expect(headers['x-api-key']).toBeUndefined();
|
expect(headers['x-api-key']).toBeUndefined();
|
||||||
|
expect(headers['user-agent']).toBeUndefined();
|
||||||
|
expect(headers['User-Agent']).toBe('custom-agent');
|
||||||
expect(headers.Accept).toBe('text/event-stream');
|
expect(headers.Accept).toBe('text/event-stream');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ export function getInputHeader(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLowerCaseHeaderMap(
|
||||||
|
sources: Array<Record<string, unknown> | Record<string, string> | undefined>,
|
||||||
|
shouldSkip: (key: string) => boolean,
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
const normalizedValue = headerValueToString(value);
|
||||||
|
if (!normalizedValue) continue;
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
if (shouldSkip(normalizedKey)) continue;
|
||||||
|
normalized[normalizedKey] = normalizedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function uuidFromSeed(seed: string): string {
|
export function uuidFromSeed(seed: string): string {
|
||||||
const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256');
|
const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256');
|
||||||
const bytes = new Uint8Array(derived);
|
const bytes = new Uint8Array(derived);
|
||||||
@@ -52,22 +70,15 @@ export function mergeClaudeBetaHeader(
|
|||||||
defaultValue: string,
|
defaultValue: string,
|
||||||
extraBetas: string[] = [],
|
extraBetas: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
const source = explicitValue || defaultValue;
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: string[] = [];
|
const merged: string[] = [];
|
||||||
|
for (const source of [defaultValue, explicitValue ?? '', ...extraBetas]) {
|
||||||
for (const entry of source.split(',')) {
|
for (const entry of source.split(',')) {
|
||||||
const normalized = entry.trim();
|
const normalized = entry.trim();
|
||||||
if (!normalized || seen.has(normalized)) continue;
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
seen.add(normalized);
|
seen.add(normalized);
|
||||||
merged.push(normalized);
|
merged.push(normalized);
|
||||||
}
|
}
|
||||||
if (!explicitValue) {
|
|
||||||
for (const entry of extraBetas) {
|
|
||||||
const normalized = entry.trim();
|
|
||||||
if (!normalized || seen.has(normalized)) continue;
|
|
||||||
seen.add(normalized);
|
|
||||||
merged.push(normalized);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return merged.join(',');
|
return merged.join(',');
|
||||||
}
|
}
|
||||||
@@ -187,9 +198,24 @@ export function buildClaudeRuntimeHeaders(input: {
|
|||||||
input.defaultBetaHeader || CLAUDE_DEFAULT_BETA_HEADER,
|
input.defaultBetaHeader || CLAUDE_DEFAULT_BETA_HEADER,
|
||||||
input.extraBetas,
|
input.extraBetas,
|
||||||
);
|
);
|
||||||
|
const passthroughHeaders = normalizeLowerCaseHeaderMap(
|
||||||
|
[input.baseHeaders, input.claudeHeaders],
|
||||||
|
(key) => (
|
||||||
|
key === 'accept'
|
||||||
|
|| key === 'accept-encoding'
|
||||||
|
|| key === 'anthropic-beta'
|
||||||
|
|| key === 'anthropic-dangerous-direct-browser-access'
|
||||||
|
|| key === 'anthropic-version'
|
||||||
|
|| key === 'authorization'
|
||||||
|
|| key === 'connection'
|
||||||
|
|| key === 'user-agent'
|
||||||
|
|| key === 'x-api-key'
|
||||||
|
|| key === 'x-app'
|
||||||
|
|| key.startsWith('x-stainless-')
|
||||||
|
),
|
||||||
|
);
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
...input.baseHeaders,
|
...passthroughHeaders,
|
||||||
...input.claudeHeaders,
|
|
||||||
'anthropic-version': input.anthropicVersion,
|
'anthropic-version': input.anthropicVersion,
|
||||||
...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}),
|
...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}),
|
||||||
'Anthropic-Dangerous-Direct-Browser-Access': 'true',
|
'Anthropic-Dangerous-Direct-Browser-Access': 'true',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { TextDecoder } from 'node:util';
|
|||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +18,6 @@ import {
|
|||||||
} from '../../routes/proxy/downstreamPolicy.js';
|
} from '../../routes/proxy/downstreamPolicy.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||||
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
|
||||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||||
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
||||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
@@ -32,7 +29,6 @@ import {
|
|||||||
buildOauthProviderHeaders,
|
buildOauthProviderHeaders,
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
|
||||||
import {
|
import {
|
||||||
collectResponsesFinalPayloadFromSse,
|
collectResponsesFinalPayloadFromSse,
|
||||||
collectResponsesFinalPayloadFromSseText,
|
collectResponsesFinalPayloadFromSseText,
|
||||||
@@ -48,7 +44,9 @@ import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClie
|
|||||||
import {
|
import {
|
||||||
createSurfaceFailureToolkit,
|
createSurfaceFailureToolkit,
|
||||||
createSurfaceDispatchRequest,
|
createSurfaceDispatchRequest,
|
||||||
|
recordSurfaceSuccess,
|
||||||
selectSurfaceChannelForAttempt,
|
selectSurfaceChannelForAttempt,
|
||||||
|
trySurfaceOauthRefreshRecovery,
|
||||||
} from './sharedSurface.js';
|
} from './sharedSurface.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
@@ -224,28 +222,15 @@ export async function handleChatSurfaceRequest(
|
|||||||
});
|
});
|
||||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||||
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
||||||
try {
|
const recovered = await trySurfaceOauthRefreshRecovery({
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
ctx,
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected,
|
||||||
selected.account = {
|
siteUrl: selected.site.url,
|
||||||
...selected.account,
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
accessToken: refreshed.accessToken,
|
dispatchRequest,
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
});
|
||||||
};
|
if (recovered?.upstream?.ok) {
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
return recovered;
|
||||||
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
|
|
||||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
|
||||||
if (refreshedResponse.ok) {
|
|
||||||
return {
|
|
||||||
upstream: refreshedResponse,
|
|
||||||
upstreamPath: refreshedRequest.path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ctx.request = refreshedRequest;
|
|
||||||
ctx.response = refreshedResponse;
|
|
||||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
|
||||||
} catch {
|
|
||||||
return endpointStrategy.tryRecover(ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return endpointStrategy.tryRecover(ctx);
|
return endpointStrategy.tryRecover(ctx);
|
||||||
@@ -477,46 +462,19 @@ export async function handleChatSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
selected,
|
||||||
account: selected.account,
|
requestedModel,
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
tokenName: selected.tokenName,
|
|
||||||
modelName,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
requestEndedAtMs: startTime + latency,
|
|
||||||
localLatencyMs: latency,
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName,
|
modelName,
|
||||||
parsedUsage,
|
parsedUsage,
|
||||||
resolvedUsage,
|
requestStartedAtMs: startTime,
|
||||||
});
|
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
await failureToolkit.log({
|
|
||||||
selected,
|
|
||||||
modelRequested: requestedModel,
|
|
||||||
status: 'success',
|
|
||||||
httpStatus: 200,
|
|
||||||
latencyMs: latency,
|
latencyMs: latency,
|
||||||
errorMessage: null,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
estimatedCost,
|
|
||||||
billingDetails,
|
|
||||||
upstreamPath: successfulUpstreamPath,
|
upstreamPath: successfulUpstreamPath,
|
||||||
|
logSuccess: failureToolkit.log,
|
||||||
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -570,46 +528,19 @@ export async function handleChatSurfaceRequest(
|
|||||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
||||||
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
||||||
|
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
selected,
|
||||||
account: selected.account,
|
requestedModel,
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
tokenName: selected.tokenName,
|
|
||||||
modelName,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
requestEndedAtMs: startTime + latency,
|
|
||||||
localLatencyMs: latency,
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName,
|
modelName,
|
||||||
parsedUsage,
|
parsedUsage,
|
||||||
resolvedUsage,
|
requestStartedAtMs: startTime,
|
||||||
});
|
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
await failureToolkit.log({
|
|
||||||
selected,
|
|
||||||
modelRequested: requestedModel,
|
|
||||||
status: 'success',
|
|
||||||
httpStatus: 200,
|
|
||||||
latencyMs: latency,
|
latencyMs: latency,
|
||||||
errorMessage: null,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
estimatedCost,
|
|
||||||
billingDetails,
|
|
||||||
upstreamPath: successfulUpstreamPath,
|
upstreamPath: successfulUpstreamPath,
|
||||||
|
logSuccess: failureToolkit.log,
|
||||||
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.send(downstreamResponse);
|
return reply.send(downstreamResponse);
|
||||||
@@ -768,18 +699,25 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
let upstream = await dispatchRequest(upstreamRequest);
|
let upstream = await dispatchRequest(upstreamRequest);
|
||||||
|
|
||||||
if ((upstream.status === 401 || upstream.status === 403) && oauth) {
|
if ((upstream.status === 401 || upstream.status === 403) && oauth) {
|
||||||
try {
|
const recoverContext = {
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
request: upstreamRequest,
|
||||||
selected.tokenValue = refreshed.accessToken;
|
response: upstream,
|
||||||
selected.account = {
|
rawErrText: '',
|
||||||
...selected.account,
|
|
||||||
accessToken: refreshed.accessToken,
|
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
|
||||||
};
|
};
|
||||||
|
const recovered = await trySurfaceOauthRefreshRecovery({
|
||||||
|
ctx: recoverContext,
|
||||||
|
selected,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
|
buildRequest: () => buildRequest(),
|
||||||
|
dispatchRequest,
|
||||||
|
captureFailureBody: false,
|
||||||
|
});
|
||||||
|
if (recovered?.upstream?.ok) {
|
||||||
upstreamRequest = buildRequest();
|
upstreamRequest = buildRequest();
|
||||||
upstream = await dispatchRequest(upstreamRequest);
|
upstream = recovered.upstream;
|
||||||
} catch {
|
} else {
|
||||||
// Fall through to the regular upstream error handling below.
|
upstreamRequest = recoverContext.request;
|
||||||
|
upstream = recoverContext.response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
|
||||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -14,8 +12,6 @@ import {
|
|||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||||
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
|
||||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +22,6 @@ import {
|
|||||||
buildOauthProviderHeaders,
|
buildOauthProviderHeaders,
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
|
||||||
import {
|
import {
|
||||||
collectResponsesFinalPayloadFromSse,
|
collectResponsesFinalPayloadFromSse,
|
||||||
collectResponsesFinalPayloadFromSseText,
|
collectResponsesFinalPayloadFromSseText,
|
||||||
@@ -46,7 +41,9 @@ import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClie
|
|||||||
import {
|
import {
|
||||||
createSurfaceFailureToolkit,
|
createSurfaceFailureToolkit,
|
||||||
createSurfaceDispatchRequest,
|
createSurfaceDispatchRequest,
|
||||||
|
recordSurfaceSuccess,
|
||||||
selectSurfaceChannelForAttempt,
|
selectSurfaceChannelForAttempt,
|
||||||
|
trySurfaceOauthRefreshRecovery,
|
||||||
} from './sharedSurface.js';
|
} from './sharedSurface.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
@@ -325,28 +322,15 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
response: ctx.response,
|
response: ctx.response,
|
||||||
rawErrText: ctx.rawErrText || '',
|
rawErrText: ctx.rawErrText || '',
|
||||||
})) {
|
})) {
|
||||||
try {
|
const recovered = await trySurfaceOauthRefreshRecovery({
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
ctx,
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected,
|
||||||
selected.account = {
|
siteUrl: selected.site.url,
|
||||||
...selected.account,
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
accessToken: refreshed.accessToken,
|
dispatchRequest,
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
});
|
||||||
};
|
if (recovered?.upstream?.ok) {
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
return recovered;
|
||||||
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
|
|
||||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
|
||||||
if (refreshedResponse.ok) {
|
|
||||||
return {
|
|
||||||
upstream: refreshedResponse,
|
|
||||||
upstreamPath: refreshedRequest.path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ctx.request = refreshedRequest;
|
|
||||||
ctx.response = refreshedResponse;
|
|
||||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
|
||||||
} catch {
|
|
||||||
return endpointStrategy.tryRecover(ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return endpointStrategy.tryRecover(ctx);
|
return endpointStrategy.tryRecover(ctx);
|
||||||
@@ -410,65 +394,23 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const upstream = endpointResult.upstream;
|
const upstream = endpointResult.upstream;
|
||||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||||
const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => {
|
const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => {
|
||||||
let usageForLog = {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
};
|
|
||||||
let estimatedCost = 0;
|
|
||||||
let billingDetails: unknown = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
tokenName: selected.tokenName,
|
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
requestEndedAtMs: startTime + latency,
|
|
||||||
localLatencyMs: latency,
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
usageForLog = {
|
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
};
|
|
||||||
const billing = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
parsedUsage,
|
|
||||||
resolvedUsage,
|
|
||||||
});
|
|
||||||
estimatedCost = billing.estimatedCost;
|
|
||||||
billingDetails = billing.billingDetails;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[responses] post-stream bookkeeping failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
await failureToolkit.log({
|
|
||||||
selected,
|
selected,
|
||||||
modelRequested: requestedModel,
|
requestedModel,
|
||||||
status: 'success',
|
modelName,
|
||||||
httpStatus: 200,
|
parsedUsage,
|
||||||
|
requestStartedAtMs: startTime,
|
||||||
latencyMs: latency,
|
latencyMs: latency,
|
||||||
errorMessage: null,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
promptTokens: usageForLog.promptTokens,
|
|
||||||
completionTokens: usageForLog.completionTokens,
|
|
||||||
totalTokens: usageForLog.totalTokens,
|
|
||||||
estimatedCost,
|
|
||||||
billingDetails,
|
|
||||||
upstreamPath: successfulUpstreamPath,
|
upstreamPath: successfulUpstreamPath,
|
||||||
|
logSuccess: failureToolkit.log,
|
||||||
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
},
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[responses] post-stream bookkeeping failed:',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[responses] post-stream success logging failed:', error);
|
console.error('[responses] post-stream success logging failed:', error);
|
||||||
@@ -716,48 +658,25 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
serializationMode: isCompactRequest ? 'compact' : 'response',
|
serializationMode: isCompactRequest ? 'compact' : 'response',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
selected,
|
||||||
account: selected.account,
|
requestedModel,
|
||||||
tokenValue: selected.tokenValue,
|
modelName,
|
||||||
tokenName: selected.tokenName,
|
parsedUsage,
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
requestStartedAtMs: startTime,
|
requestStartedAtMs: startTime,
|
||||||
requestEndedAtMs: startTime + latency,
|
latencyMs: latency,
|
||||||
localLatencyMs: latency,
|
retryCount,
|
||||||
usage: {
|
upstreamPath: successfulUpstreamPath,
|
||||||
promptTokens: parsedUsage.promptTokens,
|
logSuccess: failureToolkit.log,
|
||||||
completionTokens: parsedUsage.completionTokens,
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
totalTokens: parsedUsage.totalTokens,
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
},
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[responses] post-response bookkeeping failed:',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
parsedUsage,
|
|
||||||
resolvedUsage,
|
|
||||||
});
|
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
await failureToolkit.log({
|
|
||||||
selected,
|
|
||||||
modelRequested: requestedModel,
|
|
||||||
status: 'success',
|
|
||||||
httpStatus: 200,
|
|
||||||
latencyMs: latency,
|
|
||||||
errorMessage: null,
|
|
||||||
retryCount,
|
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
estimatedCost,
|
|
||||||
billingDetails,
|
|
||||||
upstreamPath: successfulUpstreamPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[responses] post-response bookkeeping failed:', error);
|
console.error('[responses] post-response success logging failed:', error);
|
||||||
}
|
}
|
||||||
return reply.send(downstreamData);
|
return reply.send(downstreamData);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicyTypes.js';
|
import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicyTypes.js';
|
||||||
|
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
@@ -16,12 +16,18 @@ const reportTokenExpiredMock = vi.fn();
|
|||||||
const isTokenExpiredErrorMock = vi.fn();
|
const isTokenExpiredErrorMock = vi.fn();
|
||||||
const shouldRetryProxyRequestMock = vi.fn();
|
const shouldRetryProxyRequestMock = vi.fn();
|
||||||
const recordOauthQuotaResetHintMock = vi.fn();
|
const recordOauthQuotaResetHintMock = vi.fn();
|
||||||
|
const recordSuccessMock = vi.fn();
|
||||||
|
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
|
||||||
|
const resolveProxyLogBillingMock = vi.fn();
|
||||||
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
|
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||||
|
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -67,7 +73,23 @@ vi.mock('../../services/oauth/quota.js', () => ({
|
|||||||
recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args),
|
recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
||||||
|
resolveProxyUsageWithSelfLogFallback: (...args: unknown[]) => resolveProxyUsageWithSelfLogFallbackMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../routes/proxy/proxyBilling.js', () => ({
|
||||||
|
resolveProxyLogBilling: (...args: unknown[]) => resolveProxyLogBillingMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||||
|
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('selectSurfaceChannelForAttempt', () => {
|
describe('selectSurfaceChannelForAttempt', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
consoleWarnMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
selectChannelMock.mockReset();
|
selectChannelMock.mockReset();
|
||||||
selectNextChannelMock.mockReset();
|
selectNextChannelMock.mockReset();
|
||||||
@@ -84,6 +106,11 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
isTokenExpiredErrorMock.mockReset();
|
isTokenExpiredErrorMock.mockReset();
|
||||||
shouldRetryProxyRequestMock.mockReset();
|
shouldRetryProxyRequestMock.mockReset();
|
||||||
recordOauthQuotaResetHintMock.mockReset();
|
recordOauthQuotaResetHintMock.mockReset();
|
||||||
|
recordSuccessMock.mockReset();
|
||||||
|
resolveProxyUsageWithSelfLogFallbackMock.mockReset();
|
||||||
|
resolveProxyLogBillingMock.mockReset();
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||||
|
consoleWarnMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => {
|
it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => {
|
||||||
@@ -308,6 +335,40 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps retryable failures on the retry path even when quota hint recording fails', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
insertProxyLogMock.mockResolvedValue(undefined);
|
||||||
|
shouldRetryProxyRequestMock.mockReturnValue(true);
|
||||||
|
isTokenExpiredErrorMock.mockReturnValue(false);
|
||||||
|
recordOauthQuotaResetHintMock.mockRejectedValue(new Error('hint failed'));
|
||||||
|
|
||||||
|
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||||
|
const toolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'chat',
|
||||||
|
downstreamPath: '/v1/chat/completions',
|
||||||
|
maxRetries: 2,
|
||||||
|
clientContext: null,
|
||||||
|
downstreamApiKeyId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(toolkit.handleUpstreamFailure({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { name: 'Codex OAuth' },
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
status: 429,
|
||||||
|
errText: 'quota exceeded',
|
||||||
|
rawErrText: '{"error":"quota exceeded"}',
|
||||||
|
latencyMs: 1200,
|
||||||
|
retryCount: 0,
|
||||||
|
})).resolves.toEqual({ action: 'retry' });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a terminal upstream error response and reports token expiration when retries stop', async () => {
|
it('returns a terminal upstream error response and reports token expiration when retries stop', async () => {
|
||||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
@@ -363,6 +424,50 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns terminal failures even when final alerting throws', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
insertProxyLogMock.mockResolvedValue(undefined);
|
||||||
|
shouldRetryProxyRequestMock.mockReturnValue(false);
|
||||||
|
isTokenExpiredErrorMock.mockReturnValue(true);
|
||||||
|
recordOauthQuotaResetHintMock.mockResolvedValue(null);
|
||||||
|
reportTokenExpiredMock.mockRejectedValue(new Error('token alert failed'));
|
||||||
|
|
||||||
|
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||||
|
const toolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'responses',
|
||||||
|
downstreamPath: '/v1/responses',
|
||||||
|
maxRetries: 2,
|
||||||
|
clientContext: null,
|
||||||
|
downstreamApiKeyId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(toolkit.handleUpstreamFailure({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { name: 'Codex OAuth' },
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
status: 401,
|
||||||
|
errText: 'expired token',
|
||||||
|
rawErrText: 'expired token',
|
||||||
|
latencyMs: 900,
|
||||||
|
retryCount: 2,
|
||||||
|
})).resolves.toEqual({
|
||||||
|
action: 'respond',
|
||||||
|
status: 401,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: 'expired token',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('handles detected proxy failures through the shared failure toolkit', async () => {
|
it('handles detected proxy failures through the shared failure toolkit', async () => {
|
||||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
@@ -468,4 +573,255 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
reason: 'socket hang up',
|
reason: 'socket hang up',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records stream failures with error text even without a runtime status code', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
insertProxyLogMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||||
|
const toolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'responses',
|
||||||
|
downstreamPath: '/v1/responses',
|
||||||
|
maxRetries: 2,
|
||||||
|
clientContext: null,
|
||||||
|
downstreamApiKeyId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await toolkit.recordStreamFailure({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { name: 'Codex OAuth' },
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
errorMessage: 'stream exploded',
|
||||||
|
latencyMs: 450,
|
||||||
|
retryCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordFailureMock).toHaveBeenCalledWith(11, {
|
||||||
|
errorText: 'stream exploded',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes oauth tokens through the shared recover helper and retries the rebuilt request', async () => {
|
||||||
|
const refreshedResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: vi.fn(),
|
||||||
|
};
|
||||||
|
const selected = {
|
||||||
|
account: {
|
||||||
|
id: 33,
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh"}}',
|
||||||
|
},
|
||||||
|
tokenValue: 'old-access-token',
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
request: {
|
||||||
|
endpoint: 'responses' as const,
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: 'Bearer old-access-token' },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: vi.fn(),
|
||||||
|
},
|
||||||
|
rawErrText: 'expired token',
|
||||||
|
};
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}',
|
||||||
|
});
|
||||||
|
const dispatchRequest = vi.fn().mockResolvedValue(refreshedResponse);
|
||||||
|
|
||||||
|
const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js');
|
||||||
|
const result = await trySurfaceOauthRefreshRecovery({
|
||||||
|
ctx,
|
||||||
|
selected,
|
||||||
|
siteUrl: 'https://upstream.example.com',
|
||||||
|
buildRequest: () => ({
|
||||||
|
endpoint: 'responses',
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: `Bearer ${selected.tokenValue}` },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
}),
|
||||||
|
dispatchRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
|
||||||
|
expect(selected.tokenValue).toBe('new-access-token');
|
||||||
|
expect(selected.account.accessToken).toBe('new-access-token');
|
||||||
|
expect(selected.account.extraConfig).toBe('{"oauth":{"refreshToken":"refresh-next"}}');
|
||||||
|
expect(dispatchRequest).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
headers: { authorization: 'Bearer new-access-token' },
|
||||||
|
}), 'https://upstream.example.com/v1/responses');
|
||||||
|
expect(result).toEqual({
|
||||||
|
upstream: refreshedResponse,
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the recover context with the refreshed failure response when oauth refresh retry still fails', async () => {
|
||||||
|
const refreshedResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
text: vi.fn().mockResolvedValue('account mismatch'),
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
request: {
|
||||||
|
endpoint: 'responses' as const,
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: 'Bearer old-access-token' },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: vi.fn(),
|
||||||
|
},
|
||||||
|
rawErrText: 'expired token',
|
||||||
|
};
|
||||||
|
const selected = {
|
||||||
|
account: {
|
||||||
|
id: 33,
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh"}}',
|
||||||
|
},
|
||||||
|
tokenValue: 'old-access-token',
|
||||||
|
};
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js');
|
||||||
|
const result = await trySurfaceOauthRefreshRecovery({
|
||||||
|
ctx,
|
||||||
|
selected,
|
||||||
|
siteUrl: 'https://upstream.example.com',
|
||||||
|
buildRequest: () => ({
|
||||||
|
endpoint: 'responses',
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: `Bearer ${selected.tokenValue}` },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
}),
|
||||||
|
dispatchRequest: vi.fn().mockResolvedValue(refreshedResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(ctx.request.headers).toEqual({ authorization: 'Bearer new-access-token' });
|
||||||
|
expect(ctx.response).toBe(refreshedResponse);
|
||||||
|
expect(ctx.rawErrText).toBe('account mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records shared success bookkeeping with usage fallback, billing, and success logging', async () => {
|
||||||
|
resolveProxyUsageWithSelfLogFallbackMock.mockResolvedValue({
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
});
|
||||||
|
resolveProxyLogBillingMock.mockResolvedValue({
|
||||||
|
estimatedCost: 0.42,
|
||||||
|
billingDetails: { source: 'pricing-test' },
|
||||||
|
});
|
||||||
|
const logSuccess = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const recordDownstreamCost = vi.fn();
|
||||||
|
|
||||||
|
const { recordSurfaceSuccess } = await import('./sharedSurface.js');
|
||||||
|
const result = await recordSurfaceSuccess({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
parsedUsage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
requestStartedAtMs: 1000,
|
||||||
|
latencyMs: 250,
|
||||||
|
retryCount: 1,
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
logSuccess,
|
||||||
|
recordDownstreamCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveProxyUsageWithSelfLogFallbackMock).toHaveBeenCalledWith({
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
requestStartedAtMs: 1000,
|
||||||
|
requestEndedAtMs: 1250,
|
||||||
|
localLatencyMs: 250,
|
||||||
|
usage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resolveProxyLogBillingMock).toHaveBeenCalledWith({
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
parsedUsage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
resolvedUsage: {
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0.42, 'upstream-model');
|
||||||
|
expect(recordDownstreamCost).toHaveBeenCalledWith(0.42);
|
||||||
|
expect(logSuccess).toHaveBeenCalledWith({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
modelRequested: 'gpt-5.2',
|
||||||
|
status: 'success',
|
||||||
|
httpStatus: 200,
|
||||||
|
latencyMs: 250,
|
||||||
|
errorMessage: null,
|
||||||
|
retryCount: 1,
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
estimatedCost: 0.42,
|
||||||
|
billingDetails: { source: 'pricing-test' },
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
resolvedUsage: {
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
},
|
||||||
|
estimatedCost: 0.42,
|
||||||
|
billingDetails: { source: 'pricing-test' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
|
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||||
import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||||
import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
|
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||||
|
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||||
|
|
||||||
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||||
type SurfaceWarningScope = 'chat' | 'responses';
|
type SurfaceWarningScope = 'chat' | 'responses';
|
||||||
@@ -39,6 +43,34 @@ type SurfaceFailureOutcome =
|
|||||||
| { action: 'retry' }
|
| { action: 'retry' }
|
||||||
| SurfaceFailureResponse;
|
| SurfaceFailureResponse;
|
||||||
|
|
||||||
|
type SurfaceOauthRefreshSelectedChannel = {
|
||||||
|
account: {
|
||||||
|
id: number;
|
||||||
|
accessToken?: string | null;
|
||||||
|
extraConfig?: string | null;
|
||||||
|
};
|
||||||
|
tokenValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceOauthRefreshContext<TRequest extends BuiltEndpointRequest> = {
|
||||||
|
request: TRequest;
|
||||||
|
response: Awaited<ReturnType<typeof dispatchRuntimeRequest>>;
|
||||||
|
rawErrText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceSuccessSelectedChannel = SurfaceSelectedChannel & {
|
||||||
|
account: Record<string, unknown> & { id: number };
|
||||||
|
site: Record<string, unknown>;
|
||||||
|
tokenValue: string;
|
||||||
|
tokenName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceUsageSummary = {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function selectSurfaceChannelForAttempt(input: {
|
export async function selectSurfaceChannelForAttempt(input: {
|
||||||
requestedModel: string;
|
requestedModel: string;
|
||||||
downstreamPolicy: DownstreamRoutingPolicy;
|
downstreamPolicy: DownstreamRoutingPolicy;
|
||||||
@@ -146,6 +178,150 @@ export function createSurfaceDispatchRequest(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function trySurfaceOauthRefreshRecovery<TRequest extends BuiltEndpointRequest>(input: {
|
||||||
|
ctx: SurfaceOauthRefreshContext<TRequest>;
|
||||||
|
selected: SurfaceOauthRefreshSelectedChannel;
|
||||||
|
siteUrl: string;
|
||||||
|
buildRequest: (endpoint: TRequest['endpoint']) => TRequest;
|
||||||
|
dispatchRequest: (
|
||||||
|
request: TRequest,
|
||||||
|
targetUrl: string,
|
||||||
|
) => Promise<Awaited<ReturnType<typeof dispatchRuntimeRequest>>>;
|
||||||
|
captureFailureBody?: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
upstream: Awaited<ReturnType<typeof dispatchRuntimeRequest>>;
|
||||||
|
upstreamPath: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshOauthAccessTokenSingleflight(input.selected.account.id);
|
||||||
|
input.selected.tokenValue = refreshed.accessToken;
|
||||||
|
input.selected.account = {
|
||||||
|
...input.selected.account,
|
||||||
|
accessToken: refreshed.accessToken,
|
||||||
|
extraConfig: refreshed.extraConfig ?? input.selected.account.extraConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshedRequest = input.buildRequest(input.ctx.request.endpoint);
|
||||||
|
const refreshedTargetUrl = buildUpstreamUrl(input.siteUrl, refreshedRequest.path);
|
||||||
|
const refreshedResponse = await input.dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||||
|
if (refreshedResponse.ok) {
|
||||||
|
return {
|
||||||
|
upstream: refreshedResponse,
|
||||||
|
upstreamPath: refreshedRequest.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
input.ctx.request = refreshedRequest;
|
||||||
|
input.ctx.response = refreshedResponse;
|
||||||
|
if (input.captureFailureBody !== false) {
|
||||||
|
input.ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordSurfaceSuccess(input: {
|
||||||
|
selected: SurfaceSuccessSelectedChannel;
|
||||||
|
requestedModel: string;
|
||||||
|
modelName: string;
|
||||||
|
parsedUsage: SurfaceUsageSummary;
|
||||||
|
requestStartedAtMs: number;
|
||||||
|
latencyMs: number;
|
||||||
|
retryCount: number;
|
||||||
|
upstreamPath?: string | null;
|
||||||
|
logSuccess: (args: {
|
||||||
|
selected: SurfaceSelectedChannel;
|
||||||
|
modelRequested: string;
|
||||||
|
status: string;
|
||||||
|
httpStatus: number;
|
||||||
|
latencyMs: number;
|
||||||
|
errorMessage: string | null;
|
||||||
|
retryCount: number;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
estimatedCost?: number;
|
||||||
|
billingDetails?: unknown;
|
||||||
|
upstreamPath?: string | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
recordDownstreamCost?: (estimatedCost: number) => void;
|
||||||
|
bestEffortMetrics?: {
|
||||||
|
errorLabel: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
let resolvedUsage = {
|
||||||
|
promptTokens: input.parsedUsage.promptTokens,
|
||||||
|
completionTokens: input.parsedUsage.completionTokens,
|
||||||
|
totalTokens: input.parsedUsage.totalTokens,
|
||||||
|
};
|
||||||
|
let estimatedCost = 0;
|
||||||
|
let billingDetails: unknown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||||
|
site: input.selected.site,
|
||||||
|
account: input.selected.account,
|
||||||
|
tokenValue: input.selected.tokenValue,
|
||||||
|
tokenName: input.selected.tokenName,
|
||||||
|
modelName: input.modelName,
|
||||||
|
requestStartedAtMs: input.requestStartedAtMs,
|
||||||
|
requestEndedAtMs: input.requestStartedAtMs + input.latencyMs,
|
||||||
|
localLatencyMs: input.latencyMs,
|
||||||
|
usage: {
|
||||||
|
promptTokens: input.parsedUsage.promptTokens,
|
||||||
|
completionTokens: input.parsedUsage.completionTokens,
|
||||||
|
totalTokens: input.parsedUsage.totalTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const billing = await resolveProxyLogBilling({
|
||||||
|
site: input.selected.site,
|
||||||
|
account: input.selected.account,
|
||||||
|
modelName: input.modelName,
|
||||||
|
parsedUsage: input.parsedUsage,
|
||||||
|
resolvedUsage,
|
||||||
|
});
|
||||||
|
estimatedCost = billing.estimatedCost;
|
||||||
|
billingDetails = billing.billingDetails;
|
||||||
|
} catch (error) {
|
||||||
|
if (!input.bestEffortMetrics) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.error(input.bestEffortMetrics.errorLabel, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenRouter.recordSuccess(
|
||||||
|
input.selected.channel.id,
|
||||||
|
input.latencyMs,
|
||||||
|
estimatedCost,
|
||||||
|
input.modelName,
|
||||||
|
);
|
||||||
|
input.recordDownstreamCost?.(estimatedCost);
|
||||||
|
await input.logSuccess({
|
||||||
|
selected: input.selected,
|
||||||
|
modelRequested: input.requestedModel,
|
||||||
|
status: 'success',
|
||||||
|
httpStatus: 200,
|
||||||
|
latencyMs: input.latencyMs,
|
||||||
|
errorMessage: null,
|
||||||
|
retryCount: input.retryCount,
|
||||||
|
promptTokens: resolvedUsage.promptTokens,
|
||||||
|
completionTokens: resolvedUsage.completionTokens,
|
||||||
|
totalTokens: resolvedUsage.totalTokens,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
upstreamPath: input.upstreamPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolvedUsage,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createSurfaceFailureToolkit(input: {
|
export function createSurfaceFailureToolkit(input: {
|
||||||
warningScope: SurfaceWarningScope;
|
warningScope: SurfaceWarningScope;
|
||||||
downstreamPath: string;
|
downstreamPath: string;
|
||||||
@@ -193,6 +369,14 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
? { action: 'retry' as const }
|
? { action: 'retry' as const }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const runBestEffort = (label: string, fn: () => Promise<unknown>) => {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(fn)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(`[proxy/${input.warningScope}] failed to ${label}`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
log,
|
log,
|
||||||
async handleUpstreamFailure(args: {
|
async handleUpstreamFailure(args: {
|
||||||
@@ -220,19 +404,19 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
errorMessage: args.errText,
|
errorMessage: args.errText,
|
||||||
retryCount: args.retryCount,
|
retryCount: args.retryCount,
|
||||||
});
|
});
|
||||||
await recordOauthQuotaResetHint({
|
runBestEffort('record oauth quota reset hint', () => recordOauthQuotaResetHint({
|
||||||
accountId: args.selected.account.id,
|
accountId: args.selected.account.id,
|
||||||
statusCode: args.status,
|
statusCode: args.status,
|
||||||
errorText: rawErrText,
|
errorText: rawErrText,
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (isTokenExpiredError({ status: args.status, message: args.errText })) {
|
if (isTokenExpiredError({ status: args.status, message: args.errText })) {
|
||||||
await reportTokenExpired({
|
runBestEffort('report token expired', () => reportTokenExpired({
|
||||||
accountId: args.selected.account.id,
|
accountId: args.selected.account.id,
|
||||||
username: args.selected.account.username,
|
username: args.selected.account.username,
|
||||||
siteName: args.selected.site.name,
|
siteName: args.selected.site.name,
|
||||||
detail: `HTTP ${args.status}`,
|
detail: `HTTP ${args.status}`,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(args.status, args.errText)) {
|
if (shouldRetryProxyRequest(args.status, args.errText)) {
|
||||||
@@ -240,10 +424,10 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
if (retry) return retry;
|
if (retry) return retry;
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportProxyAllFailed({
|
runBestEffort('report proxy all failed', () => reportProxyAllFailed({
|
||||||
model: args.requestedModel,
|
model: args.requestedModel,
|
||||||
reason: `upstream returned HTTP ${args.status}`,
|
reason: `upstream returned HTTP ${args.status}`,
|
||||||
});
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: 'respond',
|
action: 'respond',
|
||||||
@@ -293,10 +477,10 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
if (retry) return retry;
|
if (retry) return retry;
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportProxyAllFailed({
|
runBestEffort('report proxy all failed', () => reportProxyAllFailed({
|
||||||
model: args.requestedModel,
|
model: args.requestedModel,
|
||||||
reason: args.failure.reason,
|
reason: args.failure.reason,
|
||||||
});
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: 'respond',
|
action: 'respond',
|
||||||
@@ -335,10 +519,10 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
const retry = maybeRetry(args.retryCount);
|
const retry = maybeRetry(args.retryCount);
|
||||||
if (retry) return retry;
|
if (retry) return retry;
|
||||||
|
|
||||||
await reportProxyAllFailed({
|
runBestEffort('report proxy all failed', () => reportProxyAllFailed({
|
||||||
model: args.requestedModel,
|
model: args.requestedModel,
|
||||||
reason: args.errorMessage || 'network failure',
|
reason: args.errorMessage || 'network failure',
|
||||||
});
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: 'respond',
|
action: 'respond',
|
||||||
@@ -374,7 +558,10 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
modelName: args.modelName,
|
modelName: args.modelName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await tokenRouter.recordFailure(args.selected.channel.id, args.modelName);
|
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||||
|
errorText: errorMessage,
|
||||||
|
modelName: args.modelName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await log({
|
await log({
|
||||||
selected: args.selected,
|
selected: args.selected,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('account token route architecture boundaries', () => {
|
||||||
|
it('keeps legacy token repair from forcing route rebuilds inline', () => {
|
||||||
|
const source = readSource('./accountTokens.ts');
|
||||||
|
expect(source).not.toContain('rebuildRoutes: true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,7 +233,6 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
|||||||
accountId,
|
accountId,
|
||||||
preferredApiToken: row.accounts.apiToken,
|
preferredApiToken: row.accounts.apiToken,
|
||||||
defaultTokenSource: 'legacy',
|
defaultTokenSource: 'legacy',
|
||||||
rebuildRoutes: true,
|
|
||||||
});
|
});
|
||||||
if (convergence.defaultTokenId != null) {
|
if (convergence.defaultTokenId != null) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { db, schema, runtimeDbDialect } from '../../db/index.js';
|
|||||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import { refreshBalance } from '../../services/balanceService.js';
|
import { refreshBalance } from '../../services/balanceService.js';
|
||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
|
import {
|
||||||
import { convergeAccountMutation } from '../../services/accountMutationWorkflow.js';
|
convergeAccountMutation,
|
||||||
|
rebuildRoutesBestEffort,
|
||||||
|
} from '../../services/accountMutationWorkflow.js';
|
||||||
import {
|
import {
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
getProxyUrlFromExtraConfig,
|
getProxyUrlFromExtraConfig,
|
||||||
@@ -1330,9 +1332,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
||||||
const id = parseInt(request.params.id);
|
const id = parseInt(request.params.id);
|
||||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
||||||
try {
|
await rebuildRoutesBestEffort();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1386,9 +1386,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRebuildRoutes) {
|
if (shouldRebuildRoutes) {
|
||||||
try {
|
await rebuildRoutesBestEffort();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1596,10 +1594,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await rebuildRoutesBestEffort();
|
||||||
try {
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
expect(source).not.toContain('normalizeContentText(');
|
expect(source).not.toContain('normalizeContentText(');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps codex runtime header and prompt-cache derivation inside provider profiles', () => {
|
||||||
|
const source = readSource('./upstreamEndpoint.ts');
|
||||||
|
expect(source).not.toContain('buildCodexRuntimeHeaders(');
|
||||||
|
expect(source).not.toContain('shouldInjectDerivedPromptCacheKey');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
||||||
const source = readSource('./gemini.ts');
|
const source = readSource('./gemini.ts');
|
||||||
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||||
@@ -139,6 +145,23 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps oauth refresh recovery and success bookkeeping behind shared surface helpers', () => {
|
||||||
|
const chatSurfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts');
|
||||||
|
const responsesSurfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts');
|
||||||
|
|
||||||
|
expect(chatSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
|
||||||
|
expect(chatSurfaceSource).toContain('recordSurfaceSuccess(');
|
||||||
|
expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
|
||||||
|
expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
|
||||||
|
expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling(');
|
||||||
|
|
||||||
|
expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
|
||||||
|
expect(responsesSurfaceSource).toContain('recordSurfaceSuccess(');
|
||||||
|
expect(responsesSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
|
||||||
|
expect(responsesSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
|
||||||
|
expect(responsesSurfaceSource).not.toContain('resolveProxyLogBilling(');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps canonical transformer contracts imported from the transformer boundary only', () => {
|
it('keeps canonical transformer contracts imported from the transformer boundary only', () => {
|
||||||
const chatSource = readSource('./chat.ts');
|
const chatSource = readSource('./chat.ts');
|
||||||
const responsesSource = readSource('./responses.ts');
|
const responsesSource = readSource('./responses.ts');
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ import {
|
|||||||
} from '../../transformers/shared/endpointCompatibility.js';
|
} from '../../transformers/shared/endpointCompatibility.js';
|
||||||
import {
|
import {
|
||||||
buildClaudeRuntimeHeaders,
|
buildClaudeRuntimeHeaders,
|
||||||
buildCodexRuntimeHeaders,
|
|
||||||
buildGeminiCliRuntimeHeaders,
|
|
||||||
buildGeminiCliUserAgent,
|
buildGeminiCliUserAgent,
|
||||||
getInputHeader,
|
|
||||||
headerValueToString,
|
headerValueToString,
|
||||||
} from '../../proxy-core/providers/headerUtils.js';
|
} from '../../proxy-core/providers/headerUtils.js';
|
||||||
export {
|
export {
|
||||||
@@ -1234,7 +1231,10 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
);
|
);
|
||||||
const configuredResponsesBody = applyConfiguredPayloadRules(body);
|
const configuredResponsesBody = applyConfiguredPayloadRules(body);
|
||||||
|
|
||||||
if (providerProfile?.id === 'codex') {
|
if (sitePlatform === 'codex') {
|
||||||
|
if (providerProfile?.id !== 'codex') {
|
||||||
|
throw new Error(`missing codex provider profile for platform: ${sitePlatform}`);
|
||||||
|
}
|
||||||
return providerProfile.prepareRequest({
|
return providerProfile.prepareRequest({
|
||||||
endpoint: 'responses',
|
endpoint: 'responses',
|
||||||
modelName: input.modelName,
|
modelName: input.modelName,
|
||||||
@@ -1254,39 +1254,14 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = sitePlatform === 'codex'
|
const headers = ensureStreamAcceptHeader({
|
||||||
? buildCodexRuntimeHeaders({
|
|
||||||
baseHeaders: {
|
|
||||||
...commonHeaders,
|
|
||||||
...responsesHeaders,
|
|
||||||
},
|
|
||||||
providerHeaders: input.providerHeaders,
|
|
||||||
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
|
||||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
|
||||||
})
|
|
||||||
: ensureStreamAcceptHeader({
|
|
||||||
...commonHeaders,
|
...commonHeaders,
|
||||||
...responsesHeaders,
|
...responsesHeaders,
|
||||||
}, input.stream);
|
}, input.stream);
|
||||||
const codexSessionId = sitePlatform === 'codex'
|
|
||||||
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
|
|
||||||
: null;
|
|
||||||
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
|
|
||||||
&& !!codexSessionId
|
|
||||||
&& !asTrimmedString((configuredResponsesBody as Record<string, unknown>).prompt_cache_key)
|
|
||||||
&& !asTrimmedString(input.codexExplicitSessionId)
|
|
||||||
&& !!asTrimmedString(input.codexSessionCacheKey);
|
|
||||||
const runtimeBody = shouldInjectDerivedPromptCacheKey
|
|
||||||
? {
|
|
||||||
...configuredResponsesBody,
|
|
||||||
prompt_cache_key: codexSessionId,
|
|
||||||
}
|
|
||||||
: configuredResponsesBody;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: resolveEndpointPath('responses'),
|
path: resolveEndpointPath('responses'),
|
||||||
headers,
|
headers,
|
||||||
body: runtimeBody,
|
body: configuredResponsesBody,
|
||||||
runtime,
|
runtime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ describe('accountMutationWorkflow', () => {
|
|||||||
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
|
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only marks refreshedModels when the refresh result explicitly says it refreshed', async () => {
|
||||||
|
refreshModelsForAccountMock.mockResolvedValue({ accountId: 4, refreshed: false, status: 'skipped' });
|
||||||
|
|
||||||
|
const { convergeAccountMutation } = await import('./accountMutationWorkflow.js');
|
||||||
|
const result = await convergeAccountMutation({
|
||||||
|
accountId: 4,
|
||||||
|
refreshModels: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.modelRefreshResult).toEqual({ accountId: 4, refreshed: false, status: 'skipped' });
|
||||||
|
expect(result.refreshedModels).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('refreshes model coverage in batches and maps failures', async () => {
|
it('refreshes model coverage in batches and maps failures', async () => {
|
||||||
refreshModelsForAccountMock
|
refreshModelsForAccountMock
|
||||||
.mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' })
|
.mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' })
|
||||||
@@ -129,4 +142,26 @@ describe('accountMutationWorkflow', () => {
|
|||||||
result: { createdRoutes: 2 },
|
result: { createdRoutes: 2 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid batch sizes before starting the batch loop', async () => {
|
||||||
|
const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js');
|
||||||
|
|
||||||
|
await expect(refreshAccountCoverageBatch({
|
||||||
|
accountIds: [1, 2],
|
||||||
|
batchSize: 1.5,
|
||||||
|
mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }),
|
||||||
|
})).rejects.toThrow('batchSize must be a positive integer');
|
||||||
|
|
||||||
|
expect(refreshModelsForAccountMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a best-effort route rebuild helper for controller callers', async () => {
|
||||||
|
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValueOnce({ createdRoutes: 1 });
|
||||||
|
|
||||||
|
const { rebuildRoutesBestEffort } = await import('./accountMutationWorkflow.js');
|
||||||
|
await expect(rebuildRoutesBestEffort()).resolves.toBe(true);
|
||||||
|
|
||||||
|
rebuildTokenRoutesFromAvailabilityMock.mockRejectedValueOnce(new Error('rebuild failed'));
|
||||||
|
await expect(rebuildRoutesBestEffort()).resolves.toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export type CoverageBatchRebuildResult =
|
|||||||
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function rebuildRoutesBestEffort(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await rebuildTokenRoutesFromAvailability();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function convergeAccountMutation(input: {
|
export async function convergeAccountMutation(input: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
preferredApiToken?: string | null;
|
preferredApiToken?: string | null;
|
||||||
@@ -110,7 +119,7 @@ export async function convergeAccountMutation(input: {
|
|||||||
const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId));
|
const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId));
|
||||||
if (modelRefreshResult) {
|
if (modelRefreshResult) {
|
||||||
result.modelRefreshResult = modelRefreshResult;
|
result.modelRefreshResult = modelRefreshResult;
|
||||||
result.refreshedModels = true;
|
result.refreshedModels = modelRefreshResult.refreshed === true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +142,11 @@ export async function refreshAccountCoverageBatch<TFailure>(input: {
|
|||||||
refresh: Array<ModelRefreshResult | TFailure>;
|
refresh: Array<ModelRefreshResult | TFailure>;
|
||||||
rebuild: CoverageBatchRebuildResult | null;
|
rebuild: CoverageBatchRebuildResult | null;
|
||||||
}> {
|
}> {
|
||||||
|
if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) {
|
||||||
|
throw new Error('batchSize must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = input.batchSize;
|
||||||
const uniqueAccountIds = Array.from(new Set(
|
const uniqueAccountIds = Array.from(new Set(
|
||||||
input.accountIds.filter((id) => Number.isFinite(id) && id > 0),
|
input.accountIds.filter((id) => Number.isFinite(id) && id > 0),
|
||||||
));
|
));
|
||||||
@@ -142,8 +156,8 @@ export async function refreshAccountCoverageBatch<TFailure>(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refresh: Array<ModelRefreshResult | TFailure> = [];
|
const refresh: Array<ModelRefreshResult | TFailure> = [];
|
||||||
for (let offset = 0; offset < uniqueAccountIds.length; offset += input.batchSize) {
|
for (let offset = 0; offset < uniqueAccountIds.length; offset += batchSize) {
|
||||||
const batch = uniqueAccountIds.slice(offset, offset + input.batchSize);
|
const batch = uniqueAccountIds.slice(offset, offset + batchSize);
|
||||||
const settled = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
|
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase {
|
class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase {
|
||||||
readonly platformName = 'test-standard';
|
readonly platformName = 'test-standard';
|
||||||
|
fetchJsonImpl = async () => ({ data: [] as Array<{ id: string }> });
|
||||||
|
|
||||||
async detect(_url: string): Promise<boolean> {
|
async detect(_url: string): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
@@ -15,6 +16,14 @@ class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase {
|
|||||||
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
|
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async fetchJson<T>(url: string, options?: Parameters<StandardApiProviderAdapterBase['fetchJson']>[1]): Promise<T> {
|
||||||
|
return this.fetchJsonImpl(url, options) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchModelsForTest(options: Parameters<StandardApiProviderAdapterBase['fetchModelsFromStandardEndpoint']>[0]) {
|
||||||
|
return this.fetchModelsFromStandardEndpoint(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('standardApiProvider helpers', () => {
|
describe('standardApiProvider helpers', () => {
|
||||||
@@ -43,4 +52,33 @@ describe('standardApiProvider helpers', () => {
|
|||||||
quota: 0,
|
quota: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not swallow mapper bugs while still returning empty lists for network failures', async () => {
|
||||||
|
const adapter = new TestStandardApiProviderAdapter();
|
||||||
|
adapter.fetchJsonImpl = async () => ({ data: [{ id: 'gpt-5' }] });
|
||||||
|
|
||||||
|
await expect(adapter.fetchModelsForTest({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
mapResponse: () => {
|
||||||
|
throw new Error('mapper exploded');
|
||||||
|
},
|
||||||
|
})).rejects.toThrow('mapper exploded');
|
||||||
|
|
||||||
|
adapter.fetchJsonImpl = async () => {
|
||||||
|
throw new Error('network failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(adapter.fetchModelsForTest({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
})).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid payload shapes instead of silently treating them as no models', async () => {
|
||||||
|
const adapter = new TestStandardApiProviderAdapter();
|
||||||
|
adapter.fetchJsonImpl = async () => ({ data: 'not-an-array' });
|
||||||
|
|
||||||
|
await expect(adapter.fetchModelsForTest({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
})).rejects.toThrow('invalid standard models payload');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,18 +60,27 @@ export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter
|
|||||||
? options.resolveUrl(normalizedBaseUrl)
|
? options.resolveUrl(normalizedBaseUrl)
|
||||||
: resolveVersionedModelsUrl(normalizedBaseUrl);
|
: resolveVersionedModelsUrl(normalizedBaseUrl);
|
||||||
|
|
||||||
|
let payload: any;
|
||||||
try {
|
try {
|
||||||
const payload = await this.fetchJson<any>(url, {
|
payload = await this.fetchJson<any>(url, {
|
||||||
headers: options.headers,
|
headers: options.headers,
|
||||||
});
|
});
|
||||||
const rows = options.mapResponse
|
|
||||||
? options.mapResponse(payload)
|
|
||||||
: (payload?.data || []).map((item: any) => item?.id);
|
|
||||||
return rows
|
|
||||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
|
||||||
.filter((item) => item.length > 0);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rows = options.mapResponse
|
||||||
|
? options.mapResponse(payload)
|
||||||
|
: Array.isArray(payload?.data)
|
||||||
|
? payload.data.map((item: any) => item?.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!Array.isArray(rows)) {
|
||||||
|
throw new Error('invalid standard models payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,47 @@ describe('responses conversion single source of truth', () => {
|
|||||||
expect(convertOpenAiBodyToResponsesBodyViaCompatibility).toBe(convertOpenAiBodyToResponsesBody);
|
expect(convertOpenAiBodyToResponsesBodyViaCompatibility).toBe(convertOpenAiBodyToResponsesBody);
|
||||||
expect(sanitizeResponsesBodyForProxyViaCompatibility).toBe(sanitizeResponsesBodyForProxy);
|
expect(sanitizeResponsesBodyForProxyViaCompatibility).toBe(sanitizeResponsesBodyForProxy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves extra properties when normalizing role-only message items', () => {
|
||||||
|
expect(normalizeResponsesInputForCompatibility([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'done',
|
||||||
|
id: 'msg_1',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
])).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'output_text', text: 'done' }],
|
||||||
|
id: 'msg_1',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters whitespace-only string entries from normalized responses input arrays', () => {
|
||||||
|
expect(normalizeResponsesInputForCompatibility([
|
||||||
|
'hello',
|
||||||
|
' ',
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'world',
|
||||||
|
},
|
||||||
|
])).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'world' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sanitizeResponsesBodyForProxy', () => {
|
describe('sanitizeResponsesBodyForProxy', () => {
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
|
|||||||
|
|
||||||
if (asTrimmedString(item.role)) {
|
if (asTrimmedString(item.role)) {
|
||||||
return {
|
return {
|
||||||
|
...item,
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role,
|
role,
|
||||||
content: normalizedContent,
|
content: normalizedContent,
|
||||||
@@ -158,13 +159,13 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
return input.map((item) => {
|
return input.flatMap((item) => {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
const normalized = item.trim();
|
const normalized = item.trim();
|
||||||
return normalized ? toResponsesInputMessageFromText(normalized) : item;
|
return normalized ? [toResponsesInputMessageFromText(normalized)] : [];
|
||||||
}
|
}
|
||||||
if (!isRecord(item)) return item;
|
if (!isRecord(item)) return [item];
|
||||||
return normalizeResponsesMessageItem(item);
|
return [normalizeResponsesMessageItem(item)];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ function normalizeValue(value) {
|
|||||||
return (value || '').trim().toLowerCase();
|
return (value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMimeType(value) {
|
||||||
|
const normalized = normalizeValue(value);
|
||||||
|
const [essence] = normalized.split(';');
|
||||||
|
return (essence || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function inferConversationFileMimeType(filename) {
|
export function inferConversationFileMimeType(filename) {
|
||||||
const normalized = normalizeValue(filename);
|
const normalized = normalizeValue(filename);
|
||||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||||
@@ -41,7 +47,7 @@ export function inferConversationFileMimeType(filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConversationFileMimeType(mimeType, filename) {
|
export function resolveConversationFileMimeType(mimeType, filename) {
|
||||||
const normalizedMimeType = normalizeValue(mimeType);
|
const normalizedMimeType = normalizeMimeType(mimeType);
|
||||||
if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) {
|
if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) {
|
||||||
return normalizedMimeType;
|
return normalizedMimeType;
|
||||||
}
|
}
|
||||||
@@ -49,14 +55,14 @@ export function resolveConversationFileMimeType(mimeType, filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function classifyConversationFileMimeType(mimeType) {
|
export function classifyConversationFileMimeType(mimeType) {
|
||||||
const normalized = normalizeValue(mimeType);
|
const normalized = normalizeMimeType(mimeType);
|
||||||
if (normalized.startsWith('image/')) return 'image';
|
if (normalized.startsWith('image/')) return 'image';
|
||||||
if (normalized.startsWith('audio/')) return 'audio';
|
if (normalized.startsWith('audio/')) return 'audio';
|
||||||
return 'document';
|
return 'document';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectConversationFileKind(file) {
|
export function detectConversationFileKind(file) {
|
||||||
const mimeType = normalizeValue(file?.mimeType);
|
const mimeType = normalizeMimeType(file?.mimeType);
|
||||||
if (mimeType) {
|
if (mimeType) {
|
||||||
if (mimeType.startsWith('image/')) return 'image';
|
if (mimeType.startsWith('image/')) return 'image';
|
||||||
if (mimeType.startsWith('audio/')) return 'audio';
|
if (mimeType.startsWith('audio/')) return 'audio';
|
||||||
@@ -73,7 +79,7 @@ export function detectConversationFileKind(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isSupportedConversationFileMimeType(mimeType) {
|
export function isSupportedConversationFileMimeType(mimeType) {
|
||||||
const normalized = normalizeValue(mimeType);
|
const normalized = normalizeMimeType(mimeType);
|
||||||
return DOCUMENT_MIME_TYPES.has(normalized)
|
return DOCUMENT_MIME_TYPES.has(normalized)
|
||||||
|| normalized.startsWith('image/')
|
|| normalized.startsWith('image/')
|
||||||
|| normalized.startsWith('audio/');
|
|| normalized.startsWith('audio/');
|
||||||
|
|||||||
@@ -42,4 +42,14 @@ describe('conversationFileTypes', () => {
|
|||||||
mimeType: 'application/octet-stream',
|
mimeType: 'application/octet-stream',
|
||||||
})).toBe('document');
|
})).toBe('document');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips MIME parameters before matching support and kind rules', () => {
|
||||||
|
expect(resolveConversationFileMimeType('text/plain; charset=utf-8', 'notes.txt')).toBe('text/plain');
|
||||||
|
expect(classifyConversationFileMimeType('text/plain; charset=utf-8')).toBe('document');
|
||||||
|
expect(detectConversationFileKind({
|
||||||
|
filename: 'notes.txt',
|
||||||
|
mimeType: 'text/plain; charset=utf-8',
|
||||||
|
})).toBe('document');
|
||||||
|
expect(isSupportedConversationFileMimeType('text/plain; charset=utf-8')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ function isSafeRegexPatternBody(body) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === '\\') {
|
if (ch === '\\') {
|
||||||
|
const next = body[index + 1];
|
||||||
|
if (!next) return false;
|
||||||
|
if (/[a-z]/i.test(next) && next !== 'd') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
escaped = true;
|
escaped = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -99,10 +104,7 @@ function isSafeRegexPatternBody(body) {
|
|||||||
}
|
}
|
||||||
if (ch === '(') {
|
if (ch === '(') {
|
||||||
if (body[index + 1] === '?') {
|
if (body[index + 1] === '?') {
|
||||||
if (body[index + 2] !== ':') return false;
|
return false;
|
||||||
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
|
||||||
index += 2;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ describe('token route pattern helpers', () => {
|
|||||||
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
|
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects regex syntax that the lightweight parser does not implement', async () => {
|
||||||
|
const {
|
||||||
|
matchesTokenRouteModelPattern,
|
||||||
|
parseTokenRouteRegexPattern,
|
||||||
|
} = await import('./tokenRoutePatterns.js');
|
||||||
|
|
||||||
|
expect(parseTokenRouteRegexPattern('re:^(?:gpt|claude)-5$').regex).toBeNull();
|
||||||
|
expect(matchesTokenRouteModelPattern('gpt-5', 're:^(?:gpt|claude)-5$')).toBe(false);
|
||||||
|
expect(parseTokenRouteRegexPattern('re:^gpt-\\s+$').regex).toBeNull();
|
||||||
|
expect(matchesTokenRouteModelPattern('gpt- ', 're:^gpt-\\s+$')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('supports exact, glob, and safe regex route matches', async () => {
|
it('supports exact, glob, and safe regex route matches', async () => {
|
||||||
const { matchesTokenRouteModelPattern } = await import('./tokenRoutePatterns.js');
|
const { matchesTokenRouteModelPattern } = await import('./tokenRoutePatterns.js');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user