forked from boos/BoosAPI
Prepare protocol compatibility release
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validateExternalResponsesHttpRequest } from './responsesPreflight.js';
|
||||
|
||||
describe('validateExternalResponsesHttpRequest', () => {
|
||||
it('rejects external HTTP previous_response_id and explains msg ids separately', () => {
|
||||
const responseIdResult = validateExternalResponsesHttpRequest({
|
||||
model: 'gpt-5',
|
||||
previous_response_id: 'resp_prev_1',
|
||||
input: 'hello',
|
||||
});
|
||||
expect(responseIdResult.ok).toBe(false);
|
||||
if (!responseIdResult.ok) {
|
||||
expect(responseIdResult.payload.error.message).toContain('HTTP /v1/responses does not support');
|
||||
}
|
||||
|
||||
const messageIdResult = validateExternalResponsesHttpRequest({
|
||||
model: 'gpt-5',
|
||||
previous_response_id: 'msg_prev_1',
|
||||
input: 'hello',
|
||||
});
|
||||
expect(messageIdResult.ok).toBe(false);
|
||||
if (!messageIdResult.ok) {
|
||||
expect(messageIdResult.payload.error.message).toContain('response id');
|
||||
expect(messageIdResult.payload.error.message).toContain('msg_*');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects HTTP function_call_output without call_id or local context, but allows matching item_reference', () => {
|
||||
const missingCallId = validateExternalResponsesHttpRequest({
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'function_call_output', output: 'done' }],
|
||||
});
|
||||
expect(missingCallId.ok).toBe(false);
|
||||
if (!missingCallId.ok) {
|
||||
expect(missingCallId.payload.error.message).toContain('requires call_id');
|
||||
}
|
||||
|
||||
const missingContext = validateExternalResponsesHttpRequest({
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'function_call_output', call_id: 'call_1', output: 'done' }],
|
||||
});
|
||||
expect(missingContext.ok).toBe(false);
|
||||
if (!missingContext.ok) {
|
||||
expect(missingContext.payload.error.message).toContain('Responses WebSocket v2');
|
||||
}
|
||||
|
||||
expect(validateExternalResponsesHttpRequest({
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{ type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'lookup', arguments: '{}' },
|
||||
{ type: 'function_call_output', call_id: 'call_1', output: 'done' },
|
||||
],
|
||||
})).toEqual({ ok: true });
|
||||
|
||||
expect(validateExternalResponsesHttpRequest({
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{ type: 'function_call', id: 'fc_1', call_id: 'call_other', name: 'lookup', arguments: '{}' },
|
||||
{ type: 'function_call_output', call_id: 'call_1', item_reference: 'fc_1', output: 'done' },
|
||||
],
|
||||
})).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
export type ResponsesPreflightResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
statusCode: 400;
|
||||
payload: {
|
||||
error: {
|
||||
message: string;
|
||||
type: 'invalid_request_error';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function invalidRequest(message: string): Extract<ResponsesPreflightResult, { ok: false }> {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function walkRecords(value: unknown, visitor: (item: Record<string, unknown>) => void): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) walkRecords(item, visitor);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
visitor(value);
|
||||
for (const item of Object.values(value)) {
|
||||
if (Array.isArray(item) || isRecord(item)) walkRecords(item, visitor);
|
||||
}
|
||||
}
|
||||
|
||||
function validateExternalPreviousResponseId(body: Record<string, unknown>): ResponsesPreflightResult {
|
||||
const previousResponseId = asTrimmedString(body.previous_response_id);
|
||||
if (!previousResponseId) return { ok: true };
|
||||
|
||||
const message = previousResponseId.startsWith('msg_')
|
||||
? 'previous_response_id must be a response id beginning with resp_; message ids like msg_* are not valid, and HTTP /v1/responses does not support continuation chaining. Use Responses WebSocket v2 for function-call follow-up turns.'
|
||||
: 'HTTP /v1/responses does not support user-supplied previous_response_id continuation chaining. Use Responses WebSocket v2 for follow-up turns.';
|
||||
return invalidRequest(message);
|
||||
}
|
||||
|
||||
function validateFunctionCallOutputs(
|
||||
body: Record<string, unknown>,
|
||||
options: { allowContinuationToolOutput?: boolean } = {},
|
||||
): ResponsesPreflightResult {
|
||||
const input = body.input;
|
||||
if (!Array.isArray(input)) return { ok: true };
|
||||
|
||||
const knownCallIds = new Set<string>();
|
||||
const knownReferenceIds = new Set<string>();
|
||||
const outputs: Record<string, unknown>[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!isRecord(item)) continue;
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'function_call' || itemType === 'custom_tool_call' || itemType === 'tool_call') {
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (callId) knownCallIds.add(callId);
|
||||
const id = asTrimmedString(item.id);
|
||||
if (id) knownReferenceIds.add(id);
|
||||
continue;
|
||||
}
|
||||
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
outputs.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowContinuationToolOutput) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
for (const item of outputs) {
|
||||
const callId = asTrimmedString(item.call_id);
|
||||
if (!callId) {
|
||||
return invalidRequest('function_call_output requires call_id. Use Responses WebSocket v2 for incremental tool-output follow-up turns.');
|
||||
}
|
||||
const itemReference = asTrimmedString(
|
||||
item.item_reference
|
||||
?? item.itemReference
|
||||
?? (isRecord(item.reference) ? item.reference.id : undefined),
|
||||
);
|
||||
if (!knownCallIds.has(callId) && (!itemReference || !knownReferenceIds.has(itemReference))) {
|
||||
return invalidRequest('function_call_output must match a function_call/tool_call in the same HTTP request or include a matching item_reference. Use Responses WebSocket v2 for continuation turns.');
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateExternalResponsesHttpRequest(
|
||||
body: Record<string, unknown>,
|
||||
options: { allowContinuationToolOutput?: boolean } = {},
|
||||
): ResponsesPreflightResult {
|
||||
const previousResponseResult = validateExternalPreviousResponseId(body);
|
||||
if (!previousResponseResult.ok) return previousResponseResult;
|
||||
|
||||
const functionCallOutputResult = validateFunctionCallOutputs(body, options);
|
||||
if (!functionCallOutputResult.ok) return functionCallOutputResult;
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function isWebSearchToolRecord(tool: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(tool.type).toLowerCase();
|
||||
const name = asTrimmedString(tool.name).toLowerCase();
|
||||
return type === 'web_search'
|
||||
|| type === 'web_search_preview'
|
||||
|| type === 'web_search_20250305'
|
||||
|| type === 'google_search'
|
||||
|| name === 'web_search'
|
||||
|| name === 'google_search';
|
||||
}
|
||||
|
||||
export function hasResponsesWebSearchOnlyRequest(body: Record<string, unknown>): boolean {
|
||||
let hasWebSearchTool = false;
|
||||
let hasOtherTools = false;
|
||||
|
||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||
for (const tool of tools) {
|
||||
if (!isRecord(tool)) continue;
|
||||
if (isWebSearchToolRecord(tool)) {
|
||||
hasWebSearchTool = true;
|
||||
} else {
|
||||
hasOtherTools = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasWebSearchTool && !hasOtherTools;
|
||||
}
|
||||
|
||||
export function extractResponsesWebSearchQuery(body: Record<string, unknown>): string {
|
||||
if (typeof body.input === 'string') {
|
||||
const query = body.input.trim();
|
||||
if (query) return query;
|
||||
}
|
||||
|
||||
const queryCandidates: string[] = [];
|
||||
walkRecords(body.input, (item) => {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'input_text' || type === 'text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) queryCandidates.push(text);
|
||||
return;
|
||||
}
|
||||
if (type === 'message') {
|
||||
const text = asTrimmedString(item.content);
|
||||
if (text) queryCandidates.push(text);
|
||||
}
|
||||
});
|
||||
return queryCandidates[queryCandidates.length - 1] || '';
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyOpenAiServiceTierPolicy } from './serviceTierPolicy.js';
|
||||
|
||||
describe('applyOpenAiServiceTierPolicy', () => {
|
||||
it('normalizes fast to priority and drops unknown or non-string tiers', () => {
|
||||
expect(applyOpenAiServiceTierPolicy({
|
||||
body: { model: 'gpt-5', service_tier: ' fast ' },
|
||||
})).toMatchObject({
|
||||
ok: true,
|
||||
body: { model: 'gpt-5', service_tier: 'priority' },
|
||||
action: 'pass',
|
||||
});
|
||||
|
||||
expect(applyOpenAiServiceTierPolicy({
|
||||
body: { model: 'gpt-5', service_tier: 'turbo' },
|
||||
})).toMatchObject({
|
||||
ok: true,
|
||||
body: { model: 'gpt-5' },
|
||||
action: 'filter',
|
||||
});
|
||||
|
||||
expect(applyOpenAiServiceTierPolicy({
|
||||
body: { model: 'gpt-5', service_tier: 123 },
|
||||
})).toMatchObject({
|
||||
ok: true,
|
||||
body: { model: 'gpt-5' },
|
||||
action: 'filter',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies pass, filter and block rules by tier, model, platform and account type', () => {
|
||||
const rules = [
|
||||
{
|
||||
action: 'filter',
|
||||
tiers: ['flex'],
|
||||
platforms: ['sub2api'],
|
||||
},
|
||||
{
|
||||
action: 'block',
|
||||
tiers: ['priority'],
|
||||
models: ['gpt-5'],
|
||||
platforms: ['openai'],
|
||||
accountTypes: ['free'],
|
||||
},
|
||||
];
|
||||
|
||||
expect(applyOpenAiServiceTierPolicy({
|
||||
body: { model: 'gpt-5', service_tier: 'flex' },
|
||||
context: { sitePlatform: 'sub2api', requestedModel: 'gpt-5' },
|
||||
rules,
|
||||
})).toMatchObject({
|
||||
ok: true,
|
||||
body: { model: 'gpt-5' },
|
||||
serviceTier: 'flex',
|
||||
action: 'filter',
|
||||
});
|
||||
|
||||
const blocked = applyOpenAiServiceTierPolicy({
|
||||
body: { model: 'gpt-5', service_tier: 'fast' },
|
||||
context: {
|
||||
sitePlatform: 'openai',
|
||||
requestedModel: 'gpt-5',
|
||||
accountType: 'free',
|
||||
},
|
||||
rules,
|
||||
});
|
||||
|
||||
expect(blocked.ok).toBe(false);
|
||||
if (!blocked.ok) {
|
||||
expect(blocked.statusCode).toBe(400);
|
||||
expect(blocked.payload.error.message).toContain('priority');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
export type ServiceTierAction = 'pass' | 'filter' | 'block';
|
||||
|
||||
export type ServiceTierRule = {
|
||||
action: ServiceTierAction;
|
||||
tiers?: string[];
|
||||
models?: string[];
|
||||
platforms?: string[];
|
||||
accountTypes?: string[];
|
||||
};
|
||||
|
||||
export type ServiceTierRuleContext = {
|
||||
requestedModel?: string | null;
|
||||
actualModel?: string | null;
|
||||
sitePlatform?: string | null;
|
||||
accountType?: string | null;
|
||||
};
|
||||
|
||||
export type ServiceTierPolicyResult =
|
||||
| {
|
||||
ok: true;
|
||||
body: Record<string, unknown>;
|
||||
serviceTier?: string;
|
||||
action: 'pass' | 'filter';
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
statusCode: 400;
|
||||
payload: {
|
||||
error: {
|
||||
message: string;
|
||||
type: 'invalid_request_error';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const KNOWN_OPENAI_SERVICE_TIERS = new Set([
|
||||
'auto',
|
||||
'default',
|
||||
'flex',
|
||||
'priority',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeMatchList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => asTrimmedString(item).toLowerCase())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function normalizeRule(value: unknown): ServiceTierRule | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const action = asTrimmedString(value.action).toLowerCase();
|
||||
if (action !== 'pass' && action !== 'filter' && action !== 'block') return null;
|
||||
return {
|
||||
action,
|
||||
tiers: normalizeMatchList(value.tiers),
|
||||
models: normalizeMatchList(value.models),
|
||||
platforms: normalizeMatchList(value.platforms),
|
||||
accountTypes: normalizeMatchList(value.accountTypes ?? value.account_types),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRules(value: unknown): ServiceTierRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => normalizeRule(item))
|
||||
.filter((item): item is ServiceTierRule => !!item);
|
||||
}
|
||||
|
||||
function candidateMatches(patterns: string[] | undefined, candidates: Array<string | null | undefined>): boolean {
|
||||
if (!patterns || patterns.length <= 0) return true;
|
||||
const normalizedCandidates = candidates
|
||||
.map((item) => asTrimmedString(item).toLowerCase())
|
||||
.filter((item) => item.length > 0);
|
||||
if (normalizedCandidates.length <= 0) return false;
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern === '*') return true;
|
||||
return normalizedCandidates.some((candidate) => candidate === pattern);
|
||||
});
|
||||
}
|
||||
|
||||
function ruleMatches(
|
||||
rule: ServiceTierRule,
|
||||
serviceTier: string,
|
||||
context: ServiceTierRuleContext,
|
||||
): boolean {
|
||||
return candidateMatches(rule.tiers, [serviceTier])
|
||||
&& candidateMatches(rule.models, [context.actualModel, context.requestedModel])
|
||||
&& candidateMatches(rule.platforms, [context.sitePlatform])
|
||||
&& candidateMatches(rule.accountTypes, [context.accountType]);
|
||||
}
|
||||
|
||||
function normalizeServiceTier(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'fast') return 'priority';
|
||||
if (!KNOWN_OPENAI_SERVICE_TIERS.has(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildBlockedResult(serviceTier: string): Extract<ServiceTierPolicyResult, { ok: false }> {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: `service_tier '${serviceTier}' is not allowed for this upstream policy`,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenAiServiceTierPolicy(input: {
|
||||
body: Record<string, unknown>;
|
||||
context?: ServiceTierRuleContext;
|
||||
rules?: unknown;
|
||||
}): ServiceTierPolicyResult {
|
||||
const rawTier = input.body.service_tier;
|
||||
const serviceTier = normalizeServiceTier(rawTier);
|
||||
const next = { ...input.body };
|
||||
|
||||
if (!serviceTier) {
|
||||
delete next.service_tier;
|
||||
return {
|
||||
ok: true,
|
||||
body: next,
|
||||
action: 'filter',
|
||||
};
|
||||
}
|
||||
|
||||
next.service_tier = serviceTier;
|
||||
const rules = normalizeRules(input.rules);
|
||||
const matchedRule = rules.find((rule) => ruleMatches(rule, serviceTier, input.context || {}));
|
||||
if (matchedRule?.action === 'block') return buildBlockedResult(serviceTier);
|
||||
if (matchedRule?.action === 'filter') {
|
||||
delete next.service_tier;
|
||||
return {
|
||||
ok: true,
|
||||
body: next,
|
||||
serviceTier,
|
||||
action: 'filter',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: next,
|
||||
serviceTier,
|
||||
action: 'pass',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/
|
||||
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||
import { applyOpenAiServiceTierPolicy } from '../serviceTierPolicy.js';
|
||||
import { maybeHandleWebSearchOnlySimulation } from '../webSearchSimulation.js';
|
||||
import {
|
||||
acquireSurfaceChannelLease,
|
||||
bindSurfaceStickyChannel,
|
||||
@@ -144,6 +146,17 @@ export async function handleChatSurfaceRequest(
|
||||
upstreamBody,
|
||||
claudeOriginalBody,
|
||||
} = requestEnvelope.parsed;
|
||||
if (downstreamFormat === 'claude') {
|
||||
const handledSearch = await maybeHandleWebSearchOnlySimulation({
|
||||
app: request.server,
|
||||
request,
|
||||
reply,
|
||||
downstreamFormat: 'claude',
|
||||
body: (claudeOriginalBody || request.body || {}) as Record<string, unknown>,
|
||||
openAiBody: upstreamBody,
|
||||
});
|
||||
if (handledSearch) return;
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
@@ -328,6 +341,29 @@ export async function handleChatSurfaceRequest(
|
||||
options: { forceNormalizeClaudeBody?: boolean } = {},
|
||||
) => {
|
||||
const upstreamStream = isStream || (forceResponsesUpstreamStream && endpoint === 'responses');
|
||||
const bodyForEndpoint = endpoint === 'responses'
|
||||
? (() => {
|
||||
const policyResult = applyOpenAiServiceTierPolicy({
|
||||
body: resolvedOpenAiBody,
|
||||
context: {
|
||||
requestedModel,
|
||||
actualModel: modelName,
|
||||
sitePlatform: selected.site.platform,
|
||||
accountType: oauth?.planType,
|
||||
},
|
||||
rules: (config as any).openAiServiceTierRules,
|
||||
});
|
||||
if (!policyResult.ok) {
|
||||
const error = new SiteApiEndpointRequestError(policyResult.payload.error.message, {
|
||||
status: policyResult.statusCode,
|
||||
rawErrText: JSON.stringify(policyResult.payload),
|
||||
});
|
||||
(error as SiteApiEndpointRequestError & { serviceTierBlocked?: boolean }).serviceTierBlocked = true;
|
||||
throw error;
|
||||
}
|
||||
return policyResult.body;
|
||||
})()
|
||||
: resolvedOpenAiBody;
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
@@ -337,7 +373,7 @@ export async function handleChatSurfaceRequest(
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: siteApiBaseUrl,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
openaiBody: bodyForEndpoint,
|
||||
downstreamFormat,
|
||||
claudeOriginalBody,
|
||||
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
|
||||
@@ -959,8 +995,24 @@ export async function handleChatSurfaceRequest(
|
||||
err instanceof SiteApiEndpointRequestError
|
||||
|| err?.name === 'SiteApiEndpointRequestError'
|
||||
|| err?.siteApiEndpointUpstreamFailure === true
|
||||
|| err?.serviceTierBlocked === true
|
||||
|| (endpointFailureStatus !== null && endpointFailureStatus >= 500)
|
||||
);
|
||||
if (err?.serviceTierBlocked === true) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(err.rawErrText || '');
|
||||
} catch {
|
||||
payload = {
|
||||
error: {
|
||||
message: err.message || 'service_tier is blocked by policy',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
await finalizeDebugFailure(endpointFailureStatus || 400, payload, null);
|
||||
return reply.code(endpointFailureStatus || 400).send(payload);
|
||||
}
|
||||
if (isSiteApiEndpointFailure) {
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
@@ -1391,8 +1443,24 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
error instanceof SiteApiEndpointRequestError
|
||||
|| error?.name === 'SiteApiEndpointRequestError'
|
||||
|| error?.siteApiEndpointUpstreamFailure === true
|
||||
|| error?.serviceTierBlocked === true
|
||||
|| (endpointFailureStatus !== null && endpointFailureStatus >= 500)
|
||||
);
|
||||
if (error?.serviceTierBlocked === true) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(error.rawErrText || '');
|
||||
} catch {
|
||||
payload = {
|
||||
error: {
|
||||
message: error.message || 'service_tier is blocked by policy',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
await finalizeDebugFailure(endpointFailureStatus || 400, payload, null);
|
||||
return reply.code(endpointFailureStatus || 400).send(payload);
|
||||
}
|
||||
if (isSiteApiEndpointFailure) {
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
|
||||
@@ -66,6 +66,9 @@ import {
|
||||
shouldFallbackCompactResponsesToResponses,
|
||||
} from '../capabilities/responsesCompact.js';
|
||||
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||
import { validateExternalResponsesHttpRequest } from '../responsesPreflight.js';
|
||||
import { applyOpenAiServiceTierPolicy } from '../serviceTierPolicy.js';
|
||||
import { maybeHandleWebSearchOnlySimulation } from '../webSearchSimulation.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||
import {
|
||||
@@ -262,6 +265,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||
request.headers as Record<string, unknown>,
|
||||
);
|
||||
if (!isResponsesWebsocketTransportRequest(request.headers as Record<string, unknown>)) {
|
||||
const preflight = validateExternalResponsesHttpRequest(body, {
|
||||
allowContinuationToolOutput: defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (!preflight.ok) {
|
||||
return reply.code(preflight.statusCode).send(preflight.payload);
|
||||
}
|
||||
}
|
||||
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
|
||||
defaultEncryptedReasoningInclude,
|
||||
});
|
||||
@@ -280,6 +291,16 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!isCompactRequest) {
|
||||
const handledSearch = await maybeHandleWebSearchOnlySimulation({
|
||||
app: request.server,
|
||||
request,
|
||||
reply,
|
||||
downstreamFormat: 'responses',
|
||||
body: requestEnvelope.parsed.normalizedBody,
|
||||
});
|
||||
if (handledSearch) return;
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
@@ -400,6 +421,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
model: modelName,
|
||||
stream: isStream,
|
||||
};
|
||||
const serviceTierPolicy = applyOpenAiServiceTierPolicy({
|
||||
body: normalizedResponsesBody,
|
||||
context: {
|
||||
requestedModel,
|
||||
actualModel: modelName,
|
||||
sitePlatform: selected.site.platform,
|
||||
accountType: oauth?.planType,
|
||||
},
|
||||
rules: (config as any).openAiServiceTierRules,
|
||||
});
|
||||
if (!serviceTierPolicy.ok) {
|
||||
await finalizeDebugFailure(serviceTierPolicy.statusCode, serviceTierPolicy.payload, null);
|
||||
return reply.code(serviceTierPolicy.statusCode).send(serviceTierPolicy.payload);
|
||||
}
|
||||
normalizedResponsesBody = serviceTierPolicy.body;
|
||||
if (body.generate === false) {
|
||||
normalizedResponsesBody.generate = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { anthropicMessagesTransformer } from '../transformers/anthropic/messages/index.js';
|
||||
import {
|
||||
extractResponsesWebSearchQuery,
|
||||
hasResponsesWebSearchOnlyRequest,
|
||||
} from './responsesPreflight.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function findSearchTool(body: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||
for (const tool of tools) {
|
||||
if (!isRecord(tool)) continue;
|
||||
const type = asTrimmedString(tool.type).toLowerCase();
|
||||
const name = asTrimmedString(tool.name).toLowerCase();
|
||||
if (
|
||||
type === 'web_search'
|
||||
|| type === 'web_search_preview'
|
||||
|| type === 'web_search_20250305'
|
||||
|| type === 'google_search'
|
||||
|| name === 'web_search'
|
||||
|| name === 'google_search'
|
||||
) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toSearchMaxResults(tool: Record<string, unknown> | null): number {
|
||||
const raw = tool?.max_uses ?? tool?.max_results ?? tool?.maxResults;
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 10;
|
||||
return Math.max(1, Math.min(20, Math.trunc(raw)));
|
||||
}
|
||||
|
||||
function buildSearchInjectHeaders(request: FastifyRequest): Record<string, string | string[]> {
|
||||
const headers: Record<string, string | string[]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(request.headers as Record<string, string | string[]>)) {
|
||||
const key = rawKey.toLowerCase();
|
||||
if (
|
||||
key === 'host'
|
||||
|| key === 'content-length'
|
||||
|| key === 'content-type'
|
||||
|| key === 'connection'
|
||||
|| key === 'transfer-encoding'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (rawValue === undefined) continue;
|
||||
headers[rawKey] = rawValue;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function extractAnthropicSearchQuery(body: Record<string, unknown>): string {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!isRecord(message)) continue;
|
||||
if (asTrimmedString(message.role).toLowerCase() !== 'user') continue;
|
||||
const content = message.content;
|
||||
if (typeof content === 'string' && content.trim()) return content.trim();
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parts = content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item.trim();
|
||||
if (!isRecord(item)) return '';
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type && type !== 'text' && type !== 'input_text') return '';
|
||||
return asTrimmedString(item.text ?? item.content);
|
||||
})
|
||||
.filter((item) => item.length > 0);
|
||||
if (parts.length > 0) return parts.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function callLocalSearchRoute(input: {
|
||||
app: FastifyInstance;
|
||||
request: FastifyRequest;
|
||||
query: string;
|
||||
model: string;
|
||||
maxResults: number;
|
||||
}): Promise<{ statusCode: number; payload: unknown }> {
|
||||
const searchResponse = await input.app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/search',
|
||||
headers: buildSearchInjectHeaders(input.request),
|
||||
payload: {
|
||||
model: input.model,
|
||||
query: input.query,
|
||||
max_results: input.maxResults,
|
||||
},
|
||||
});
|
||||
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(searchResponse.body);
|
||||
} catch {
|
||||
payload = searchResponse.body;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: searchResponse.statusCode,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchResults(payload: unknown): unknown[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
const results = Array.isArray(payload.results) ? payload.results : [];
|
||||
return data.length > 0 ? data : results;
|
||||
}
|
||||
|
||||
function buildSyntheticResponsesPayload(input: {
|
||||
body: Record<string, unknown>;
|
||||
query: string;
|
||||
searchPayload: unknown;
|
||||
}) {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const responseId = `resp_web_search_${randomUUID()}`;
|
||||
const searchCallId = `ws_${randomUUID()}`;
|
||||
const results = normalizeSearchResults(input.searchPayload);
|
||||
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: createdAt,
|
||||
model: asTrimmedString(input.body.model) || 'unknown',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: searchCallId,
|
||||
type: 'web_search_call',
|
||||
status: 'completed',
|
||||
action: {
|
||||
type: 'search',
|
||||
query: input.query,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `msg_${searchCallId}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
text: results.length > 0
|
||||
? JSON.stringify(results)
|
||||
: '[]',
|
||||
}],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serializeResponsesSse(payload: Record<string, unknown>): string[] {
|
||||
return [
|
||||
`event: response.completed\ndata: ${JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: payload,
|
||||
})}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
];
|
||||
}
|
||||
|
||||
async function sendAnthropicSearchSimulation(input: {
|
||||
app: FastifyInstance;
|
||||
request: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
body: Record<string, unknown>;
|
||||
openAiBody: Record<string, unknown>;
|
||||
searchBody: Record<string, unknown>;
|
||||
}): Promise<boolean> {
|
||||
const tool = findSearchTool(input.searchBody) || findSearchTool(input.openAiBody);
|
||||
const query = extractAnthropicSearchQuery(input.body) || extractResponsesWebSearchQuery(input.openAiBody);
|
||||
if (!query) return false;
|
||||
|
||||
const search = await callLocalSearchRoute({
|
||||
app: input.app,
|
||||
request: input.request,
|
||||
query,
|
||||
model: asTrimmedString(input.body.model) || asTrimmedString(input.openAiBody.model) || '__search',
|
||||
maxResults: toSearchMaxResults(tool),
|
||||
});
|
||||
if (search.statusCode < 200 || search.statusCode >= 300) {
|
||||
input.reply.code(search.statusCode).send(search.payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
const responsesPayload = buildSyntheticResponsesPayload({
|
||||
body: input.openAiBody,
|
||||
query,
|
||||
searchPayload: search.payload,
|
||||
});
|
||||
if (input.body.stream === true) {
|
||||
const streamContext = anthropicMessagesTransformer.createStreamContext(asTrimmedString(input.body.model) || 'unknown');
|
||||
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
const lines = anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
|
||||
responsesPayload,
|
||||
asTrimmedString(input.body.model) || 'unknown',
|
||||
'',
|
||||
streamContext,
|
||||
claudeContext,
|
||||
);
|
||||
input.reply
|
||||
.code(200)
|
||||
.header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
.header('Cache-Control', 'no-cache, no-transform')
|
||||
.send(lines.join(''));
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = anthropicMessagesTransformer.transformFinalResponse(
|
||||
responsesPayload,
|
||||
asTrimmedString(input.body.model) || 'unknown',
|
||||
'',
|
||||
);
|
||||
input.reply.code(200).send(anthropicMessagesTransformer.serializeFinalResponse(normalized, {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendResponsesSearchSimulation(input: {
|
||||
app: FastifyInstance;
|
||||
request: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
body: Record<string, unknown>;
|
||||
}): Promise<boolean> {
|
||||
if (!hasResponsesWebSearchOnlyRequest(input.body)) return false;
|
||||
const tool = findSearchTool(input.body);
|
||||
const query = extractResponsesWebSearchQuery(input.body);
|
||||
if (!query) return false;
|
||||
|
||||
const search = await callLocalSearchRoute({
|
||||
app: input.app,
|
||||
request: input.request,
|
||||
query,
|
||||
model: asTrimmedString(input.body.model) || '__search',
|
||||
maxResults: toSearchMaxResults(tool),
|
||||
});
|
||||
if (search.statusCode < 200 || search.statusCode >= 300) {
|
||||
input.reply.code(search.statusCode).send(search.payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = buildSyntheticResponsesPayload({
|
||||
body: input.body,
|
||||
query,
|
||||
searchPayload: search.payload,
|
||||
});
|
||||
if (input.body.stream === true) {
|
||||
input.reply
|
||||
.code(200)
|
||||
.header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
.header('Cache-Control', 'no-cache, no-transform')
|
||||
.send(serializeResponsesSse(payload).join(''));
|
||||
return true;
|
||||
}
|
||||
|
||||
input.reply.code(200).send(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function maybeHandleWebSearchOnlySimulation(input: {
|
||||
app: FastifyInstance;
|
||||
request: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
downstreamFormat: 'responses' | 'claude';
|
||||
body: Record<string, unknown>;
|
||||
openAiBody?: Record<string, unknown>;
|
||||
}): Promise<boolean> {
|
||||
if (input.downstreamFormat === 'responses') {
|
||||
return sendResponsesSearchSimulation({
|
||||
app: input.app,
|
||||
request: input.request,
|
||||
reply: input.reply,
|
||||
body: input.body,
|
||||
});
|
||||
}
|
||||
|
||||
const openAiBody = input.openAiBody;
|
||||
const rawBodyHasSearchOnly = hasResponsesWebSearchOnlyRequest(input.body);
|
||||
const openAiBodyHasSearchOnly = !!openAiBody && hasResponsesWebSearchOnlyRequest(openAiBody);
|
||||
if (!openAiBody || (!rawBodyHasSearchOnly && !openAiBodyHasSearchOnly)) return false;
|
||||
return sendAnthropicSearchSimulation({
|
||||
app: input.app,
|
||||
request: input.request,
|
||||
reply: input.reply,
|
||||
body: input.body,
|
||||
openAiBody,
|
||||
searchBody: rawBodyHasSearchOnly ? input.body : openAiBody,
|
||||
});
|
||||
}
|
||||
|
||||
export function isResponsesWebSearchOnlyRequest(body: Record<string, unknown>): boolean {
|
||||
return hasResponsesWebSearchOnlyRequest(body);
|
||||
}
|
||||
Reference in New Issue
Block a user