Prepare protocol compatibility release

This commit is contained in:
cita-777
2026-05-08 00:32:50 +08:00
28 changed files with 2205 additions and 108 deletions
@@ -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 });
});
});
+166
View File
@@ -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');
}
});
});
+163
View File
@@ -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',
};
}
+69 -1
View File
@@ -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);
}