feat(plugins): add hosted web search client

This commit is contained in:
2026-09-01 02:03:17 +08:00
parent f3874e9070
commit fc68cf2951
9 changed files with 1558 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins';
import type { CapabilityBillingReceiptV1 } from '../../../shared/data-service';
import {
MarketplaceHostedAdmissionError,
MarketplaceHostedAdmissionResolver,
} from '../hosted-admission';
import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store';
import type {
AdapterInvocationResult,
CodingPluginAdapter,
PluginBackendProjection,
TrustedCodingCapabilityContext,
} from '../registry';
import {
WebSearchClientError,
type WebSearchClient,
type WebSearchRead,
} from '../../services/web-search-client';
const PLUGIN_ID = 'makelore.web-search';
const TOOL_NAME = 'makelore_web_search';
const CAPABILITY_ID = 'web-search.search';
const OPERATION = 'search';
const MAX_QUERY_LENGTH = 2_000;
const MAX_RETRY_AFTER_SECONDS = 86_400;
const SAFE_ERROR_CODES = new Set([
'authentication_required',
'confirmation_required',
'plugin_backend_invalid',
'plugin_backend_unavailable',
'plugin_backend_response_too_large',
'plugin_billing_unavailable',
'plugin_execution_unavailable',
'plugin_operation_conflict',
'plugin_account_changed',
'plugin_provider_unavailable',
'plugin_receipt_unavailable',
'plugin_release_admission_required',
'plugin_release_unavailable',
'plugin_reservation_expired',
'plugin_reservation_unavailable',
'plugin_runtime_stale',
'token_point_balance_exhausted',
'web_search_provider_rejected',
'web_search_rate_limited',
'web_search_request_invalid',
'web_search_result_invalid',
'web_search_submission_unknown',
]);
type Input = Record<string, unknown>;
type MeteredBilling = Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;
const NOT_STARTED: MeteredBilling = { mode: 'platform_metered', status: 'not_started' };
const RECEIPT_UNAVAILABLE: MeteredBilling = {
mode: 'platform_metered',
status: 'receipt_unavailable',
};
export interface WebSearchPluginAdapterOptions {
readonly client: WebSearchClient;
readonly marketplace?: MarketplacePackageClientPort;
readonly packageStore?: Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>;
readonly makeloreVersion?: string;
readonly admissionResolver?: MarketplaceHostedAdmissionResolver;
}
function isRecord(value: unknown): value is Input {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function exactInput(value: Input): boolean {
const keys = Object.keys(value);
return keys.length === 2 && keys.includes('query') && keys.includes('confirmed');
}
function validRetryAfter(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0
&& (value as number) <= MAX_RETRY_AFTER_SECONDS;
}
function safeErrorMessage(code: string, status: number): string {
switch (code) {
case 'confirmation_required': return 'Explicit Token Point Web Search confirmation is required';
case 'plugin_input_invalid': return 'Web Search input is invalid';
case 'plugin_release_admission_required': return 'Web Search Release admission is required';
case 'plugin_release_unavailable': return 'Web Search Release is unavailable';
case 'plugin_account_changed': return 'Marketplace account changed while resolving Web Search admission';
case 'plugin_runtime_stale': return 'Web Search worker resources are stale';
case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request';
case 'token_point_balance_exhausted': return 'Token Point balance is insufficient';
case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable';
case 'web_search_provider_rejected': return 'Web Search Provider rejected the request';
case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later';
case 'web_search_submission_unknown': return 'Web Search submission status is unknown; do not retry automatically';
case 'plugin_receipt_unavailable': return 'Web Search billing status could not be synchronized; do not retry automatically';
case 'plugin_backend_invalid': return 'Web Search service returned an invalid response';
case 'plugin_backend_response_too_large': return 'Web Search service returned an oversized response';
case 'plugin_billing_unavailable': return 'Web Search billing is unavailable';
case 'authentication_required': return 'Works Square sign-in is required';
default: return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is temporarily unavailable';
}
}
function safeErrorCode(code: unknown, fallback = 'plugin_backend_unavailable'): string {
return typeof code === 'string' && SAFE_ERROR_CODES.has(code) ? code : fallback;
}
function failure(
code: string,
error: string,
status: number,
retryable: boolean,
billing: MeteredBilling = NOT_STARTED,
retryAfterSeconds?: number,
): AdapterInvocationResult {
return {
success: false,
status,
code,
error,
retryable,
...(validRetryAfter(retryAfterSeconds) ? { retry_after_seconds: retryAfterSeconds } : {}),
payload_schema: 'web-search.v1',
data: null,
billing,
};
}
function resultData(result: WebSearchRead): {
answer: string;
sources: readonly { title: string; url: string }[];
searchQueries: readonly string[];
} | null {
if (typeof result.answer !== 'string' || result.answer.length === 0
|| result.answer.length > 16_000 || !Array.isArray(result.sources)
|| result.sources.length > 20 || !Array.isArray(result.searchQueries)
|| result.searchQueries.length > 8) return null;
return {
answer: result.answer,
sources: result.sources,
searchQueries: result.searchQueries,
};
}
function project(result: WebSearchRead): AdapterInvocationResult {
if (result.status === 'succeeded' || result.status === 'pending_review') {
const data = resultData(result);
if (!data) {
return failure('plugin_backend_invalid', 'Web Search result is incomplete', 502, false);
}
return {
success: true,
status: result.status === 'pending_review' ? 202 : 200,
code: null,
error: null,
retryable: false,
payload_schema: 'web-search.v1',
data,
billing: result.billing,
};
}
if (result.status === 'submission_unknown') {
return failure(
safeErrorCode(result.errorCode, 'web_search_submission_unknown'),
'Web Search submission status is unknown; do not retry automatically',
503,
false,
result.billing,
);
}
if (result.status === 'failed') {
const rateLimited = result.errorCode === 'web_search_rate_limited';
return failure(
safeErrorCode(result.errorCode, 'web_search_request_invalid'),
rateLimited
? 'Web Search is rate limited; confirm a new search later'
: 'Web Search request was rejected',
rateLimited ? 429 : 422,
false,
result.billing,
rateLimited ? result.retryAfterSeconds : undefined,
);
}
return failure(
'plugin_receipt_unavailable',
'Web Search billing status could not be synchronized; do not retry automatically',
503,
false,
RECEIPT_UNAVAILABLE,
);
}
function clientFailure(error: unknown): AdapterInvocationResult {
if (error instanceof MarketplaceHostedAdmissionError) {
const code = safeErrorCode(error.code);
return failure(code, safeErrorMessage(code, error.status), error.status, error.retryable);
}
if (error instanceof WebSearchClientError) {
const code = safeErrorCode(error.code);
if (code === 'plugin_receipt_unavailable') {
return failure(code, safeErrorMessage(code, 503), 503, false, RECEIPT_UNAVAILABLE);
}
return failure(
code,
safeErrorMessage(code, error.status),
error.status,
error.retryable,
NOT_STARTED,
error.status === 429 ? error.retryAfterSeconds : undefined,
);
}
return failure(
'plugin_backend_unavailable',
'Web Search service is temporarily unavailable',
503,
true,
);
}
export class WebSearchPluginAdapter implements CodingPluginAdapter {
readonly pluginId = PLUGIN_ID;
private readonly admissionResolver: MarketplaceHostedAdmissionResolver;
constructor(private readonly options: WebSearchPluginAdapterOptions) {
if (options.admissionResolver) {
this.admissionResolver = options.admissionResolver;
} else if (options.marketplace && options.packageStore && options.makeloreVersion) {
this.admissionResolver = new MarketplaceHostedAdmissionResolver({
marketplace: options.marketplace,
packageStore: options.packageStore,
makeloreVersion: options.makeloreVersion,
});
} else {
throw new TypeError('Web Search adapter requires Marketplace admission dependencies');
}
}
async inspect(_projectPath: string): Promise<PluginBackendProjection> {
const installed = await this.options.packageStore?.getInstalled(PLUGIN_ID).catch(() => null);
return installed ? { status: 'ready' } : { status: 'unconfigured' };
}
async invoke(
context: TrustedCodingCapabilityContext,
tool: CodingPluginToolDefinition,
input: unknown,
): Promise<AdapterInvocationResult> {
if (tool.name !== TOOL_NAME || tool.capabilityId !== CAPABILITY_ID || tool.operation !== OPERATION) {
return failure('plugin_contract_unsupported', 'Web Search operation is unavailable', 503, true);
}
if (!isRecord(input) || !exactInput(input)
|| typeof input.query !== 'string' || !input.query.trim()
|| input.query.trim().length > MAX_QUERY_LENGTH) {
return failure('plugin_input_invalid', 'Web Search input is invalid', 422, false);
}
if (input.confirmed !== true) {
return failure(
'confirmation_required',
'Explicit Token Point Web Search confirmation is required',
400,
false,
);
}
let admission: { releaseId: string; releaseAdmissionId: string };
try {
admission = await this.admissionResolver.resolve({
pluginId: PLUGIN_ID,
workerSnapshot: {
requestId: context.requestId,
...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }),
},
});
} catch (error) {
return clientFailure(error);
}
try {
return project(await this.options.client.search({
releaseId: admission.releaseId,
releaseAdmissionId: admission.releaseAdmissionId,
projectId: context.durableProjectId,
logicalOperationId: context.requestId,
query: input.query.trim(),
confirmed: true,
}));
} catch (error) {
return clientFailure(error);
}
}
}
export function createWebSearchPluginAdapter(
options: WebSearchPluginAdapterOptions,
): WebSearchPluginAdapter {
return new WebSearchPluginAdapter(options);
}

View File

@@ -208,6 +208,9 @@ function validBillingReceipt(value: unknown): value is CapabilityBillingReceiptV
}
if (value.mode === 'included' && value.status === 'included') return keys.size === 2;
if (value.mode === 'external_account' && value.status === 'external') return keys.size === 2;
if (value.mode === 'platform_metered' && value.status === 'receipt_unavailable') {
return keys.size === 2;
}
if (value.mode !== 'platform_metered' || !validDecimal(value.reserved_points)) return false;
const common = new Set(['mode', 'status', 'reserved_points', 'actual_points', 'usage_amount', 'unit']);
if (keys.size !== [...keys].filter((key) => common.has(key)).length) return false;

View File

@@ -0,0 +1,521 @@
import { Buffer } from 'node:buffer';
import type { CapabilityBillingReceiptV1 } from '../../shared/data-service';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getValidWorksSquareAccessToken } from './works-square-session';
export const WEB_SEARCH_ROUTE = '/api/plugins/v1/hosted/web-search/searches';
export const WEB_SEARCH_RECONCILIATION_WINDOW_MS = 155_000;
const MAX_JSON_BYTES = 1_048_576;
const MAX_REQUEST_BYTES = 98_304;
const MAX_QUERY_LENGTH = 2_000;
const MAX_ANSWER_LENGTH = 16_000;
const MAX_SOURCE_TITLE_LENGTH = 240;
const MAX_SOURCE_URL_LENGTH = 4_096;
const MAX_SEARCH_QUERY_LENGTH = 500;
const MAX_RETRY_AFTER_SECONDS = 86_400;
const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u;
const SAFE_ID = /^[\x21-\x7e]{1,128}$/u;
const KNOWN_ERROR_CODES = new Set([
'authentication_required',
'confirmation_required',
'plugin_backend_unavailable',
'plugin_billing_unavailable',
'plugin_execution_unavailable',
'plugin_operation_conflict',
'plugin_provider_unavailable',
'plugin_release_admission_required',
'plugin_reservation_expired',
'plugin_reservation_unavailable',
'plugin_runtime_stale',
'token_point_balance_exhausted',
'web_search_provider_rejected',
'web_search_rate_limited',
'web_search_request_invalid',
'web_search_result_invalid',
'web_search_submission_unknown',
]);
type FetchImplementation = typeof fetch;
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
type JsonRecord = Record<string, unknown>;
type ServerBillingStatus =
| 'reserved'
| 'dispatched'
| 'settled'
| 'released'
| 'expired'
| 'pending_review'
| 'refunded';
export interface WebSearchRequest {
readonly releaseId: string;
readonly releaseAdmissionId: string;
readonly projectId: string;
readonly logicalOperationId: string;
readonly query: string;
readonly confirmed: true;
}
export type WebSearchInput = WebSearchRequest;
export interface WebSearchSource {
readonly title: string;
readonly url: string;
}
export interface WebSearchServerBillingReceipt {
readonly mode: 'platform_metered';
readonly status: ServerBillingStatus;
readonly reserved_points: string;
readonly actual_points?: string;
readonly usage_amount?: number;
readonly unit: 'search_request';
}
export interface WebSearchRead {
readonly executionId: string;
readonly releaseId: string;
readonly logicalOperationId: string;
readonly status: 'reserved' | 'dispatched' | 'succeeded' | 'failed' | 'submission_unknown' | 'pending_review';
readonly answer: string | null;
readonly sources: readonly WebSearchSource[];
readonly searchQueries: readonly string[];
readonly errorCode: string | null;
readonly retryAfterSeconds?: number;
readonly billing: WebSearchServerBillingReceipt;
}
export type WebSearchResult = WebSearchRead;
export class WebSearchClientError extends Error {
constructor(
readonly code: string,
readonly status: number,
readonly retryable: boolean,
message: string,
readonly retryAfterSeconds?: number,
) {
super(message);
this.name = 'WebSearchClientError';
}
}
export interface WebSearchClientOptions {
readonly fetchImpl?: FetchImplementation;
readonly getAccessToken?: AccessTokenGetter;
readonly apiBaseUrl?: string;
readonly now?: () => number;
readonly sleep?: (milliseconds: number) => Promise<void>;
}
class WebSearchTransportError extends Error {}
function isRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function exactKeys(value: JsonRecord, required: readonly string[], optional: readonly string[] = []): boolean {
const allowed = new Set([...required, ...optional]);
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key))
&& Object.keys(value).every((key) => allowed.has(key));
}
function boundedText(value: unknown, maximum: number): string | null {
return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null;
}
function boundedIdentifier(value: unknown, maximum: number): string | null {
return typeof value === 'string' && value.length > 0 && value.length <= maximum && SAFE_ID.test(value)
? value
: null;
}
function nonNegativeInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null {
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum
? value as number
: null;
}
function errorCode(value: unknown): string | null {
return typeof value === 'string' && KNOWN_ERROR_CODES.has(value) ? value : null;
}
function billing(value: unknown): WebSearchServerBillingReceipt {
if (!isRecord(value) || !exactKeys(
value,
['mode', 'status', 'reserved_points', 'unit'],
['actual_points', 'usage_amount'],
)) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid');
}
const status = value.status;
const reservedPoints = value.reserved_points;
const actualPoints = value.actual_points === null || value.actual_points === undefined
? undefined
: boundedText(value.actual_points, 32) ?? undefined;
const usageAmount = value.usage_amount === null || value.usage_amount === undefined
? undefined
: nonNegativeInteger(value.usage_amount) ?? undefined;
if (value.mode !== 'platform_metered'
|| typeof status !== 'string'
|| !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status)
|| typeof reservedPoints !== 'string'
|| !DECIMAL.test(reservedPoints)
|| (value.actual_points !== null && value.actual_points !== undefined && actualPoints === undefined)
|| (value.usage_amount !== null && value.usage_amount !== undefined && usageAmount === undefined)
|| (['settled', 'refunded'].includes(status) && actualPoints === undefined)
|| value.unit !== 'search_request') {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid');
}
return {
mode: 'platform_metered',
status: status as ServerBillingStatus,
reserved_points: reservedPoints,
...(actualPoints === undefined ? {} : { actual_points: actualPoints }),
...(usageAmount === undefined ? {} : { usage_amount: usageAmount }),
unit: 'search_request',
};
}
function source(value: unknown): WebSearchSource {
if (!isRecord(value) || !exactKeys(value, ['title', 'url'])) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid');
}
const title = boundedText(value.title, MAX_SOURCE_TITLE_LENGTH);
const url = boundedText(value.url, MAX_SOURCE_URL_LENGTH);
if (!title || !url) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid');
}
return { title, url };
}
function read(value: unknown, request: WebSearchRequest): WebSearchRead {
if (!isRecord(value) || !exactKeys(
value,
[
'schema_version', 'plugin_id', 'execution_id', 'release_id', 'logical_operation_id',
'status', 'billing',
],
['answer', 'sources', 'search_queries', 'error_code', 'retry_after_seconds'],
) || value.schema_version !== 1 || value.plugin_id !== 'makelore.web-search') {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
const executionId = boundedIdentifier(value.execution_id, 36);
const releaseId = boundedIdentifier(value.release_id, 36);
const logicalOperationId = boundedIdentifier(value.logical_operation_id, 128);
const statuses = ['reserved', 'dispatched', 'succeeded', 'failed', 'submission_unknown', 'pending_review'];
const status = typeof value.status === 'string' && statuses.includes(value.status) ? value.status : null;
const answer = value.answer === undefined || value.answer === null
? null
: boundedText(value.answer, MAX_ANSWER_LENGTH);
const rawSources = value.sources === undefined ? [] : value.sources;
const rawSearchQueries = value.search_queries === undefined ? [] : value.search_queries;
const retryAfter = value.retry_after_seconds === null || value.retry_after_seconds === undefined
? undefined
: nonNegativeInteger(value.retry_after_seconds, MAX_RETRY_AFTER_SECONDS) ?? undefined;
const parsedErrorCode = value.error_code === undefined || value.error_code === null
? null
: errorCode(value.error_code);
if (!executionId || !releaseId || !logicalOperationId || !status
|| releaseId !== request.releaseId || logicalOperationId !== request.logicalOperationId
|| (value.answer !== undefined && value.answer !== null && answer === null)
|| !Array.isArray(rawSources) || rawSources.length > 20
|| !Array.isArray(rawSearchQueries) || rawSearchQueries.length > 8
|| (value.error_code !== undefined && value.error_code !== null && parsedErrorCode === null)
|| (value.retry_after_seconds !== null && value.retry_after_seconds !== undefined && retryAfter === undefined)
|| (retryAfter !== undefined && (status !== 'failed' || parsedErrorCode !== 'web_search_rate_limited'))) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
const sources = rawSources.map(source);
const searchQueries = rawSearchQueries.map((item) => boundedText(item, MAX_SEARCH_QUERY_LENGTH));
if (searchQueries.some((item): item is null => item === null)) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
if (status === 'succeeded' || status === 'pending_review') {
if (!answer || (status === 'pending_review' && parsedErrorCode !== null)) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search result is incomplete');
}
} else if (answer !== null || sources.length > 0 || searchQueries.length > 0) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected result');
}
if ((status === 'succeeded' || status === 'reserved' || status === 'dispatched' || status === 'pending_review')
&& parsedErrorCode !== null) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected error');
}
return {
executionId,
releaseId,
logicalOperationId,
status: status as WebSearchRead['status'],
answer,
sources,
searchQueries: searchQueries as string[],
errorCode: parsedErrorCode,
...(retryAfter === undefined ? {} : { retryAfterSeconds: retryAfter }),
billing: billing(value.billing),
};
}
function retryAfterHeader(response: Response): number | undefined {
const raw = response.headers.get('retry-after');
if (!raw || !/^\d+$/u.test(raw)) return undefined;
return nonNegativeInteger(Number(raw), MAX_RETRY_AFTER_SECONDS) ?? undefined;
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declared = response.headers.get('content-length');
if (declared && /^\d+$/u.test(declared) && Number(declared) > MAX_JSON_BYTES) {
await response.body?.cancel().catch(() => undefined);
throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound');
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > MAX_JSON_BYTES) {
throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound');
}
if (bytes.byteLength === 0) return null;
try {
return JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown;
} catch {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
}
function requestBody(input: WebSearchRequest): string {
const normalized = {
release_id: input.releaseId,
release_admission_id: input.releaseAdmissionId,
project_id: input.projectId,
logical_operation_id: input.logicalOperationId,
query: input.query.trim(),
confirmed: true,
};
if (!normalized.query || normalized.query.length > MAX_QUERY_LENGTH
|| Buffer.byteLength(normalized.query, 'utf8') > MAX_REQUEST_BYTES) {
throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search query is invalid');
}
const encoded = JSON.stringify(normalized);
if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) {
throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search request is too large');
}
return encoded;
}
function validateInput(input: WebSearchRequest): WebSearchRequest {
const value = input as unknown as JsonRecord;
if (!isRecord(value) || !exactKeys(value, [
'releaseId', 'releaseAdmissionId', 'projectId', 'logicalOperationId', 'query', 'confirmed',
])) {
throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid');
}
for (const [key, maximum] of [
['releaseId', 36],
['releaseAdmissionId', 36],
['projectId', 36],
['logicalOperationId', 128],
] as const) {
if (!boundedIdentifier(value[key], maximum)) {
throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid');
}
}
if (typeof value.query !== 'string' || !value.query.trim() || value.query.trim().length > MAX_QUERY_LENGTH
|| value.confirmed !== true) {
throw new WebSearchClientError(
value.confirmed === false ? 'confirmation_required' : 'plugin_input_invalid',
value.confirmed === false ? 400 : 422,
false,
value.confirmed === false ? 'Explicit Web Search confirmation is required' : 'Web Search input is invalid',
);
}
return {
releaseId: value.releaseId as string,
releaseAdmissionId: value.releaseAdmissionId as string,
projectId: value.projectId as string,
logicalOperationId: value.logicalOperationId as string,
query: value.query as string,
confirmed: true,
};
}
function domainMessage(code: string, status: number): string {
switch (code) {
case 'confirmation_required': return 'Explicit Web Search confirmation is required';
case 'web_search_request_invalid': return 'Web Search request is invalid';
case 'plugin_release_admission_required': return 'Web Search Release admission is required';
case 'plugin_runtime_stale': return 'Web Search worker resources are stale';
case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request';
case 'token_point_balance_exhausted': return 'Token Point balance is insufficient';
case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable';
case 'web_search_provider_rejected': return 'Web Search Provider rejected the request';
case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later';
case 'plugin_billing_unavailable': return 'Web Search billing is unavailable';
default:
return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is unavailable';
}
}
async function domainError(response: Response): Promise<WebSearchClientError> {
let payload: unknown = null;
try {
payload = await readBoundedJson(response);
} catch (error) {
if (error instanceof WebSearchClientError) return error;
}
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
const code = errorCode(detail?.error_code) ?? (response.status === 429 ? 'web_search_rate_limited' : 'plugin_backend_unavailable');
const retryAfter = response.status === 429 ? retryAfterHeader(response) : undefined;
const retryable = response.status >= 500 && response.status !== 401;
return new WebSearchClientError(
code,
response.status,
retryable,
domainMessage(code, response.status),
retryAfter,
);
}
function receiptUnavailable(): WebSearchClientError {
return new WebSearchClientError(
'plugin_receipt_unavailable',
503,
false,
'Web Search billing status could not be synchronized; do not retry automatically',
);
}
function delayMilliseconds(response: Response): number {
const seconds = retryAfterHeader(response);
return (seconds === undefined ? 1 : seconds) * 1_000;
}
export class WebSearchClient {
private readonly fetchImpl: FetchImplementation;
private readonly getAccessToken: AccessTokenGetter;
private readonly apiBaseUrl: string;
private readonly now: () => number;
private readonly sleep: (milliseconds: number) => Promise<void>;
constructor(options: WebSearchClientOptions = {}) {
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken;
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, '');
this.now = options.now ?? (() => Date.now());
this.sleep = options.sleep ?? (async (milliseconds) => {
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
});
}
async search(input: WebSearchRequest): Promise<WebSearchRead> {
const valid = validateInput(input);
const encoded = requestBody(valid);
let token: string | null;
try {
token = await this.getAccessToken({ fetchImpl: this.fetchImpl });
} catch {
token = null;
}
if (!token) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required');
let refreshAttempted = false;
const request = async (): Promise<Response> => {
const send = async (accessToken: string): Promise<Response> => await this.fetchImpl(
`${this.apiBaseUrl}${WEB_SEARCH_ROUTE}`,
{
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: encoded,
redirect: 'manual',
signal: AbortSignal.timeout(35_000),
},
);
let response: Response;
try {
response = await send(token as string);
} catch {
throw new WebSearchTransportError('Web Search request transport failed');
}
if (response.status !== 401) return response;
await response.body?.cancel().catch(() => undefined);
if (refreshAttempted) {
throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required');
}
refreshAttempted = true;
let refreshed: string | null;
try {
refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true });
} catch {
refreshed = null;
}
if (!refreshed) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required');
token = refreshed;
try {
response = await send(refreshed);
} catch {
throw new WebSearchTransportError('Web Search request transport failed');
}
if (response.status === 401) {
await response.body?.cancel().catch(() => undefined);
throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required');
}
return response;
};
const deadline = this.now() + WEB_SEARCH_RECONCILIATION_WINDOW_MS;
while (true) {
let response: Response;
try {
response = await request();
} catch (error) {
if (error instanceof WebSearchClientError) throw error;
if (this.now() >= deadline) throw receiptUnavailable();
const remaining = deadline - this.now();
await this.sleep(Math.min(1_000, remaining));
continue;
}
if (response.status === 429) throw await domainError(response);
if (response.ok) {
let payload: unknown;
try {
payload = await readBoundedJson(response);
} catch (error) {
if (error instanceof WebSearchClientError) throw error;
if (this.now() >= deadline) throw receiptUnavailable();
const remaining = deadline - this.now();
await this.sleep(Math.min(1_000, remaining));
continue;
}
const result = read(payload, valid);
const shouldBeAccepted = result.status === 'reserved'
|| result.status === 'dispatched'
|| result.status === 'submission_unknown'
|| result.status === 'pending_review';
if ((shouldBeAccepted && response.status !== 202)
|| (!shouldBeAccepted && response.status !== 200)) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response status is invalid');
}
if (result.status !== 'reserved' && result.status !== 'dispatched') return result;
if (this.now() >= deadline) throw receiptUnavailable();
const remaining = deadline - this.now();
await this.sleep(Math.min(delayMilliseconds(response), remaining));
continue;
}
throw await domainError(response);
}
}
}
export function isWebSearchServerBillingReceipt(value: unknown): value is WebSearchServerBillingReceipt {
try {
billing(value);
return true;
} catch {
return false;
}
}
export type WebSearchCapabilityBilling = Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;