feat(plugins): add hosted web search client
This commit is contained in:
1 parent
f3874e9070
commit
fc68cf2951
9 files changed
+1558
No files matched your search
@@ -0,0 +1,123 @@
|
||||
# Task: Implement MLW-02 Web Search Main client and adapter
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260901-web-search-mlw02-client-c8e4a2d1
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260901-web-search-mlw02-client-c8e4a2d1-web-search-mlw02-client
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-web-search-mlw02-client-c8e4a2d1
|
||||
- Base commit: f3874e90706692094f0f22fca465923143e23c7e
|
||||
- Owner: web-search-mlw02-implementer
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Implement MLW-02 from exact client coordinator frontier
|
||||
`f3874e90706692094f0f22fca465923143e23c7e` and frozen server DTO/context
|
||||
frontier `a49c696ebc4213e3d62ece780961efbe17576f8e`.
|
||||
- Add the Main-only `electron/services/web-search-client.ts` with closed input,
|
||||
fixed route, Works authentication/one-refresh behavior, bounded DTO parsing,
|
||||
same-operation reconciliation, and honest billing receipt projection.
|
||||
- Add `electron/coding-plugins/adapters/web-search.ts` as the code-owned hosted
|
||||
adapter. It validates closed input and confirmation, invokes the shared MLW-01
|
||||
admission resolver, supplies trusted project/request identity, and maps server
|
||||
outcomes without exposing Provider details or calculating Token Points.
|
||||
- Make only the minimal shared contract changes required by the new
|
||||
`platform_metered/not_started` and Main-only `receipt_unavailable` billing
|
||||
statuses: `shared/data-service.ts`,
|
||||
`shared/coding-conversation-product-tool-protocol.ts`, and the Registry billing
|
||||
validator in `electron/coding-plugins/registry.ts`.
|
||||
- Add focused Web Search, billing/parser, adapter, and conversation contract
|
||||
tests within the ticket's owned test paths.
|
||||
- Do not modify Renderer, composition, Pi worker/resource, Package Store,
|
||||
effective resolver, server, MLW-03 paths, or any user/coordinator worktree.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Project Context Loaded: client `AGENTS.md`, the complete
|
||||
`maintain-project-docs` skill and document-system reference, TDD and
|
||||
implement-spec instructions, required project-memory entry points, peer task
|
||||
records, and Web Search design/implementation spec §12 were read before
|
||||
planning.
|
||||
- Concurrent Task Gate: Passed. `task_context.py start` created this isolated
|
||||
feature task from the exact coordinator frontier; status identity matches the
|
||||
task ID, owner, mode, branch, absolute worktree, and base. The user root and
|
||||
client coordinator worktrees remain untouched.
|
||||
- Planning Gate: Passed. The older broad Web Search coordinator/integration and
|
||||
completed MLW-01 records were inspected read-only. MLW-01 is the only direct
|
||||
dependency and its exact source commit is integrated at this task's base;
|
||||
this task owns different files. No unresolved semantic conflict remains.
|
||||
- Frozen server context is the typed Web Search route/receipt contract at
|
||||
`a49c696ebc4213e3d62ece780961efbe17576f8e`; client code must not invent
|
||||
Provider/model/key/URL/payload authority or a generic invoke surface.
|
||||
- Main retains Works credentials, route, logical IDs, reconciliation and raw
|
||||
server receipt projection. The client never calculates points, exposes query
|
||||
beyond the typed request boundary, or forwards Provider response/header/raw
|
||||
error data to Pi/Renderer.
|
||||
- Transport uncertainty and `reserved`/`dispatched` responses reuse the same
|
||||
logical operation and exact request body for one bounded window of at most
|
||||
155 seconds. A 429 is non-retryable and preserves only bounded
|
||||
`Retry-After`; a new user confirmation must create a new logical operation.
|
||||
- TDD vertical slices: closed parser/fixed request, auth refresh, reconciliation
|
||||
and receipt states, adapter mappings, then shared billing/protocol validator
|
||||
changes and regression gates. One source commit with this task record,
|
||||
clean worktree, and `READY_FOR_INTEGRATION` handoff are required. No push,
|
||||
PR, deploy, publish, or real OpenAI call.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added the Main-only `WebSearchClient` with the exact typed hosted route,
|
||||
closed request/response parsing, one Works-token refresh, whole-response
|
||||
bounds, bounded same-operation reconciliation, and honest projection of the
|
||||
server-owned receipt. Transport ambiguity and in-progress receipts reuse the
|
||||
same logical operation/body for at most 155 seconds; an exhausted window
|
||||
returns only `plugin_receipt_unavailable` and never invents an amount.
|
||||
- Added the code-owned Web Search Plugin adapter. It validates the exact
|
||||
`query`/`confirmed` input, resolves the frozen Release admission through the
|
||||
MLW-01 helper, and supplies only trusted durable project/request identity to
|
||||
Main. Complete `succeeded` and result-bearing `pending_review` responses are
|
||||
usable; result-less `submission_unknown`, 429, admission/auth/input failures,
|
||||
and receipt uncertainty retain their bounded business code and billing state.
|
||||
- Extended the shared billing union, Registry validator, and conversation
|
||||
parser only for Main-owned `platform_metered/receipt_unavailable` while
|
||||
retaining the existing `platform_metered/not_started` state for every
|
||||
pre-transaction failure.
|
||||
- Final review corrected two frozen-contract mismatches: source title/URL
|
||||
bounds now exactly match Server `240/4096`, and the adapter preserves
|
||||
MLW-01 `plugin_release_unavailable` / `plugin_account_changed` admission
|
||||
failures instead of collapsing them into a generic backend error.
|
||||
- No composition, Renderer, Pi worker/resource, Package Store, effective
|
||||
resolver, Server, Provider/model/key/URL authority, generic invoke, deploy,
|
||||
publish, or live Provider call was added.
|
||||
|
||||
## Verification
|
||||
|
||||
- Concurrent/Planning Gate identity remained exact after recovery: same task,
|
||||
owner, worktree, branch, and base `f3874e90706692094f0f22fca465923143e23c7e`;
|
||||
the coordinator and user root worktrees were not modified.
|
||||
- The original delegated turn was interrupted after preserving the complete
|
||||
owned test/product diff but before returning its red/green ledger. A second
|
||||
delegated recovery also stalled without changing files. The parent
|
||||
coordinator then became the sole writer in this same owned worktree; no
|
||||
task release, rebuild, reset, stash, clean, or concurrent edit occurred.
|
||||
- First observable recovered focused run: 3 files / 32 tests passed. Contract
|
||||
review regressions for exact source bounds and both admission failure codes
|
||||
brought the final focused result to 3 files / 35 tests passed.
|
||||
- Adjacent hosted admission, Game Resource, Registry, conversation,
|
||||
Marketplace-client and timeline regression: 11 files / 114 tests passed.
|
||||
- `pnpm run typecheck` passed. Owned-file ESLint passed. Full
|
||||
`pnpm run lint:check` passed with zero errors and the unchanged five warnings
|
||||
in Home/Makelore outside this task.
|
||||
- Full unit suite passed: 214 files / 1748 tests, 2 staged-runtime skips; the
|
||||
pressure suite passed 1/1. `pnpm run build:vite` passed Renderer, Main,
|
||||
Preload, and utility builds with only existing chunk/dynamic-import notices.
|
||||
- Final diff, project-docs, document-drift, task-context completion, sole-parent,
|
||||
and clean-worktree facts are recorded by the source commit handoff.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- None recorded.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }>;
|
||||
@@ -192,6 +192,11 @@ function capabilityBilling(value: unknown): CapabilityBillingReceiptV1 | null {
|
||||
? { mode: 'external_account', status: 'external' }
|
||||
: null;
|
||||
}
|
||||
if (billing.mode === 'platform_metered' && billing.status === 'receipt_unavailable') {
|
||||
return exactKeys(billing, ['mode', 'status'])
|
||||
? { mode: 'platform_metered', status: 'receipt_unavailable' }
|
||||
: null;
|
||||
}
|
||||
if (billing.mode !== 'platform_metered' || typeof billing.reserved_points !== 'string'
|
||||
|| !CAPABILITY_DECIMAL_PATTERN.test(billing.reserved_points)
|
||||
|| billing.reserved_points.length > 32) return null;
|
||||
|
||||
@@ -100,6 +100,11 @@ export type CapabilityBillingReceiptV1 =
|
||||
actual_points: string;
|
||||
usage_amount?: number;
|
||||
unit?: string;
|
||||
}
|
||||
| {
|
||||
/** Main-only projection used when a possibly-dispatched receipt cannot be read. */
|
||||
mode: 'platform_metered';
|
||||
status: 'receipt_unavailable';
|
||||
};
|
||||
|
||||
export interface CapabilityResultV1<T = unknown> {
|
||||
|
||||
@@ -106,6 +106,33 @@ describe('Conversation product contracts', () => {
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips the Main-only receipt-unavailable billing state without an amount', () => {
|
||||
const envelope = {
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.web-search',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'web-search.search',
|
||||
operation: 'search',
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'plugin_receipt_unavailable',
|
||||
error: 'Web Search billing status could not be synchronized; do not retry automatically',
|
||||
retryable: false,
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
payload_schema: 'web-search.v1',
|
||||
data: null,
|
||||
};
|
||||
expect(productToolDetails(envelope)).toMatchObject({
|
||||
plugin_id: 'makelore.web-search',
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
data: null,
|
||||
});
|
||||
expect(productToolDetails({ ...envelope, billing: {
|
||||
mode: 'platform_metered', status: 'receipt_unavailable', reserved_points: '0.00',
|
||||
} })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts schema v1 snapshots and fail-closes unknown schemas until replacement', () => {
|
||||
const snapshot = createProductSnapshot();
|
||||
expect(isConversationSnapshot(snapshot)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
WebSearchClient,
|
||||
WebSearchClientError,
|
||||
} from '../../electron/services/web-search-client';
|
||||
|
||||
const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const ADMISSION_ID = 'admission-a';
|
||||
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const LOGICAL_OPERATION_ID = 'pi:run-a:resource-a';
|
||||
|
||||
function search(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
plugin_id: 'makelore.web-search',
|
||||
execution_id: '11111111-1111-4111-8111-111111111111',
|
||||
release_id: RELEASE_ID,
|
||||
logical_operation_id: LOGICAL_OPERATION_ID,
|
||||
status: 'succeeded',
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
search_queries: ['latest release'],
|
||||
error_code: null,
|
||||
retry_after_seconds: null,
|
||||
billing: {
|
||||
mode: 'platform_metered',
|
||||
status: 'settled',
|
||||
reserved_points: '1.00',
|
||||
actual_points: '1.00',
|
||||
usage_amount: 1,
|
||||
unit: 'search_request',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function input(query = 'latest release') {
|
||||
return {
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: ADMISSION_ID,
|
||||
projectId: PROJECT_ID,
|
||||
logicalOperationId: LOGICAL_OPERATION_ID,
|
||||
query,
|
||||
confirmed: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebSearchClient', () => {
|
||||
it('posts the closed request, refreshes one 401, and projects the server receipt', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(jsonResponse(search(), 200));
|
||||
const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => (
|
||||
options?.forceRefresh ? 'fresh-token' : 'stale-token'
|
||||
));
|
||||
const client = new WebSearchClient({
|
||||
apiBaseUrl: 'https://works.example/',
|
||||
fetchImpl,
|
||||
getAccessToken: getAccessToken as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).resolves.toEqual({
|
||||
executionId: '11111111-1111-4111-8111-111111111111',
|
||||
releaseId: RELEASE_ID,
|
||||
logicalOperationId: LOGICAL_OPERATION_ID,
|
||||
status: 'succeeded',
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
searchQueries: ['latest release'],
|
||||
errorCode: null,
|
||||
billing: {
|
||||
mode: 'platform_metered',
|
||||
status: 'settled',
|
||||
reserved_points: '1.00',
|
||||
actual_points: '1.00',
|
||||
usage_amount: 1,
|
||||
unit: 'search_request',
|
||||
},
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe(
|
||||
'https://works.example/api/plugins/v1/hosted/web-search/searches',
|
||||
);
|
||||
expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: { Authorization: 'Bearer stale-token', 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
release_id: RELEASE_ID,
|
||||
release_admission_id: ADMISSION_ID,
|
||||
project_id: PROJECT_ID,
|
||||
logical_operation_id: LOGICAL_OPERATION_ID,
|
||||
query: 'latest release',
|
||||
confirmed: true,
|
||||
});
|
||||
expect(fetchImpl.mock.calls[1]?.[1]).toMatchObject({
|
||||
headers: { Authorization: 'Bearer fresh-token' },
|
||||
});
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
||||
});
|
||||
|
||||
it('rejects oversized input and unknown response fields without a request', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({ extra: true })));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input('x'.repeat(2_001)))).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_input_invalid', status: 422, retryable: false,
|
||||
});
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the frozen server source title and URL bounds exactly', async () => {
|
||||
const maximumUrl = `https://example.test/${'x'.repeat(4_096 - 'https://example.test/'.length)}`;
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
sources: [{ title: 't'.repeat(240), url: maximumUrl }],
|
||||
}))),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).resolves.toMatchObject({
|
||||
sources: [{ title: 't'.repeat(240), url: maximumUrl }],
|
||||
});
|
||||
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
sources: [{ title: 't'.repeat(241), url: 'https://example.test/source' }],
|
||||
}))),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves only a bounded Retry-After for a known rate limit', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
{ detail: { error_code: 'web_search_rate_limited', message: 'try later' } },
|
||||
429,
|
||||
{ 'retry-after': '30' },
|
||||
));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'web_search_rate_limited', status: 429, retryable: false, retryAfterSeconds: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects a server-side rate-limit result and does not trust Retry-After on other statuses', async () => {
|
||||
const rateLimited = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
status: 'failed',
|
||||
answer: null,
|
||||
sources: [],
|
||||
search_queries: [],
|
||||
error_code: 'web_search_rate_limited',
|
||||
retry_after_seconds: 30,
|
||||
billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' },
|
||||
})));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: rateLimited,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
await expect(client.search(input())).resolves.toMatchObject({
|
||||
status: 'failed', errorCode: 'web_search_rate_limited', retryAfterSeconds: 30,
|
||||
billing: { status: 'released' },
|
||||
});
|
||||
|
||||
const unavailable = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
{ detail: { error_code: 'plugin_provider_unavailable', message: 'private provider text' } },
|
||||
503,
|
||||
{ 'retry-after': '30' },
|
||||
));
|
||||
const unavailableClient = new WebSearchClient({
|
||||
fetchImpl: unavailable,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
await expect(unavailableClient.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_provider_unavailable', status: 503, retryable: true,
|
||||
retryAfterSeconds: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed on a result-bearing status with a mismatched HTTP status', async () => {
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search(), 202)),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts omitted nullable/default response fields while keeping the object closed', async () => {
|
||||
const payload = search();
|
||||
delete payload.answer;
|
||||
delete payload.sources;
|
||||
delete payload.search_queries;
|
||||
delete payload.error_code;
|
||||
delete payload.retry_after_seconds;
|
||||
payload.status = 'failed';
|
||||
payload.billing = {
|
||||
mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request',
|
||||
};
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(payload)),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).resolves.toMatchObject({
|
||||
status: 'failed', answer: null, sources: [], searchQueries: [], errorCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles the same operation after in-progress and transport responses', async () => {
|
||||
let clock = 0;
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse(search({
|
||||
status: 'dispatched',
|
||||
answer: null,
|
||||
sources: [],
|
||||
search_queries: [],
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'dispatched', reserved_points: '1.00',
|
||||
usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
}), 202, { 'retry-after': '1' }))
|
||||
.mockRejectedValueOnce(new TypeError('connection lost'))
|
||||
.mockResolvedValueOnce(jsonResponse(search()));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
});
|
||||
|
||||
await expect(client.search(input())).resolves.toMatchObject({ status: 'succeeded' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(String(fetchImpl.mock.calls[0]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[1]?.[1]?.body));
|
||||
expect(String(fetchImpl.mock.calls[1]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[2]?.[1]?.body));
|
||||
});
|
||||
|
||||
it('returns receipt_unavailable when the bounded reconciliation window is exhausted', async () => {
|
||||
let clock = 0;
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new TypeError('offline'));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
now: () => clock,
|
||||
sleep: async () => { clock = 155_000; },
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_receipt_unavailable', status: 503, retryable: false,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CodingPluginToolDefinition } from '../../shared/coding-plugins';
|
||||
import {
|
||||
WebSearchPluginAdapter,
|
||||
} from '../../electron/coding-plugins/adapters/web-search';
|
||||
import {
|
||||
MarketplaceHostedAdmissionError,
|
||||
} from '../../electron/coding-plugins/hosted-admission';
|
||||
import type { MarketplaceHostedAdmissionResolver } from '../../electron/coding-plugins/hosted-admission';
|
||||
import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry';
|
||||
import {
|
||||
WebSearchClientError,
|
||||
type WebSearchClient,
|
||||
type WebSearchRead,
|
||||
} from '../../electron/services/web-search-client';
|
||||
|
||||
const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const REQUEST_ID = 'pi:run-a:resource-a';
|
||||
|
||||
const TOOL: CodingPluginToolDefinition = {
|
||||
name: 'makelore_web_search',
|
||||
label: 'Search the web',
|
||||
description: 'Search the web',
|
||||
capabilityId: 'web-search.search',
|
||||
operation: 'search',
|
||||
roles: ['parent'],
|
||||
mutation: 'read',
|
||||
projectWriteLease: false,
|
||||
permissions: ['hosted.web-search.search'],
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['query', 'confirmed'],
|
||||
properties: {
|
||||
query: { type: 'string', minLength: 1, maxLength: 2_000 },
|
||||
confirmed: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
executionMode: 'synchronous',
|
||||
};
|
||||
|
||||
const BILLING = {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'settled' as const,
|
||||
reserved_points: '1.00',
|
||||
actual_points: '1.00',
|
||||
usage_amount: 1,
|
||||
unit: 'search_request' as const,
|
||||
};
|
||||
|
||||
function response(overrides: Partial<WebSearchRead> = {}): WebSearchRead {
|
||||
return {
|
||||
executionId: '11111111-1111-4111-8111-111111111111',
|
||||
releaseId: RELEASE_ID,
|
||||
logicalOperationId: REQUEST_ID,
|
||||
status: 'succeeded',
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
searchQueries: ['latest release'],
|
||||
errorCode: null,
|
||||
billing: BILLING,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toolContext(): TrustedCodingCapabilityContext {
|
||||
return {
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
resourceId: 'resource-a',
|
||||
requestId: REQUEST_ID,
|
||||
localProjectId: 'local-project',
|
||||
projectPath: 'C:/project',
|
||||
durableProjectId: PROJECT_ID,
|
||||
workerRole: 'parent',
|
||||
effectiveSkillIds: ['makelore-web-search'],
|
||||
pluginReleaseId: RELEASE_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(search: ReturnType<typeof vi.fn> = vi.fn(async () => response())) {
|
||||
const resolve = vi.fn(async () => ({
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: 'admission-a',
|
||||
}));
|
||||
const admissionResolver = { resolve } as unknown as MarketplaceHostedAdmissionResolver;
|
||||
const client = { search } as unknown as WebSearchClient;
|
||||
const adapter = new WebSearchPluginAdapter({ client, admissionResolver });
|
||||
return { adapter, client, resolve };
|
||||
}
|
||||
|
||||
describe('WebSearchPluginAdapter', () => {
|
||||
it('uses trusted project/request/admission context and projects a successful result', async () => {
|
||||
const search = vi.fn(async () => response());
|
||||
const { adapter, client, resolve } = fixture(search);
|
||||
|
||||
const result = await adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release',
|
||||
confirmed: true,
|
||||
});
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: 'admission-a',
|
||||
projectId: PROJECT_ID,
|
||||
logicalOperationId: REQUEST_ID,
|
||||
query: 'latest release',
|
||||
confirmed: true,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
status: 200,
|
||||
payload_schema: 'web-search.v1',
|
||||
billing: BILLING,
|
||||
data: {
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
searchQueries: ['latest release'],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('provider');
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps a complete pending-review result usable without claiming settlement', async () => {
|
||||
const { adapter } = fixture(vi.fn(async () => response({
|
||||
status: 'pending_review',
|
||||
billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request' },
|
||||
})));
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
status: 202,
|
||||
billing: { mode: 'platform_metered', status: 'pending_review' },
|
||||
data: { answer: 'The answer' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resolve admission or call the client before confirmation', async () => {
|
||||
const search = vi.fn(async () => response());
|
||||
const { adapter, client, resolve } = fixture(search);
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: false,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 400,
|
||||
code: 'confirmation_required',
|
||||
billing: { mode: 'platform_metered', status: 'not_started' },
|
||||
data: null,
|
||||
});
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves admission failures as not-started business failures', async () => {
|
||||
const admissionResolver = {
|
||||
resolve: vi.fn(async () => {
|
||||
throw new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, 'stale');
|
||||
}),
|
||||
} as unknown as MarketplaceHostedAdmissionResolver;
|
||||
const search = vi.fn(async () => response());
|
||||
const adapter = new WebSearchPluginAdapter({
|
||||
client: { search } as unknown as WebSearchClient,
|
||||
admissionResolver,
|
||||
});
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: 'plugin_runtime_stale',
|
||||
billing: { mode: 'platform_metered', status: 'not_started' },
|
||||
});
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plugin_release_unavailable', 409],
|
||||
['plugin_account_changed', 409],
|
||||
] as const)('preserves the typed %s admission failure', async (code, status) => {
|
||||
const admissionResolver = {
|
||||
resolve: vi.fn(async () => {
|
||||
throw new MarketplaceHostedAdmissionError(code, status, false, 'private admission detail');
|
||||
}),
|
||||
} as unknown as MarketplaceHostedAdmissionResolver;
|
||||
const search = vi.fn(async () => response());
|
||||
const adapter = new WebSearchPluginAdapter({
|
||||
client: { search } as unknown as WebSearchClient,
|
||||
admissionResolver,
|
||||
});
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
billing: { mode: 'platform_metered', status: 'not_started' },
|
||||
});
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps result-less submission unknown to a non-retryable null payload', async () => {
|
||||
const { adapter } = fixture(vi.fn(async () => response({
|
||||
status: 'submission_unknown',
|
||||
answer: null,
|
||||
sources: [],
|
||||
searchQueries: [],
|
||||
errorCode: 'web_search_submission_unknown',
|
||||
billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request' },
|
||||
})));
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'web_search_submission_unknown',
|
||||
retryable: false,
|
||||
billing: { mode: 'platform_metered', status: 'pending_review' },
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps rate limiting to 429 while preserving only the bounded Retry-After', async () => {
|
||||
const { adapter } = fixture(vi.fn(async () => response({
|
||||
status: 'failed',
|
||||
answer: null,
|
||||
sources: [],
|
||||
searchQueries: [],
|
||||
errorCode: 'web_search_rate_limited',
|
||||
retryAfterSeconds: 30,
|
||||
billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' },
|
||||
})));
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 429,
|
||||
code: 'web_search_rate_limited',
|
||||
retryable: false,
|
||||
retry_after_seconds: 30,
|
||||
billing: { mode: 'platform_metered', status: 'released' },
|
||||
});
|
||||
});
|
||||
|
||||
it('projects receipt-unavailable without inventing an amount', async () => {
|
||||
const search = vi.fn(async () => {
|
||||
throw new WebSearchClientError(
|
||||
'plugin_receipt_unavailable',
|
||||
503,
|
||||
false,
|
||||
'Web Search billing status could not be synchronized; do not retry automatically',
|
||||
);
|
||||
});
|
||||
const { adapter } = fixture(search);
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'plugin_receipt_unavailable',
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not project provider-shaped error text or client-supplied authority fields', async () => {
|
||||
const search = vi.fn(async () => {
|
||||
throw new WebSearchClientError(
|
||||
'plugin_provider_unavailable',
|
||||
503,
|
||||
true,
|
||||
'https://provider.example model=secret-model key=secret-key',
|
||||
);
|
||||
});
|
||||
const { adapter } = fixture(search);
|
||||
const result = await adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
projectId: 'attacker-project', logicalOperationId: 'attacker-operation',
|
||||
});
|
||||
expect(result).toMatchObject({ success: false, code: 'plugin_input_invalid', status: 422 });
|
||||
expect(JSON.stringify(result)).not.toContain('provider.example');
|
||||
expect(JSON.stringify(result)).not.toContain('secret-key');
|
||||
|
||||
const normal = await adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
});
|
||||
expect(normal).toMatchObject({
|
||||
success: false,
|
||||
code: 'plugin_provider_unavailable',
|
||||
status: 503,
|
||||
error: 'Web Search Provider is unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user