@arnilo/prism 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -1
- package/README.md +23 -20
- package/dist/agent-run-state.d.ts +1 -2
- package/dist/agent-run-state.js +0 -3
- package/dist/agent-session/session/assemble.d.ts +6 -0
- package/dist/agent-session/session/assemble.js +391 -0
- package/dist/agent-session/session/persist.d.ts +28 -0
- package/dist/agent-session/session/persist.js +166 -0
- package/dist/agent-session/session/provider-round.d.ts +6 -0
- package/dist/agent-session/session/provider-round.js +231 -0
- package/dist/agent-session/session/tool-round.d.ts +31 -0
- package/dist/agent-session/session/tool-round.js +473 -0
- package/dist/agent-session/session/types.d.ts +115 -0
- package/dist/agent-session/session/types.js +5 -0
- package/dist/agent-session/session.d.ts +49 -43
- package/dist/agent-session/session.js +11 -1177
- package/dist/capture.d.ts +63 -0
- package/dist/capture.js +67 -0
- package/dist/cli-init.d.ts +18 -2
- package/dist/cli-init.js +2 -7
- package/dist/cli-runner.d.ts +2 -2
- package/dist/cli-runner.js +45 -9
- package/dist/content.d.ts +3 -3
- package/dist/content.js +3 -1
- package/dist/contracts-core/agent.d.ts +2 -0
- package/dist/contracts-core/batch.d.ts +97 -0
- package/dist/contracts-core/batch.js +65 -0
- package/dist/contracts-core/content.d.ts +72 -1
- package/dist/contracts-core/embeddings.d.ts +30 -0
- package/dist/contracts-core/embeddings.js +17 -0
- package/dist/contracts-core/images.d.ts +60 -0
- package/dist/contracts-core/images.js +17 -0
- package/dist/contracts-core/moderation.d.ts +46 -0
- package/dist/contracts-core/moderation.js +34 -0
- package/dist/contracts-core/speech.d.ts +39 -0
- package/dist/contracts-core/speech.js +17 -0
- package/dist/contracts-core/transcription.d.ts +48 -0
- package/dist/contracts-core/transcription.js +17 -0
- package/dist/contracts-core/video.d.ts +61 -0
- package/dist/contracts-core/video.js +17 -0
- package/dist/contracts-core.d.ts +7 -0
- package/dist/contracts-core.js +7 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -3
- package/dist/node/agent-definitions.d.ts +1 -8
- package/dist/node/agent-definitions.js +0 -34
- package/dist/node/settings.d.ts +0 -1
- package/dist/node/settings.js +0 -5
- package/dist/pinned-fetch.js +29 -3
- package/dist/provider-events.js +3 -4
- package/dist/providers/media.d.ts +1 -2
- package/dist/providers/media.js +1 -4
- package/dist/rpc.d.ts +1 -1
- package/dist/rpc.js +4 -4
- package/dist/testing/provider-conformance.d.ts +114 -5
- package/dist/testing/provider-conformance.js +342 -0
- package/dist/testing/tool-effect-store-conformance.d.ts +0 -1
- package/dist/testing/tool-effect-store-conformance.js +0 -3
- package/dist/thinking.d.ts +48 -9
- package/dist/thinking.js +134 -8
- package/docs/0.1.0-readiness.md +3 -3
- package/docs/a2a.md +2 -2
- package/docs/acp.md +3 -3
- package/docs/ag-ui-adoption.md +1 -1
- package/docs/ag-ui.md +1 -2
- package/docs/agent-definitions.md +1 -1
- package/docs/agent-events.md +5 -5
- package/docs/agent-identity.md +13 -2
- package/docs/audit-export.md +3 -3
- package/docs/batch-jobs.md +120 -0
- package/docs/cli-rpc.md +20 -9
- package/docs/coding-agent-tools.md +19 -19
- package/docs/coding-review-and-diagnostics.md +2 -2
- package/docs/coding-security.md +4 -4
- package/docs/coding-workspaces.md +2 -2
- package/docs/computer-use-linux.md +13 -2
- package/docs/context-and-skills.md +1 -1
- package/docs/conversations.md +4 -4
- package/docs/credential-storage.md +11 -7
- package/docs/credentials-and-redaction.md +1 -1
- package/docs/data-classification.md +1 -1
- package/docs/database-persistence.md +4 -4
- package/docs/dev-inspector.md +6 -6
- package/docs/device-adapters.md +2 -2
- package/docs/diagrams.md +1 -1
- package/docs/document-reader.md +6 -6
- package/docs/documents.md +5 -4
- package/docs/embeddings.md +112 -0
- package/docs/enterprise-postgres-state.md +7 -7
- package/docs/evaluations.md +8 -8
- package/docs/extensions.md +3 -3
- package/docs/forge-integration.md +3 -3
- package/docs/graft.md +2 -2
- package/docs/guardrails.md +1 -1
- package/docs/host-security.md +15 -15
- package/docs/image-generation.md +129 -0
- package/docs/impeccable.md +5 -3
- package/docs/index.md +60 -33
- package/docs/indexed-code-search.md +2 -2
- package/docs/language-intelligence.md +4 -4
- package/docs/live-testing.md +126 -0
- package/docs/mcp-tools.md +43 -12
- package/docs/middleware-hooks.md +1 -1
- package/docs/migrate-to-0.4.md +3 -3
- package/docs/migrate-to-0.5.md +122 -0
- package/docs/migration.md +29 -1
- package/docs/model-registry.md +38 -0
- package/docs/model-routing.md +5 -5
- package/docs/moderation.md +117 -0
- package/docs/multi-agent-patterns.md +4 -4
- package/docs/multimodal-content.md +26 -2
- package/docs/obscura.md +2 -2
- package/docs/observability.md +32 -7
- package/docs/openapi-tools.md +13 -3
- package/docs/operations.md +11 -0
- package/docs/performance.md +7 -7
- package/docs/persistence-credentials-multimodality-primitives.md +6 -6
- package/docs/policy-and-audit.md +17 -7
- package/docs/ponytail.md +1 -1
- package/docs/postgres-persistence.md +5 -5
- package/docs/process-sessions.md +2 -2
- package/docs/prompt-registry.md +7 -7
- package/docs/provider-caching.md +4 -0
- package/docs/provider-conformance.md +23 -1
- package/docs/provider-packages.md +39 -3
- package/docs/provider-primitives.md +1 -1
- package/docs/provider-request-policies.md +1 -1
- package/docs/providers/ai-sdk.md +15 -3
- package/docs/providers/alibaba.md +5 -1
- package/docs/providers/anthropic.md +4 -0
- package/docs/providers/azure.md +17 -1
- package/docs/providers/bedrock.md +15 -0
- package/docs/providers/clinepass.md +4 -0
- package/docs/providers/commandcode.md +253 -0
- package/docs/providers/deepseek.md +4 -0
- package/docs/providers/google.md +4 -0
- package/docs/providers/hyper.md +284 -0
- package/docs/providers/kimi.md +4 -0
- package/docs/providers/neuralwatt.md +4 -0
- package/docs/providers/ollama.md +15 -0
- package/docs/providers/openai-compatible.md +4 -0
- package/docs/providers/openai.md +4 -0
- package/docs/providers/opencode-go.md +4 -0
- package/docs/providers/openrouter.md +5 -1
- package/docs/providers/vertex.md +16 -0
- package/docs/providers/xai.md +4 -0
- package/docs/providers/zai.md +4 -0
- package/docs/rag.md +26 -4
- package/docs/release-and-install.md +103 -46
- package/docs/resource-loading.md +1 -1
- package/docs/runs-and-usage.md +14 -2
- package/docs/server.md +5 -5
- package/docs/settings-auth-trust-security.md +7 -5
- package/docs/sheets.md +2 -2
- package/docs/speech.md +126 -0
- package/docs/sqlite-persistence.md +4 -4
- package/docs/supervisors.md +3 -3
- package/docs/thinking-and-reasoning.md +93 -60
- package/docs/tool-conformance.md +1 -1
- package/docs/tool-execution-primitives.md +8 -8
- package/docs/tools.md +4 -4
- package/docs/web-tools.md +1 -1
- package/docs/wiki.md +1 -1
- package/docs/work-artifacts-and-review.md +17 -6
- package/docs/work-connectors.md +4 -4
- package/docs/work-tools.md +5 -5
- package/docs/workflow-orchestration-primitives.md +11 -11
- package/docs/workflows.md +5 -5
- package/package.json +11 -8
- package/templates/init/providers.json +24 -8
- package/docs/antigravity-agent.md +0 -207
package/dist/pinned-fetch.js
CHANGED
|
@@ -99,7 +99,11 @@ export async function requestPinned(url, address, init, errorPrefix = "Request")
|
|
|
99
99
|
const nodeRequest = request(url, {
|
|
100
100
|
method,
|
|
101
101
|
headers: Object.fromEntries(headers.entries()),
|
|
102
|
-
|
|
102
|
+
// NOTE: the caller signal is NOT passed as the http.request `signal` option:
|
|
103
|
+
// Node's internal abort wiring destroys the (possibly keep-alive, already
|
|
104
|
+
// completed) socket with the abort reason, surfacing it as an unhandled
|
|
105
|
+
// socket 'error' when the response resolved and nothing listens anymore.
|
|
106
|
+
// Abort is handled manually below with a reason-less destroy.
|
|
103
107
|
lookup: ((_hostname, options, callback) => {
|
|
104
108
|
if (options.all)
|
|
105
109
|
callback(null, [{ address: address.address, family: address.family }]);
|
|
@@ -132,8 +136,13 @@ export async function requestPinned(url, address, init, errorPrefix = "Request")
|
|
|
132
136
|
controller.error(error);
|
|
133
137
|
}
|
|
134
138
|
},
|
|
135
|
-
cancel(
|
|
136
|
-
|
|
139
|
+
cancel() {
|
|
140
|
+
// Destroy WITHOUT the abort reason: once the response resolved,
|
|
141
|
+
// nothing listens on the socket, and propagate-destroy(reason)
|
|
142
|
+
// surfaces the DOMException as an unhandled socket 'error'
|
|
143
|
+
// (SDK transports abort their controller on close()). Readers
|
|
144
|
+
// that still pull observe EOF via the iterator.
|
|
145
|
+
incoming.destroy();
|
|
137
146
|
},
|
|
138
147
|
});
|
|
139
148
|
resolve(new Response(stream, {
|
|
@@ -143,12 +152,29 @@ export async function requestPinned(url, address, init, errorPrefix = "Request")
|
|
|
143
152
|
}));
|
|
144
153
|
});
|
|
145
154
|
nodeRequest.on("error", reject);
|
|
155
|
+
if (init?.signal) {
|
|
156
|
+
const signal = init.signal;
|
|
157
|
+
if (signal.aborted) {
|
|
158
|
+
reject(abortReason(signal));
|
|
159
|
+
nodeRequest.destroy();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
signal.addEventListener("abort", () => {
|
|
163
|
+
nodeRequest.destroy();
|
|
164
|
+
reject(abortReason(signal));
|
|
165
|
+
}, { once: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
146
168
|
if (body)
|
|
147
169
|
nodeRequest.end(body);
|
|
148
170
|
else
|
|
149
171
|
nodeRequest.end();
|
|
150
172
|
});
|
|
151
173
|
}
|
|
174
|
+
function abortReason(signal) {
|
|
175
|
+
const reason = signal.reason;
|
|
176
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
177
|
+
}
|
|
152
178
|
async function requestBody(body, errorPrefix) {
|
|
153
179
|
if (body === undefined || body === null)
|
|
154
180
|
return undefined;
|
package/dist/provider-events.js
CHANGED
|
@@ -27,10 +27,9 @@ export function reconstructToolCallDeltas(events) {
|
|
|
27
27
|
if (event.type !== "tool_call_delta")
|
|
28
28
|
continue;
|
|
29
29
|
const partial = partials.get(event.index) ?? { argumentsText: "" };
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
partial.name = event.name;
|
|
30
|
+
// Conformant OpenAI-compatible providers repeat identity as null on continuation chunks; coalesce like openai-compatible.js instead of clobbering.
|
|
31
|
+
partial.id = event.id ?? partial.id;
|
|
32
|
+
partial.name = event.name ?? partial.name;
|
|
34
33
|
if (event.argumentsText !== undefined)
|
|
35
34
|
partial.argumentsText += event.argumentsText;
|
|
36
35
|
partials.set(event.index, partial);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MediaContentBlock, type ModelInputCapability, type ResolvedMediaContent, type ResolveMediaContentOptions } from "../content.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ContentBlock, JsonObject, Message, ModelCapabilities, ModelConfig } from "../contracts.js";
|
|
3
3
|
/** Default upload-cache entry cap per provider media session. */
|
|
4
4
|
export declare const DEFAULT_PROVIDER_UPLOAD_CACHE_ENTRIES = 32;
|
|
5
5
|
/** Inline OpenAI file_data ceiling before preferring Files API upload. */
|
|
@@ -37,7 +37,6 @@ export declare function serializePdfDocumentWireBlock(options: {
|
|
|
37
37
|
readonly data: string;
|
|
38
38
|
readonly title?: string;
|
|
39
39
|
}): JsonObject;
|
|
40
|
-
export declare function resolveProviderMediaBlock(block: AudioContent | FileContent | DocumentContent, options?: ResolveMediaContentOptions): Promise<ResolvedMediaContent>;
|
|
41
40
|
/** Resolve every media block once and enforce aggregate request bounds before provider I/O. */
|
|
42
41
|
export declare function resolveProviderMediaMessages(messages: readonly Message[], model: ModelConfig, options?: ResolveMediaContentOptions): Promise<ReadonlyMap<MediaContentBlock, ResolvedMediaContent>>;
|
|
43
42
|
export declare function defaultProviderFilename(block: MediaContentBlock, fallback: string): string;
|
package/dist/providers/media.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { assertMessagesSupportModelCapabilities, contentBlockInputModality, DEFAULT_MAX_MEDIA_ITEM_BYTES,
|
|
2
|
+
import { assertMessagesSupportModelCapabilities, contentBlockInputModality, DEFAULT_MAX_MEDIA_ITEM_BYTES, resolveMediaContentBlocks, UnsupportedModalityError, } from "../content.js";
|
|
3
3
|
/** Default upload-cache entry cap per provider media session. */
|
|
4
4
|
export const DEFAULT_PROVIDER_UPLOAD_CACHE_ENTRIES = 32;
|
|
5
5
|
/** Inline OpenAI file_data ceiling before preferring Files API upload. */
|
|
@@ -93,9 +93,6 @@ export function serializePdfDocumentWireBlock(options) {
|
|
|
93
93
|
...(options.title ? { title: options.title } : {}),
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
-
export async function resolveProviderMediaBlock(block, options = {}) {
|
|
97
|
-
return resolveMediaContentBlock(block, options);
|
|
98
|
-
}
|
|
99
96
|
/** Resolve every media block once and enforce aggregate request bounds before provider I/O. */
|
|
100
97
|
export async function resolveProviderMediaMessages(messages, model, options = {}) {
|
|
101
98
|
assertMessagesSupportModelCapabilities(model, messages);
|
package/dist/rpc.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface RpcRequest {
|
|
|
8
8
|
readonly params?: Record<string, unknown>;
|
|
9
9
|
}
|
|
10
10
|
export interface RpcSessionFactory {
|
|
11
|
-
createSession(id?: string): AgentSession
|
|
11
|
+
createSession(id?: string): AgentSession | Promise<AgentSession>;
|
|
12
12
|
readonly commands?: readonly CommandDefinition[];
|
|
13
13
|
/** Host-opt-in driver capabilities forwarded to contributed commands on the
|
|
14
14
|
* `command` execution context. Absent ⇒ context shape unchanged. */
|
package/dist/rpc.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline";
|
|
|
2
2
|
import { resolveInstructionInjectors } from "./instruction-injection.js";
|
|
3
3
|
import { errorToErrorInfo } from "./redaction.js";
|
|
4
4
|
export async function runRpcServer(options) {
|
|
5
|
-
const first = options.createSession();
|
|
5
|
+
const first = await options.createSession();
|
|
6
6
|
const state = {
|
|
7
7
|
current: first,
|
|
8
8
|
currentHandleId: first.id,
|
|
@@ -140,7 +140,7 @@ async function handleRequest(request, state, stdout, activeRuns) {
|
|
|
140
140
|
const handleId = stringParam(request.params, "handleId") ?? stringParam(request.params, "sessionId") ?? stringParam(request.params, "id");
|
|
141
141
|
if (!handleId)
|
|
142
142
|
throw new Error("switchSession requires params.handleId (or sessionId)");
|
|
143
|
-
const session = state.sessions.get(handleId) ?? makeSession(state, handleId);
|
|
143
|
+
const session = state.sessions.get(handleId) ?? (await makeSession(state, handleId));
|
|
144
144
|
state.current = session;
|
|
145
145
|
state.currentHandleId = handleId;
|
|
146
146
|
write(stdout, { id: request.id, ok: true, result: { sessionId: session.id, leafId: session.leafId, handleId } });
|
|
@@ -220,8 +220,8 @@ function registerSession(state, session, preferredHandleId) {
|
|
|
220
220
|
state.sessions.set(handleId, session);
|
|
221
221
|
return handleId;
|
|
222
222
|
}
|
|
223
|
-
function makeSession(state, id) {
|
|
224
|
-
const session = state.createSession(id);
|
|
223
|
+
async function makeSession(state, id) {
|
|
224
|
+
const session = await state.createSession(id);
|
|
225
225
|
state.sessions.set(session.id, session);
|
|
226
226
|
return session;
|
|
227
227
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AIProvider, ContentBlock, JsonObject, ProviderEvent, ProviderRequest, ToolCallContent, Usage } from "../contracts.js";
|
|
1
|
+
import type { AIProvider, BatchJobsProvider, BatchRequestItem, ContentBlock, EmbeddingsProvider, EmbeddingsResult, ImageGenerationProvider, ImageGenerationResult, JsonObject, ModerationProvider, ModerationResult, ProviderEvent, ProviderRequest, SpeechProvider, SpeechResult, ToolCallContent, TranscriptionProvider, TranscriptionResult, Usage, VideoGenerationJob, VideoGenerationProvider } from "../contracts.js";
|
|
2
2
|
export interface ProviderStreamConformanceOptions {
|
|
3
3
|
readonly provider: AIProvider;
|
|
4
4
|
readonly request: ProviderRequest;
|
|
@@ -29,10 +29,6 @@ export interface ProviderHeaderOwnershipConformanceOptions {
|
|
|
29
29
|
/** Caller-supplied headers, including attempts to override owned names and non-owned additions. */
|
|
30
30
|
readonly caller: Readonly<Record<string, string>>;
|
|
31
31
|
}
|
|
32
|
-
export interface ProviderSecretLeakConformanceOptions {
|
|
33
|
-
readonly events: readonly ProviderEvent[];
|
|
34
|
-
readonly secrets: readonly string[];
|
|
35
|
-
}
|
|
36
32
|
export declare function collectProviderEvents(provider: AIProvider, request: ProviderRequest): Promise<readonly ProviderEvent[]>;
|
|
37
33
|
export declare function assertProviderStreamConforms(options: ProviderStreamConformanceOptions): Promise<readonly ProviderEvent[]>;
|
|
38
34
|
export declare function assertAbortIsObserved(options: ProviderAbortConformanceOptions): Promise<void>;
|
|
@@ -51,3 +47,116 @@ export declare function assertNoForeignCacheFields(body: unknown, allowed?: read
|
|
|
51
47
|
/** Provider construction and setup must perform zero network calls; discovery and streams are caller-gated. */
|
|
52
48
|
export declare function assertNoFetches(calls: readonly unknown[]): void;
|
|
53
49
|
export declare function assertUsageAccounting(events: readonly ProviderEvent[], expected: Usage): Usage;
|
|
50
|
+
export interface EmbeddingsConformanceOptions {
|
|
51
|
+
readonly provider: EmbeddingsProvider;
|
|
52
|
+
readonly model: string;
|
|
53
|
+
/** When set, an oversized batch must fail with `EmbeddingsError("batch_too_large")`. */
|
|
54
|
+
readonly maxBatchSize?: number;
|
|
55
|
+
/** Happy-path mapping probe: inputs to embed and the expected vector count/dimensions. */
|
|
56
|
+
readonly sample?: {
|
|
57
|
+
readonly inputs: readonly string[];
|
|
58
|
+
readonly dimensions?: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Offline conformance for any `EmbeddingsProvider` (plan 061): typed empty-input
|
|
62
|
+
* error, typed oversized-batch error, and input-order vector mapping with finite
|
|
63
|
+
* coordinates. No network — the caller supplies the provider (real adapter with a
|
|
64
|
+
* fake transport, or a fake provider). */
|
|
65
|
+
export declare function runEmbeddingsConformance(options: EmbeddingsConformanceOptions): Promise<EmbeddingsResult | undefined>;
|
|
66
|
+
export interface SpeechConformanceOptions {
|
|
67
|
+
readonly provider: SpeechProvider;
|
|
68
|
+
readonly model: string;
|
|
69
|
+
/** When set, an oversized input must fail with `SpeechError("input_too_large")`. */
|
|
70
|
+
readonly maxInputChars?: number;
|
|
71
|
+
/** Happy-path probe: text to synthesize, optional voice/format. */
|
|
72
|
+
readonly sample?: {
|
|
73
|
+
readonly input?: string;
|
|
74
|
+
readonly voice?: string;
|
|
75
|
+
readonly format?: string;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Offline conformance for any `SpeechProvider` (plan 061): typed empty-input and
|
|
79
|
+
* oversized-input errors, byte results, and stream ordering — the stream must emit
|
|
80
|
+
* at least one chunk before closing. Asserts event order, never wall clock. */
|
|
81
|
+
export declare function runSpeechConformance(options: SpeechConformanceOptions): Promise<SpeechResult | undefined>;
|
|
82
|
+
export interface TranscriptionConformanceOptions {
|
|
83
|
+
readonly provider: TranscriptionProvider;
|
|
84
|
+
readonly model: string;
|
|
85
|
+
/** When set, oversized audio must fail with `TranscriptionError("audio_too_large")`. */
|
|
86
|
+
readonly maxAudioBytes?: number;
|
|
87
|
+
/** Happy-path probe: audio bytes and the expected transcript prefix. */
|
|
88
|
+
readonly sample?: {
|
|
89
|
+
readonly audio?: Uint8Array;
|
|
90
|
+
readonly format?: string;
|
|
91
|
+
readonly textIncludes?: string;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Offline conformance for any `TranscriptionProvider` (plan 061): typed empty-audio
|
|
95
|
+
* and oversized-audio errors, one-shot text, and stream ordering — at least one
|
|
96
|
+
* `transcript_delta` partial, then exactly one `done` terminal event. */
|
|
97
|
+
export declare function runTranscriptionConformance(options: TranscriptionConformanceOptions): Promise<TranscriptionResult | undefined>;
|
|
98
|
+
export interface ImageGenerationConformanceOptions {
|
|
99
|
+
readonly provider: ImageGenerationProvider;
|
|
100
|
+
readonly model: string;
|
|
101
|
+
/** When set, an oversized prompt must fail with `ImageGenerationError("input_too_large")`. */
|
|
102
|
+
readonly maxPromptChars?: number;
|
|
103
|
+
/** Happy-path probe: prompt, requested image count, expected provenance. */
|
|
104
|
+
readonly sample?: {
|
|
105
|
+
readonly prompt?: string;
|
|
106
|
+
readonly size?: string;
|
|
107
|
+
readonly count?: number;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Offline conformance for any `ImageGenerationProvider` (plan 061): typed empty-input
|
|
111
|
+
* and oversized-prompt errors, plus image shape — non-empty bytes, an `image/*` mime
|
|
112
|
+
* type, and preserved provenance (`provider`/`model`) on every image. */
|
|
113
|
+
export declare function runImageGenerationConformance(options: ImageGenerationConformanceOptions): Promise<ImageGenerationResult | undefined>;
|
|
114
|
+
export interface VideoGenerationConformanceOptions {
|
|
115
|
+
readonly provider: VideoGenerationProvider;
|
|
116
|
+
readonly model: string;
|
|
117
|
+
/** When set, an oversized prompt must fail with `VideoGenerationError("input_too_large")`. */
|
|
118
|
+
readonly maxPromptChars?: number;
|
|
119
|
+
/** Happy-path lifecycle probe: submit, poll to terminal state, assert result shape. */
|
|
120
|
+
readonly sample?: {
|
|
121
|
+
readonly prompt?: string;
|
|
122
|
+
readonly maxPolls?: number;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Offline conformance for any `VideoGenerationProvider` (plan 061): typed
|
|
126
|
+
* empty-input and oversized-prompt errors, plus the submit→status lifecycle —
|
|
127
|
+
* a job id is returned, polling reaches a terminal state, and succeeded jobs
|
|
128
|
+
* carry a video with provenance (`provider`/`model`) and at least one source
|
|
129
|
+
* (`bytes` or `url`). */
|
|
130
|
+
export declare function runVideoGenerationConformance(options: VideoGenerationConformanceOptions): Promise<VideoGenerationJob | undefined>;
|
|
131
|
+
export interface ModerationConformanceOptions {
|
|
132
|
+
readonly provider: ModerationProvider;
|
|
133
|
+
readonly model: string;
|
|
134
|
+
/** When set, an oversized input must fail with `ModerationError("input_too_large")`. */
|
|
135
|
+
readonly maxInputChars?: number;
|
|
136
|
+
/** Happy-path probe: a benign string classification. */
|
|
137
|
+
readonly sample?: {
|
|
138
|
+
readonly input?: string;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Offline conformance for any `ModerationProvider` (plan 061): typed empty-input
|
|
142
|
+
* and oversized-input errors, plus a classification probe — every category
|
|
143
|
+
* verdict carries a numeric score in [0,1], a boolean `flagged`, and the
|
|
144
|
+
* top-level `flagged` boolean is present. Scores are provider output; conformance
|
|
145
|
+
* asserts no local policy decisions are baked in. */
|
|
146
|
+
export declare function runModerationConformance(options: ModerationConformanceOptions): Promise<ModerationResult | undefined>;
|
|
147
|
+
export interface BatchJobsConformanceOptions {
|
|
148
|
+
readonly provider: BatchJobsProvider;
|
|
149
|
+
/** Sample lifecycle driver: submitted via `submit` and expected to reach `completed`. */
|
|
150
|
+
readonly sample?: {
|
|
151
|
+
readonly model: string;
|
|
152
|
+
readonly requests: readonly BatchRequestItem[];
|
|
153
|
+
};
|
|
154
|
+
/** When set, a submit over this count must fail with `BatchJobsError("too_many_requests")`. */
|
|
155
|
+
readonly maxRequests?: number;
|
|
156
|
+
}
|
|
157
|
+
/** Offline conformance for any `BatchJobsProvider` (plan 061): typed empty/oversized
|
|
158
|
+
* submit errors, opaque job ids, status returning members of the neutral state
|
|
159
|
+
* union, terminal resolution via `pollBatch` (plain utility), and paged results
|
|
160
|
+
* that walk to exhaustion with cursor continuity. Failure/cancel terminal
|
|
161
|
+
* transitions are covered by provider fakes in adapter test suites. */
|
|
162
|
+
export declare function runBatchJobsConformance(options: BatchJobsConformanceOptions): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BatchJobsError, EmbeddingsError, ImageGenerationError, ModerationError, pollBatch, SpeechError, TranscriptionError, VideoGenerationError, } from "../contracts.js";
|
|
1
2
|
import { reconstructToolCallDeltas } from "../provider-events.js";
|
|
2
3
|
import { canonicalizeJsonSchema } from "../providers/schema.js";
|
|
3
4
|
export async function collectProviderEvents(provider, request) {
|
|
@@ -194,4 +195,345 @@ function jsonPrimitives(value) {
|
|
|
194
195
|
function textFrom(events) {
|
|
195
196
|
return events.map((event) => (event.type === "content_delta" && event.content.type === "text" ? event.content.text : "")).join("");
|
|
196
197
|
}
|
|
198
|
+
/** Offline conformance for any `EmbeddingsProvider` (plan 061): typed empty-input
|
|
199
|
+
* error, typed oversized-batch error, and input-order vector mapping with finite
|
|
200
|
+
* coordinates. No network — the caller supplies the provider (real adapter with a
|
|
201
|
+
* fake transport, or a fake provider). */
|
|
202
|
+
export async function runEmbeddingsConformance(options) {
|
|
203
|
+
await assertEmbeddingsErrorCode(() => options.provider.embedMany({ model: options.model, inputs: [] }), "empty_input", "empty inputs must reject with EmbeddingsError(empty_input)");
|
|
204
|
+
if (options.maxBatchSize !== undefined) {
|
|
205
|
+
const inputs = Array.from({ length: options.maxBatchSize + 1 }, (_, i) => `input-${i}`);
|
|
206
|
+
await assertEmbeddingsErrorCode(() => options.provider.embedMany({ model: options.model, inputs }), "batch_too_large", `batches over ${options.maxBatchSize} must reject with EmbeddingsError(batch_too_large)`);
|
|
207
|
+
}
|
|
208
|
+
if (!options.sample)
|
|
209
|
+
return undefined;
|
|
210
|
+
const result = await options.provider.embedMany({ model: options.model, inputs: options.sample.inputs });
|
|
211
|
+
if (result.vectors.length !== options.sample.inputs.length)
|
|
212
|
+
throw new Error(`vector count ${result.vectors.length} must match input count ${options.sample.inputs.length}`);
|
|
213
|
+
for (const vector of result.vectors) {
|
|
214
|
+
if (!Array.isArray(vector) || vector.length === 0 || !vector.every(Number.isFinite))
|
|
215
|
+
throw new Error("vectors must be non-empty arrays of finite numbers");
|
|
216
|
+
}
|
|
217
|
+
if (!(result.dimensions > 0))
|
|
218
|
+
throw new Error("result.dimensions must be positive");
|
|
219
|
+
if (options.sample.dimensions !== undefined && result.dimensions !== options.sample.dimensions)
|
|
220
|
+
throw new Error(`result.dimensions ${result.dimensions} must match expected ${options.sample.dimensions}`);
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
async function assertEmbeddingsErrorCode(run, code, label) {
|
|
224
|
+
let error;
|
|
225
|
+
try {
|
|
226
|
+
await run();
|
|
227
|
+
}
|
|
228
|
+
catch (caught) {
|
|
229
|
+
error = caught;
|
|
230
|
+
}
|
|
231
|
+
if (!(error instanceof EmbeddingsError))
|
|
232
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
233
|
+
if (error.code !== code)
|
|
234
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
235
|
+
}
|
|
236
|
+
/** Offline conformance for any `SpeechProvider` (plan 061): typed empty-input and
|
|
237
|
+
* oversized-input errors, byte results, and stream ordering — the stream must emit
|
|
238
|
+
* at least one chunk before closing. Asserts event order, never wall clock. */
|
|
239
|
+
export async function runSpeechConformance(options) {
|
|
240
|
+
const input = options.sample?.input ?? "conformance";
|
|
241
|
+
await assertSpeechErrorCode(() => options.provider.synthesize({ model: options.model, input: "" }), "empty_input", "empty input must reject with SpeechError(empty_input)");
|
|
242
|
+
if (options.maxInputChars !== undefined) {
|
|
243
|
+
await assertSpeechErrorCode(() => options.provider.synthesize({ model: options.model, input: "x".repeat(options.maxInputChars + 1) }), "input_too_large", `inputs over ${options.maxInputChars} chars must reject with SpeechError(input_too_large)`);
|
|
244
|
+
}
|
|
245
|
+
if (!options.sample)
|
|
246
|
+
return undefined;
|
|
247
|
+
const request = {
|
|
248
|
+
model: options.model,
|
|
249
|
+
input,
|
|
250
|
+
...(options.sample.voice ? { voice: options.sample.voice } : {}),
|
|
251
|
+
...(options.sample.format ? { format: options.sample.format } : {}),
|
|
252
|
+
};
|
|
253
|
+
const result = await options.provider.synthesize(request);
|
|
254
|
+
if (!(result.audio instanceof Uint8Array) || result.audio.byteLength === 0)
|
|
255
|
+
throw new Error("synthesize must return non-empty audio bytes");
|
|
256
|
+
if (typeof result.format !== "string" || result.format.length === 0)
|
|
257
|
+
throw new Error("result.format must be a non-empty string");
|
|
258
|
+
const streamed = await options.provider.synthesizeStream(request);
|
|
259
|
+
const reader = streamed.audio.getReader();
|
|
260
|
+
let chunks = 0;
|
|
261
|
+
let firstChunkBeforeClose = false;
|
|
262
|
+
while (true) {
|
|
263
|
+
const { done, value } = await reader.read();
|
|
264
|
+
if (done)
|
|
265
|
+
break;
|
|
266
|
+
if (chunks === 0 && value.byteLength > 0)
|
|
267
|
+
firstChunkBeforeClose = true;
|
|
268
|
+
chunks += 1;
|
|
269
|
+
}
|
|
270
|
+
if (chunks === 0 || !firstChunkBeforeClose)
|
|
271
|
+
throw new Error("synthesizeStream must emit at least one non-empty chunk before closing");
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
async function assertSpeechErrorCode(run, code, label) {
|
|
275
|
+
let error;
|
|
276
|
+
try {
|
|
277
|
+
await run();
|
|
278
|
+
}
|
|
279
|
+
catch (caught) {
|
|
280
|
+
error = caught;
|
|
281
|
+
}
|
|
282
|
+
if (!(error instanceof SpeechError))
|
|
283
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
284
|
+
if (error.code !== code)
|
|
285
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
286
|
+
}
|
|
287
|
+
/** Offline conformance for any `TranscriptionProvider` (plan 061): typed empty-audio
|
|
288
|
+
* and oversized-audio errors, one-shot text, and stream ordering — at least one
|
|
289
|
+
* `transcript_delta` partial, then exactly one `done` terminal event. */
|
|
290
|
+
export async function runTranscriptionConformance(options) {
|
|
291
|
+
const audio = options.sample?.audio ?? new Uint8Array([1, 2, 3]);
|
|
292
|
+
await assertTranscriptionErrorCode(() => options.provider.transcribe({ model: options.model, audio: new Uint8Array(0) }), "empty_input", "empty audio must reject with TranscriptionError(empty_input)");
|
|
293
|
+
if (options.maxAudioBytes !== undefined) {
|
|
294
|
+
await assertTranscriptionErrorCode(() => options.provider.transcribe({ model: options.model, audio: new Uint8Array(options.maxAudioBytes + 1) }), "audio_too_large", `audio over ${options.maxAudioBytes} bytes must reject with TranscriptionError(audio_too_large)`);
|
|
295
|
+
}
|
|
296
|
+
if (!options.sample)
|
|
297
|
+
return undefined;
|
|
298
|
+
const request = {
|
|
299
|
+
model: options.model,
|
|
300
|
+
audio,
|
|
301
|
+
...(options.sample.format ? { format: options.sample.format } : {}),
|
|
302
|
+
};
|
|
303
|
+
const result = await options.provider.transcribe(request);
|
|
304
|
+
if (typeof result.text !== "string")
|
|
305
|
+
throw new Error("transcribe must return string text");
|
|
306
|
+
if (options.sample.textIncludes !== undefined && !result.text.includes(options.sample.textIncludes))
|
|
307
|
+
throw new Error(`transcript ${JSON.stringify(result.text)} must include ${JSON.stringify(options.sample.textIncludes)}`);
|
|
308
|
+
let deltas = 0;
|
|
309
|
+
let doneEvents = 0;
|
|
310
|
+
for await (const event of options.provider.transcribeStream(request)) {
|
|
311
|
+
if (event.type === "transcript_delta") {
|
|
312
|
+
if (typeof event.text !== "string")
|
|
313
|
+
throw new Error("transcript_delta must carry string text");
|
|
314
|
+
deltas += 1;
|
|
315
|
+
}
|
|
316
|
+
else if (event.type === "done") {
|
|
317
|
+
doneEvents += 1;
|
|
318
|
+
if (typeof event.text !== "string")
|
|
319
|
+
throw new Error("done must carry string text");
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
throw new Error(`unexpected transcript event ${event.type}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (deltas === 0)
|
|
326
|
+
throw new Error("transcribeStream must yield at least one transcript_delta before done");
|
|
327
|
+
if (doneEvents !== 1)
|
|
328
|
+
throw new Error(`transcribeStream must yield exactly one done event; got ${doneEvents}`);
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
async function assertTranscriptionErrorCode(run, code, label) {
|
|
332
|
+
let error;
|
|
333
|
+
try {
|
|
334
|
+
await run();
|
|
335
|
+
}
|
|
336
|
+
catch (caught) {
|
|
337
|
+
error = caught;
|
|
338
|
+
}
|
|
339
|
+
if (!(error instanceof TranscriptionError))
|
|
340
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
341
|
+
if (error.code !== code)
|
|
342
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
343
|
+
}
|
|
344
|
+
/** Offline conformance for any `ImageGenerationProvider` (plan 061): typed empty-input
|
|
345
|
+
* and oversized-prompt errors, plus image shape — non-empty bytes, an `image/*` mime
|
|
346
|
+
* type, and preserved provenance (`provider`/`model`) on every image. */
|
|
347
|
+
export async function runImageGenerationConformance(options) {
|
|
348
|
+
await assertImageGenerationErrorCode(() => options.provider.generate({ model: options.model, prompt: "" }), "empty_input", "empty prompts must reject with ImageGenerationError(empty_input)");
|
|
349
|
+
if (options.maxPromptChars !== undefined) {
|
|
350
|
+
await assertImageGenerationErrorCode(() => options.provider.generate({ model: options.model, prompt: "x".repeat(options.maxPromptChars + 1) }), "input_too_large", `prompts over ${options.maxPromptChars} chars must reject with ImageGenerationError(input_too_large)`);
|
|
351
|
+
}
|
|
352
|
+
if (!options.sample)
|
|
353
|
+
return undefined;
|
|
354
|
+
const result = await options.provider.generate({
|
|
355
|
+
model: options.model,
|
|
356
|
+
prompt: options.sample.prompt ?? "conformance cube",
|
|
357
|
+
...(options.sample.size ? { size: options.sample.size } : {}),
|
|
358
|
+
...(options.sample.count ? { count: options.sample.count } : {}),
|
|
359
|
+
});
|
|
360
|
+
if (result.images.length === 0)
|
|
361
|
+
throw new Error("generate must return at least one image");
|
|
362
|
+
if (options.sample.count !== undefined && result.images.length !== options.sample.count)
|
|
363
|
+
throw new Error(`image count ${result.images.length} must match requested ${options.sample.count}`);
|
|
364
|
+
for (const image of result.images) {
|
|
365
|
+
if (!(image.bytes instanceof Uint8Array) || image.bytes.byteLength === 0)
|
|
366
|
+
throw new Error("generated images must carry non-empty bytes");
|
|
367
|
+
if (typeof image.mimeType !== "string" || !image.mimeType.startsWith("image/"))
|
|
368
|
+
throw new Error(`image mime type ${image.mimeType} must be image/*`);
|
|
369
|
+
if (image.provider !== options.provider.id)
|
|
370
|
+
throw new Error(`image provenance provider ${image.provider} must be preserved (${options.provider.id})`);
|
|
371
|
+
if (image.model !== options.model)
|
|
372
|
+
throw new Error(`image provenance model ${image.model} must be preserved (${options.model})`);
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
async function assertImageGenerationErrorCode(run, code, label) {
|
|
377
|
+
let error;
|
|
378
|
+
try {
|
|
379
|
+
await run();
|
|
380
|
+
}
|
|
381
|
+
catch (caught) {
|
|
382
|
+
error = caught;
|
|
383
|
+
}
|
|
384
|
+
if (!(error instanceof ImageGenerationError))
|
|
385
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
386
|
+
if (error.code !== code)
|
|
387
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
388
|
+
}
|
|
389
|
+
/** Offline conformance for any `VideoGenerationProvider` (plan 061): typed
|
|
390
|
+
* empty-input and oversized-prompt errors, plus the submit→status lifecycle —
|
|
391
|
+
* a job id is returned, polling reaches a terminal state, and succeeded jobs
|
|
392
|
+
* carry a video with provenance (`provider`/`model`) and at least one source
|
|
393
|
+
* (`bytes` or `url`). */
|
|
394
|
+
export async function runVideoGenerationConformance(options) {
|
|
395
|
+
await assertVideoGenerationErrorCode(() => options.provider.submit({ model: options.model, prompt: "" }), "empty_input", "empty prompts must reject with VideoGenerationError(empty_input)");
|
|
396
|
+
if (options.maxPromptChars !== undefined) {
|
|
397
|
+
await assertVideoGenerationErrorCode(() => options.provider.submit({ model: options.model, prompt: "x".repeat(options.maxPromptChars + 1) }), "input_too_large", `prompts over ${options.maxPromptChars} chars must reject with VideoGenerationError(input_too_large)`);
|
|
398
|
+
}
|
|
399
|
+
if (!options.sample)
|
|
400
|
+
return undefined;
|
|
401
|
+
const { jobId } = await options.provider.submit({ model: options.model, prompt: options.sample.prompt ?? "conformance clip" });
|
|
402
|
+
if (typeof jobId !== "string" || jobId.length === 0)
|
|
403
|
+
throw new Error("submit must return a non-empty job id");
|
|
404
|
+
const maxPolls = options.sample.maxPolls ?? 10;
|
|
405
|
+
let job;
|
|
406
|
+
for (let poll = 0; poll < maxPolls; poll += 1) {
|
|
407
|
+
job = await options.provider.status(jobId);
|
|
408
|
+
if (!job)
|
|
409
|
+
throw new Error("status must return a job");
|
|
410
|
+
if (job.state === "succeeded" || job.state === "failed")
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
if (!job || (job.state !== "succeeded" && job.state !== "failed"))
|
|
414
|
+
throw new Error(`status did not reach a terminal state within ${maxPolls} polls`);
|
|
415
|
+
if (job.state === "failed")
|
|
416
|
+
return job;
|
|
417
|
+
const video = job.video;
|
|
418
|
+
if (!video)
|
|
419
|
+
throw new Error("succeeded jobs must carry a video");
|
|
420
|
+
if (!video.bytes && !video.url)
|
|
421
|
+
throw new Error("generated videos must carry bytes or a url");
|
|
422
|
+
if (video.provider !== options.provider.id)
|
|
423
|
+
throw new Error(`video provenance provider ${video.provider} must be preserved (${options.provider.id})`);
|
|
424
|
+
if (video.model !== options.model)
|
|
425
|
+
throw new Error(`video provenance model ${video.model} must be preserved (${options.model})`);
|
|
426
|
+
return job;
|
|
427
|
+
}
|
|
428
|
+
async function assertVideoGenerationErrorCode(run, code, label) {
|
|
429
|
+
let error;
|
|
430
|
+
try {
|
|
431
|
+
await run();
|
|
432
|
+
}
|
|
433
|
+
catch (caught) {
|
|
434
|
+
error = caught;
|
|
435
|
+
}
|
|
436
|
+
if (!(error instanceof VideoGenerationError))
|
|
437
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
438
|
+
if (error.code !== code)
|
|
439
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
440
|
+
}
|
|
441
|
+
/** Offline conformance for any `ModerationProvider` (plan 061): typed empty-input
|
|
442
|
+
* and oversized-input errors, plus a classification probe — every category
|
|
443
|
+
* verdict carries a numeric score in [0,1], a boolean `flagged`, and the
|
|
444
|
+
* top-level `flagged` boolean is present. Scores are provider output; conformance
|
|
445
|
+
* asserts no local policy decisions are baked in. */
|
|
446
|
+
export async function runModerationConformance(options) {
|
|
447
|
+
await assertModerationErrorCode(() => options.provider.moderate({ input: "", model: options.model }), "empty_input", "empty inputs must reject with ModerationError(empty_input)");
|
|
448
|
+
if (options.maxInputChars !== undefined) {
|
|
449
|
+
await assertModerationErrorCode(() => options.provider.moderate({ input: "x".repeat(options.maxInputChars + 1), model: options.model }), "input_too_large", `inputs over ${options.maxInputChars} chars must reject with ModerationError(input_too_large)`);
|
|
450
|
+
}
|
|
451
|
+
if (!options.sample)
|
|
452
|
+
return undefined;
|
|
453
|
+
const classified = await options.provider.moderate({ input: options.sample.input ?? "conformance probe", model: options.model });
|
|
454
|
+
if (Array.isArray(classified))
|
|
455
|
+
throw new Error("single-string input must classify to one ModerationResult, not a batch");
|
|
456
|
+
const result = classified;
|
|
457
|
+
if (typeof result.flagged !== "boolean")
|
|
458
|
+
throw new Error("moderation results must carry a top-level flagged boolean");
|
|
459
|
+
const entries = Object.entries(result.categories);
|
|
460
|
+
if (entries.length === 0)
|
|
461
|
+
throw new Error("moderation results must expose at least one category verdict");
|
|
462
|
+
for (const [name, verdict] of entries) {
|
|
463
|
+
if (typeof verdict.score !== "number" || !(verdict.score >= 0 && verdict.score <= 1))
|
|
464
|
+
throw new Error(`category ${name} score ${verdict.score} must be a number in [0,1]`);
|
|
465
|
+
if (typeof verdict.flagged !== "boolean")
|
|
466
|
+
throw new Error(`category ${name} verdict must carry a flagged boolean`);
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
async function assertModerationErrorCode(run, code, label) {
|
|
471
|
+
let error;
|
|
472
|
+
try {
|
|
473
|
+
await run();
|
|
474
|
+
}
|
|
475
|
+
catch (caught) {
|
|
476
|
+
error = caught;
|
|
477
|
+
}
|
|
478
|
+
if (!(error instanceof ModerationError))
|
|
479
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
480
|
+
if (error.code !== code)
|
|
481
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
482
|
+
}
|
|
483
|
+
/** Offline conformance for any `BatchJobsProvider` (plan 061): typed empty/oversized
|
|
484
|
+
* submit errors, opaque job ids, status returning members of the neutral state
|
|
485
|
+
* union, terminal resolution via `pollBatch` (plain utility), and paged results
|
|
486
|
+
* that walk to exhaustion with cursor continuity. Failure/cancel terminal
|
|
487
|
+
* transitions are covered by provider fakes in adapter test suites. */
|
|
488
|
+
export async function runBatchJobsConformance(options) {
|
|
489
|
+
await assertBatchJobsErrorCode(() => options.provider.submit({ model: "batch-model", requests: [] }), "empty_requests", "empty submits must reject with BatchJobsError(empty_requests)");
|
|
490
|
+
if (options.maxRequests !== undefined) {
|
|
491
|
+
await assertBatchJobsErrorCode(() => options.provider.submit({ model: "batch-model", requests: Array.from({ length: options.maxRequests + 1 }, () => ({ body: {} })) }), "too_many_requests", `submits over ${options.maxRequests} requests must reject with BatchJobsError(too_many_requests)`);
|
|
492
|
+
}
|
|
493
|
+
if (!options.sample)
|
|
494
|
+
return;
|
|
495
|
+
const submitted = await options.provider.submit({ model: options.sample.model, requests: options.sample.requests });
|
|
496
|
+
if (typeof submitted.id !== "string" || submitted.id.length === 0)
|
|
497
|
+
throw new Error("submit must return an opaque non-empty job id");
|
|
498
|
+
const status = await options.provider.status(submitted.id);
|
|
499
|
+
if (typeof status.state !== "string")
|
|
500
|
+
throw new Error("status must return a typed job state");
|
|
501
|
+
const terminal = await pollBatch(options.provider, submitted.id, { intervalMs: 1, maxAttempts: 10 });
|
|
502
|
+
if (terminal.state !== "completed")
|
|
503
|
+
throw new Error(`sample job must reach completed; got ${terminal.state}`);
|
|
504
|
+
const seen = [];
|
|
505
|
+
let cursor = null;
|
|
506
|
+
let pages = 0;
|
|
507
|
+
while (pages < 10) {
|
|
508
|
+
const page = await options.provider.results(submitted.id, { cursor: cursor ?? null });
|
|
509
|
+
for (const item of page.items) {
|
|
510
|
+
if (typeof item.customId !== "string" || item.customId.length === 0)
|
|
511
|
+
throw new Error("result items must carry non-empty custom ids");
|
|
512
|
+
seen.push(item.customId);
|
|
513
|
+
}
|
|
514
|
+
if (!page.nextCursor)
|
|
515
|
+
break;
|
|
516
|
+
if (page.nextCursor === cursor)
|
|
517
|
+
throw new Error("results cursor must advance between pages");
|
|
518
|
+
cursor = page.nextCursor;
|
|
519
|
+
pages += 1;
|
|
520
|
+
}
|
|
521
|
+
if (seen.length === 0)
|
|
522
|
+
throw new Error("completed job must expose at least one result item");
|
|
523
|
+
if (new Set(seen).size !== seen.length)
|
|
524
|
+
throw new Error("result paging must not duplicate items across pages");
|
|
525
|
+
}
|
|
526
|
+
async function assertBatchJobsErrorCode(run, code, label) {
|
|
527
|
+
let error;
|
|
528
|
+
try {
|
|
529
|
+
await run();
|
|
530
|
+
}
|
|
531
|
+
catch (caught) {
|
|
532
|
+
error = caught;
|
|
533
|
+
}
|
|
534
|
+
if (!(error instanceof BatchJobsError))
|
|
535
|
+
throw new Error(`${label}; got ${error === undefined ? "a successful result" : String(error)}`);
|
|
536
|
+
if (error.code !== code)
|
|
537
|
+
throw new Error(`${label}; got code ${error.code} (${error.message})`);
|
|
538
|
+
}
|
|
197
539
|
//# sourceMappingURL=provider-conformance.js.map
|
|
@@ -6,4 +6,3 @@ export interface ToolEffectStoreConformanceOptions {
|
|
|
6
6
|
}
|
|
7
7
|
/** Assert core claim/CAS, duplicate, reconciliation, and cleanup semantics without a test framework. */
|
|
8
8
|
export declare function assertToolEffectStoreConforms(factory: () => ToolEffectStore | Promise<ToolEffectStore>, options?: ToolEffectStoreConformanceOptions): Promise<void>;
|
|
9
|
-
export declare function runToolEffectStoreConformance(factory: () => ToolEffectStore | Promise<ToolEffectStore>, options?: ToolEffectStoreConformanceOptions): Promise<void>;
|
|
@@ -44,9 +44,6 @@ export async function assertToolEffectStoreConforms(factory, options = {}) {
|
|
|
44
44
|
if (cleanup.deleted < 1)
|
|
45
45
|
throw new Error("store cleanup must remove terminal effects");
|
|
46
46
|
}
|
|
47
|
-
export async function runToolEffectStoreConformance(factory, options = {}) {
|
|
48
|
-
await assertToolEffectStoreConforms(factory, options);
|
|
49
|
-
}
|
|
50
47
|
function key(identity, ownership, value, toolCallId = "call") {
|
|
51
48
|
return {
|
|
52
49
|
identity,
|