@hue-run/sdk 0.1.2 → 0.1.4
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/MANAGED_TARGETS.md +84 -0
- package/README.md +61 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +6 -2
- package/dist/evals/json.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managed.d.ts +65 -0
- package/dist/managed.js +537 -0
- package/dist/receipt.d.ts +10 -0
- package/dist/receipt.js +207 -0
- package/dist/types.d.ts +21 -0
- package/package.json +6 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Run an existing agent from Hue
|
|
2
|
+
|
|
3
|
+
`@hue-run/sdk/managed` adapts an existing server-side function to Hue's managed
|
|
4
|
+
evaluation protocol. Hue dispatches a frozen case; your application keeps its
|
|
5
|
+
model, tools and OpenTelemetry provider. This helper does not create an agent,
|
|
6
|
+
choose a model, install instrumentation or replace global providers.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createManagedTargetHandler } from "@hue-run/sdk/managed";
|
|
10
|
+
|
|
11
|
+
// Your application initializes its existing NodeSDK/context manager before requests.
|
|
12
|
+
export const POST = createManagedTargetHandler({
|
|
13
|
+
machineCredential: process.env.HUE_MANAGED_TARGET_SECRET!,
|
|
14
|
+
// baseUrl defaults to https://app.hue.run; configure it here, never from a request.
|
|
15
|
+
target: async ({ input, config, inputFiles, signal }) => {
|
|
16
|
+
const result = await existingAgent({ input, config, files: inputFiles, signal });
|
|
17
|
+
return {
|
|
18
|
+
output: { text: result.text },
|
|
19
|
+
files: result.files.map((file) => ({
|
|
20
|
+
filename: file.name,
|
|
21
|
+
contentType: file.contentType,
|
|
22
|
+
data: file.bytes, // Uint8Array; upload these exact bytes once generated.
|
|
23
|
+
primary: file.isPrimary,
|
|
24
|
+
})),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
flushTelemetry: async () => {
|
|
28
|
+
await flushExistingTracesAndLogs(); // Throw if either pipeline did not flush.
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The example's `existingAgent` and `flushExistingTracesAndLogs` are application
|
|
34
|
+
functions. A flush callback must throw on delivery failure; an explicit `false`
|
|
35
|
+
result or a Hue report with pending records also leaves telemetry pending. Hue's
|
|
36
|
+
flush API throws for new export failures; its historical failure counters do not
|
|
37
|
+
invalidate later successful drains. An optional `tracer` uses an already configured provider. The default
|
|
38
|
+
uses the global provider, which must record the supplied sampled trace context.
|
|
39
|
+
Configure a host request limit of at least 120 seconds for the defaults: 90 seconds
|
|
40
|
+
for the target and 30 seconds reserved for uploads, checkpointing and telemetry.
|
|
41
|
+
Targets must honor `signal`; JavaScript cannot forcibly stop a callback that
|
|
42
|
+
ignores cancellation. A callback still running at the deadline returns `uncertain`.
|
|
43
|
+
|
|
44
|
+
## What the helper guarantees
|
|
45
|
+
|
|
46
|
+
- Validates the dedicated machine credential before network access. The scoped
|
|
47
|
+
`X-Hue-Invocation-Token` is used only for Hue callbacks; neither credential is
|
|
48
|
+
passed to the target, output uploads or telemetry attributes.
|
|
49
|
+
- Claims the assigned execution before calling the target. A duplicate claim
|
|
50
|
+
returns HTTP 409 without another agent call. A lost claim response is uncertain.
|
|
51
|
+
- Downloads only declared files from the configured Hue origin and verifies their
|
|
52
|
+
length and SHA-256 before calling the target. No redirects are followed.
|
|
53
|
+
- Creates a real `ai.managed_target` span under the incoming W3C `traceparent`,
|
|
54
|
+
using the existing provider. It records no input/output content of its own.
|
|
55
|
+
- Uploads returned file buffers, including valid secondary files on an error
|
|
56
|
+
result. Each reservation has a stable idempotency key; an already ready file is
|
|
57
|
+
reused. Only granted content-type/private-access upload headers are accepted.
|
|
58
|
+
- Ends the span, checkpoints the outcome, flushes existing traces and logs, then
|
|
59
|
+
persists a telemetry acknowledgement before returning HTTP 200.
|
|
60
|
+
|
|
61
|
+
HTTP 200 returns `{protocolVersion:1,executionId,state:"checkpointed",telemetry}`.
|
|
62
|
+
`telemetry` is `"flushed"` or `"pending"`; a flush/acknowledgement failure preserves
|
|
63
|
+
the saved outcome. A checkpoint is not experiment completion or a receipt proving
|
|
64
|
+
all evidence has been stored. Hue performs that independent verification.
|
|
65
|
+
|
|
66
|
+
The callback returns `ManagedTargetResult`: optional `output` JSON, `state`
|
|
67
|
+
(`succeeded`, `error`, `cancelled`), a safe `{type,message?}` error, file buffers and
|
|
68
|
+
optional token usage. Omitted output means unavailable; `null` is present output.
|
|
69
|
+
At most one file can be primary. Files are limited to 16, 25 MiB each and 64 MiB
|
|
70
|
+
total; input/output JSON and HTTP envelopes also have bounded sizes. Error text
|
|
71
|
+
is caller-owned public content: never return raw provider exceptions or secrets.
|
|
72
|
+
|
|
73
|
+
Signed file PUTs make one attempt and never follow redirects. If their acknowledgement
|
|
74
|
+
is lost, Hue's completion callback verifies the stored bytes before accepting the file.
|
|
75
|
+
Only identical idempotent reservation/completion/checkpoint/telemetry callbacks retry,
|
|
76
|
+
at most once within the finalization deadline. The helper never retries the agent, automatically
|
|
77
|
+
resumes a lost callback, or claims exactly-once execution across crashes. After
|
|
78
|
+
transport loss or a callback deadline, inspect the saved execution in Hue. An
|
|
79
|
+
explicitly authorized new attempt is a separate execution decision.
|
|
80
|
+
|
|
81
|
+
Protect and rate-limit the endpoint at your host as appropriate. Keep the dedicated
|
|
82
|
+
machine credential server-side. This API uses invocation-scoped callbacks, not the
|
|
83
|
+
project-wide `EvaluationClient` credential. The existing telemetry pipeline retains
|
|
84
|
+
its configured content-capture policy and exporter credentials.
|
package/README.md
CHANGED
|
@@ -177,6 +177,39 @@ drain, including records emitted before its call. Stop request production
|
|
|
177
177
|
before shutdown so late spans cannot race it. A client does not own instrumented
|
|
178
178
|
operations still running in the application.
|
|
179
179
|
|
|
180
|
+
## Verify a stored application trace
|
|
181
|
+
|
|
182
|
+
After exercising a real application request and finishing its stream, flush the
|
|
183
|
+
providers that own its spans, then verify their OpenTelemetry IDs:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// traceId and requestSpanId come from the application request you just exercised.
|
|
187
|
+
await hue.flush(); // borrowed providers must also finish their own work
|
|
188
|
+
const result = await hue.verifyTrace(traceId, {
|
|
189
|
+
expectedSpanIds: [requestSpanId], // include known model/tool span IDs when available
|
|
190
|
+
requiredFields: ["input", "output", "model"], // choose fields this request should emit
|
|
191
|
+
});
|
|
192
|
+
if (!result.verified) throw new Error("Trace verification timed out; inspect missing spans and fields.");
|
|
193
|
+
console.log(result.receipt?.traceUrl);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Available in TypeScript `0.1.3`. `verifyTrace` makes a read-only, project-key-authenticated
|
|
197
|
+
receipt request. It does not flush, run your application, create a test span, or
|
|
198
|
+
read captured values. `fields` reports the presence of stored normalized input,
|
|
199
|
+
output, model, usage, and session data across the trace; it does not establish
|
|
200
|
+
content correctness or that every possible span has arrived. Leave unknown usage
|
|
201
|
+
and intentionally disabled content out of `requiredFields`.
|
|
202
|
+
|
|
203
|
+
The default budget is 10 seconds; set `timeoutMillis` up to 60,000. Only a recognized
|
|
204
|
+
missing trace, HTTP 429, or HTTP 503 is retried, respecting `Retry-After` and the
|
|
205
|
+
overall deadline. Incomplete evidence is also checked again within that deadline.
|
|
206
|
+
A timeout returns `{ verified: false, receipt }`, retaining the latest observed
|
|
207
|
+
receipt or `null`. Authentication, unsupported endpoint, transport, and invalid
|
|
208
|
+
response failures throw `HueTraceVerificationError` with a safe `code` and optional
|
|
209
|
+
HTTP `status`. Missing expected spans and required fields remain explicit; a 200
|
|
210
|
+
response alone is not success. A successful result verifies those requested
|
|
211
|
+
conditions only. Use Hue's UI to inspect captured values and redaction.
|
|
212
|
+
|
|
180
213
|
## Package verification
|
|
181
214
|
|
|
182
215
|
From the repository root with Node 24 and Bun 1.3.9 on PATH:
|
|
@@ -194,3 +227,31 @@ published. The chatbot README describes running that external installation.
|
|
|
194
227
|
# Local evaluation workflows
|
|
195
228
|
|
|
196
229
|
The optional `@hue-run/sdk/evals` entry point supports dataset/scorer registration, frozen-version experiments, local built-in/custom scoring, upload resume, and historical rescoring. See the [evaluation guide](https://docs.hue.run/evaluations/first-evaluation) for the complete journey, content policy and checkpoint recovery contract.
|
|
230
|
+
|
|
231
|
+
## Managed targets
|
|
232
|
+
|
|
233
|
+
Start a frozen dataset run in Hue while your agent stays in your application. Expose a
|
|
234
|
+
protected POST route around your existing function:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { createManagedTargetHandler } from "@hue-run/sdk/managed";
|
|
238
|
+
|
|
239
|
+
export const POST = createManagedTargetHandler({
|
|
240
|
+
machineCredential: process.env.HUE_MANAGED_TARGET_SECRET!,
|
|
241
|
+
// Application-owned functions: keep your current provider, tools and tracing.
|
|
242
|
+
target: async ({ input, config, inputFiles, signal }) =>
|
|
243
|
+
runAgentForEvaluation({ input, config, inputFiles, signal }),
|
|
244
|
+
flushTelemetry: () => hue.flush(), // Existing Hue client; flush traces and logs.
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`runAgentForEvaluation` adapts your application result to `{ output, files? }`.
|
|
249
|
+
Files contain `filename`, `contentType`, actual `Uint8Array` data and an optional
|
|
250
|
+
`primary` flag. The helper verifies input bytes, claims the invocation, saves the
|
|
251
|
+
outcome and correlates its span with Hue. It never retries the agent automatically.
|
|
252
|
+
Use a 120-second host request limit for the default 90-second execution and
|
|
253
|
+
30-second finalization budget; your target must honor `signal`.
|
|
254
|
+
|
|
255
|
+
See the [managed-run guide](https://docs.hue.run/evaluations/managed-runs) and the
|
|
256
|
+
[full adapter contract](MANAGED_TARGETS.md) for registration, file handling,
|
|
257
|
+
existing-provider flush callbacks and recovery. Local/CI runners remain available.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Context, type Span, type Tracer } from "@opentelemetry/api";
|
|
2
2
|
import { HueTransport } from "./transport.js";
|
|
3
|
-
import type { ExportReport, FlushableLoggerProvider, FlushableTracerProvider, HueOptions, HueSpan, JsonValue, ProjectConnection, SpanOptions } from "./types.js";
|
|
3
|
+
import type { ExportReport, FlushableLoggerProvider, FlushableTracerProvider, HueOptions, HueSpan, JsonValue, ProjectConnection, SpanOptions, VerifyTraceOptions, TraceVerification } from "./types.js";
|
|
4
4
|
export interface ExistingHueProviders {
|
|
5
5
|
transport: HueTransport;
|
|
6
6
|
tracerProvider: FlushableTracerProvider;
|
|
@@ -23,6 +23,7 @@ export declare class HueClient {
|
|
|
23
23
|
private shutdownPromise?;
|
|
24
24
|
private flushPromise?;
|
|
25
25
|
constructor(options: HueOptions | ExistingHueProviders);
|
|
26
|
+
verifyTrace(traceId: string, options?: VerifyTraceOptions): Promise<TraceVerification>;
|
|
26
27
|
getContext(): Context;
|
|
27
28
|
withSpan<T>(name: string, callback: (span: HueSpan) => Promise<T> | T, options?: SpanOptions): Promise<T>;
|
|
28
29
|
tool<T extends JsonValue | undefined>(name: string, input: JsonValue, execute: () => Promise<T> | T): Promise<T>;
|
package/dist/client.js
CHANGED
|
@@ -6,6 +6,7 @@ import { TracerProvider } from "@opentelemetry/sdk-trace";
|
|
|
6
6
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
7
|
import { MAX_CONTENT_BYTES } from "./config.js";
|
|
8
8
|
import { createHueTransport, HueExportError } from "./transport.js";
|
|
9
|
+
import { verifyTrace } from "./receipt.js";
|
|
9
10
|
export class HueConnectionError extends Error {
|
|
10
11
|
status;
|
|
11
12
|
constructor(message, status) {
|
|
@@ -97,8 +98,11 @@ export class HueClient {
|
|
|
97
98
|
this.loggerProvider = logger;
|
|
98
99
|
}
|
|
99
100
|
this.captureContent = this.transport.options.captureContent;
|
|
100
|
-
this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", "0.1.
|
|
101
|
-
this.logger = this.loggerProvider.getLogger("@hue-run/sdk", "0.1.
|
|
101
|
+
this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", "0.1.4"), this.storage);
|
|
102
|
+
this.logger = this.loggerProvider.getLogger("@hue-run/sdk", "0.1.4");
|
|
103
|
+
}
|
|
104
|
+
verifyTrace(traceId, options = {}) {
|
|
105
|
+
return verifyTrace(this.transport.options, traceId, options);
|
|
102
106
|
}
|
|
103
107
|
getContext() {
|
|
104
108
|
return this.storage.getStore()?.context ?? context.active();
|
package/dist/evals/json.js
CHANGED
|
@@ -55,6 +55,7 @@ export function sourceDigest(source) {
|
|
|
55
55
|
}
|
|
56
56
|
export function uuid(value) {
|
|
57
57
|
if (typeof value !== "string" ||
|
|
58
|
+
value.length !== 36 ||
|
|
58
59
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
59
60
|
throw new TypeError("Expected a UUID");
|
|
60
61
|
return value;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createHue, HueClient, HueConnectionError, type ExistingHueProviders } from "./client.js";
|
|
2
2
|
export { createHueTransport, HueTransport, HueExportError } from "./transport.js";
|
|
3
|
+
export { HueTraceVerificationError } from "./receipt.js";
|
|
3
4
|
export type * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Tracer } from "@opentelemetry/api";
|
|
2
|
+
import type { JsonValue } from "./types.js";
|
|
3
|
+
export interface ManagedInputFile {
|
|
4
|
+
artifactId: string;
|
|
5
|
+
filename: string;
|
|
6
|
+
contentType: string;
|
|
7
|
+
byteSize: number;
|
|
8
|
+
sha256: string;
|
|
9
|
+
role: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ManagedInvocation {
|
|
12
|
+
protocolVersion: 1;
|
|
13
|
+
executionId: string;
|
|
14
|
+
attempt: number;
|
|
15
|
+
input: JsonValue;
|
|
16
|
+
config: JsonValue;
|
|
17
|
+
inputFiles: ManagedInputFile[];
|
|
18
|
+
deadline: string;
|
|
19
|
+
traceparent: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ManagedTargetContext {
|
|
22
|
+
executionId: string;
|
|
23
|
+
attempt: number;
|
|
24
|
+
input: JsonValue;
|
|
25
|
+
config: JsonValue;
|
|
26
|
+
inputFiles: Array<ManagedInputFile & {
|
|
27
|
+
data: Uint8Array;
|
|
28
|
+
}>;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
traceId: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ManagedOutputFile {
|
|
33
|
+
filename: string;
|
|
34
|
+
contentType: string;
|
|
35
|
+
data: Uint8Array;
|
|
36
|
+
primary?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface ManagedTargetResult {
|
|
39
|
+
state?: "succeeded" | "error" | "cancelled";
|
|
40
|
+
output?: JsonValue;
|
|
41
|
+
/** Public, safe error summary. Never include provider errors, credentials or stacks. */
|
|
42
|
+
error?: {
|
|
43
|
+
type: string;
|
|
44
|
+
message?: string;
|
|
45
|
+
};
|
|
46
|
+
files?: ManagedOutputFile[];
|
|
47
|
+
usage?: {
|
|
48
|
+
inputTokens?: number;
|
|
49
|
+
outputTokens?: number;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface ManagedTargetOptions {
|
|
53
|
+
machineCredential: string;
|
|
54
|
+
baseUrl?: string;
|
|
55
|
+
target: (invocation: ManagedTargetContext) => Promise<ManagedTargetResult>;
|
|
56
|
+
/** Flush the application's existing trace AND log pipelines; do not shut them down. */
|
|
57
|
+
flushTelemetry: () => Promise<unknown>;
|
|
58
|
+
tracer?: Tracer;
|
|
59
|
+
/** Reserve finalization time inside the host's request limit. Default 90 seconds. */
|
|
60
|
+
maxExecutionMillis?: number;
|
|
61
|
+
/** Upload/checkpoint/flush grace after execution deadline, at most 30 seconds. */
|
|
62
|
+
finalizationMillis?: number;
|
|
63
|
+
}
|
|
64
|
+
/** A machine-authenticated, framework-neutral POST handler. Never retries the target. */
|
|
65
|
+
export declare function createManagedTargetHandler(options: ManagedTargetOptions): (request: Request) => Promise<Response>;
|
package/dist/managed.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { context, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
5
|
+
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
6
|
+
import { validateOptions } from "./config.js";
|
|
7
|
+
import { json, uuid } from "./evals/json.js";
|
|
8
|
+
const MIB = 1024 * 1024;
|
|
9
|
+
const MAX_FILE = 25 * MIB;
|
|
10
|
+
const MAX_TOTAL = 64 * MIB;
|
|
11
|
+
const STATES = new Set([
|
|
12
|
+
"queued",
|
|
13
|
+
"dispatched",
|
|
14
|
+
"running",
|
|
15
|
+
"uncertain",
|
|
16
|
+
"checkpointed",
|
|
17
|
+
"completed",
|
|
18
|
+
"unsupported",
|
|
19
|
+
"error",
|
|
20
|
+
"cancelled",
|
|
21
|
+
"superseded",
|
|
22
|
+
]);
|
|
23
|
+
const propagator = new W3CTraceContextPropagator();
|
|
24
|
+
/** A machine-authenticated, framework-neutral POST handler. Never retries the target. */
|
|
25
|
+
export function createManagedTargetHandler(options) {
|
|
26
|
+
token(options.machineCredential);
|
|
27
|
+
const baseUrl = validateOptions({
|
|
28
|
+
apiKey: "managed-invocation",
|
|
29
|
+
serviceName: "managed-target",
|
|
30
|
+
captureContent: false,
|
|
31
|
+
baseUrl: options.baseUrl,
|
|
32
|
+
}).baseUrl;
|
|
33
|
+
const maxExecution = bounded(options.maxExecutionMillis ?? 90_000, 1, 90_000);
|
|
34
|
+
const grace = bounded(options.finalizationMillis ?? 30_000, 1, 30_000);
|
|
35
|
+
if (typeof options.target !== "function" || typeof options.flushTelemetry !== "function")
|
|
36
|
+
throw new TypeError("target and flushTelemetry are required");
|
|
37
|
+
const credential = createHash("sha256").update(`Bearer ${options.machineCredential}`).digest();
|
|
38
|
+
return async (request) => {
|
|
39
|
+
if (request.method !== "POST")
|
|
40
|
+
return reply(405, { error: "method_not_allowed" });
|
|
41
|
+
const supplied = request.headers.get("authorization") ?? "";
|
|
42
|
+
if (supplied.length > 8192 ||
|
|
43
|
+
!timingSafeEqual(credential, createHash("sha256").update(supplied).digest()))
|
|
44
|
+
return reply(401, { error: "authentication" });
|
|
45
|
+
const began = Date.now();
|
|
46
|
+
let invocation;
|
|
47
|
+
let invocationToken;
|
|
48
|
+
try {
|
|
49
|
+
invocationToken = token(request.headers.get("x-hue-invocation-token"));
|
|
50
|
+
const body = await readBytes(request, MIB, AbortSignal.timeout(5_000));
|
|
51
|
+
invocation = validateInvocation(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)));
|
|
52
|
+
if (Date.parse(invocation.deadline) <= Date.now())
|
|
53
|
+
return reply(408, { error: "deadline_exceeded" });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return reply(400, { error: "invalid_invocation" });
|
|
57
|
+
}
|
|
58
|
+
const executionEnd = Math.min(Date.parse(invocation.deadline), began + maxExecution);
|
|
59
|
+
const finalEnd = Math.min(executionEnd + grace, began + maxExecution + grace);
|
|
60
|
+
const signal = AbortSignal.timeout(Math.max(1, executionEnd - Date.now()));
|
|
61
|
+
const api = new InvocationApi(baseUrl, invocationToken, invocation.executionId);
|
|
62
|
+
const uncertain = () => reply(503, { protocolVersion: 1, executionId: invocation.executionId, state: "uncertain" });
|
|
63
|
+
let claim;
|
|
64
|
+
try {
|
|
65
|
+
claim = object(await api.json("POST", "/claim", {}, executionEnd));
|
|
66
|
+
if (typeof claim.claimed !== "boolean" ||
|
|
67
|
+
claim.executionId !== invocation.executionId ||
|
|
68
|
+
!STATES.has(String(claim.state)))
|
|
69
|
+
throw new Error();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return uncertain();
|
|
73
|
+
}
|
|
74
|
+
if (!claim.claimed)
|
|
75
|
+
return reply(409, {
|
|
76
|
+
protocolVersion: 1,
|
|
77
|
+
executionId: invocation.executionId,
|
|
78
|
+
state: claim.state,
|
|
79
|
+
});
|
|
80
|
+
// The application's existing provider owns this span and every child. No global setup.
|
|
81
|
+
const parent = propagator.extract(ROOT_CONTEXT, { traceparent: invocation.traceparent }, {
|
|
82
|
+
keys: (carrier) => Object.keys(carrier),
|
|
83
|
+
get: (carrier, key) => carrier[key],
|
|
84
|
+
});
|
|
85
|
+
const span = (options.tracer ?? trace.getTracer("@hue-run/sdk/managed")).startSpan("ai.managed_target", {
|
|
86
|
+
attributes: {
|
|
87
|
+
"hue.execution.id": invocation.executionId,
|
|
88
|
+
"hue.execution.attempt": invocation.attempt,
|
|
89
|
+
},
|
|
90
|
+
}, parent);
|
|
91
|
+
const spanContext = span.spanContext();
|
|
92
|
+
if (!span.isRecording() || spanContext.traceId !== invocation.traceparent.split("-")[1]) {
|
|
93
|
+
span.end();
|
|
94
|
+
return uncertain();
|
|
95
|
+
}
|
|
96
|
+
let result;
|
|
97
|
+
try {
|
|
98
|
+
const inputFiles = [];
|
|
99
|
+
for (const file of invocation.inputFiles) {
|
|
100
|
+
const data = await api.bytes(`/inputs/${file.artifactId}`, file.byteSize, executionEnd);
|
|
101
|
+
if (data.byteLength !== file.byteSize || sha256(data) !== file.sha256)
|
|
102
|
+
throw new Error("input_integrity");
|
|
103
|
+
inputFiles.push({ ...file, data });
|
|
104
|
+
}
|
|
105
|
+
// Synchronous input hashing or a busy event loop can exhaust the budget
|
|
106
|
+
// before the abort timer runs. Do not start a paid callback in that gap.
|
|
107
|
+
if (Date.now() >= executionEnd)
|
|
108
|
+
throw new Error("deadline");
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
result = await within(context.with(trace.setSpan(parent, span), () => options.target({
|
|
111
|
+
executionId: invocation.executionId,
|
|
112
|
+
attempt: invocation.attempt,
|
|
113
|
+
input: invocation.input,
|
|
114
|
+
config: invocation.config,
|
|
115
|
+
inputFiles,
|
|
116
|
+
signal,
|
|
117
|
+
traceId: spanContext.traceId,
|
|
118
|
+
})), signal);
|
|
119
|
+
validateResult(result);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A timed-out callback may still be running. Do not claim a terminal outcome for it.
|
|
123
|
+
if (signal.aborted || Date.now() >= executionEnd) {
|
|
124
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
125
|
+
span.end();
|
|
126
|
+
return uncertain();
|
|
127
|
+
}
|
|
128
|
+
result = {
|
|
129
|
+
state: "error",
|
|
130
|
+
error: { type: "target_error", message: "The target or input validation failed." },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const artifactIds = [];
|
|
134
|
+
let primaryArtifactId;
|
|
135
|
+
let uploadFailed = false;
|
|
136
|
+
for (const [index, file] of (result.files ?? []).entries()) {
|
|
137
|
+
try {
|
|
138
|
+
const hash = sha256(file.data);
|
|
139
|
+
const reserved = object(await api.json("POST", "/files", {
|
|
140
|
+
idempotencyKey: `file:${index}:${hash}`,
|
|
141
|
+
filename: file.filename,
|
|
142
|
+
contentType: file.contentType,
|
|
143
|
+
byteSize: file.data.byteLength,
|
|
144
|
+
sha256: hash,
|
|
145
|
+
}, finalEnd, true));
|
|
146
|
+
const artifactId = uuid(reserved.artifactId);
|
|
147
|
+
if (reserved.state !== "ready") {
|
|
148
|
+
const uploadUrl = safeUploadUrl(reserved.uploadUrl);
|
|
149
|
+
const headers = uploadHeaders(reserved.headers, file.contentType);
|
|
150
|
+
// Never replay a signed write. Completion independently verifies the
|
|
151
|
+
// stored hash, so it can settle a lost successful acknowledgement.
|
|
152
|
+
try {
|
|
153
|
+
await api.upload(uploadUrl, file.data, headers, finalEnd);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* Verify below. */
|
|
157
|
+
}
|
|
158
|
+
await api.json("POST", `/files/${artifactId}/complete`, {}, finalEnd, true);
|
|
159
|
+
}
|
|
160
|
+
artifactIds.push(artifactId);
|
|
161
|
+
if (file.primary)
|
|
162
|
+
primaryArtifactId = artifactId;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
uploadFailed = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Preserve every successfully uploaded secondary even when another file failed.
|
|
169
|
+
if (uploadFailed)
|
|
170
|
+
result = {
|
|
171
|
+
...result,
|
|
172
|
+
state: "error",
|
|
173
|
+
error: {
|
|
174
|
+
type: "artifact_upload_failed",
|
|
175
|
+
message: "One or more output files could not be stored.",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const state = result.state ?? "succeeded";
|
|
179
|
+
if (state !== "succeeded")
|
|
180
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
181
|
+
span.end();
|
|
182
|
+
const outcome = {
|
|
183
|
+
protocolVersion: 1,
|
|
184
|
+
executionId: invocation.executionId,
|
|
185
|
+
state,
|
|
186
|
+
...(Object.hasOwn(result, "output") ? { output: result.output } : {}),
|
|
187
|
+
...(result.error ? { error: result.error } : {}),
|
|
188
|
+
artifactIds,
|
|
189
|
+
...(primaryArtifactId ? { primaryArtifactId } : {}),
|
|
190
|
+
traceId: spanContext.traceId,
|
|
191
|
+
expectedSpanIds: [spanContext.spanId],
|
|
192
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
await api.json("POST", "/outcome", outcome, finalEnd, true);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return uncertain();
|
|
199
|
+
}
|
|
200
|
+
const acknowledgement = {
|
|
201
|
+
protocolVersion: 1,
|
|
202
|
+
executionId: invocation.executionId,
|
|
203
|
+
state: "checkpointed",
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
const remaining = finalEnd - Date.now();
|
|
207
|
+
if (remaining <= 0)
|
|
208
|
+
throw new Error();
|
|
209
|
+
const flushed = await within(Promise.resolve().then(options.flushTelemetry), AbortSignal.timeout(remaining));
|
|
210
|
+
if (flushed === false)
|
|
211
|
+
throw new Error("flush_failed");
|
|
212
|
+
if (flushed &&
|
|
213
|
+
typeof flushed === "object" &&
|
|
214
|
+
("pendingSpans" in flushed || "pendingLogs" in flushed)) {
|
|
215
|
+
// Hue's failure counters are cumulative; its flush throws for new failures.
|
|
216
|
+
// Pending counters describe the current drain and must both be zero.
|
|
217
|
+
const report = flushed;
|
|
218
|
+
if (report.pendingSpans !== 0 || report.pendingLogs !== 0)
|
|
219
|
+
throw new Error("flush_pending");
|
|
220
|
+
}
|
|
221
|
+
await api.json("POST", "/telemetry", { expectedSpanIds: [spanContext.spanId], flushed: true }, finalEnd, true);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return reply(200, { ...acknowledgement, telemetry: "pending" });
|
|
225
|
+
}
|
|
226
|
+
return reply(200, { ...acknowledgement, telemetry: "flushed" });
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
class InvocationApi {
|
|
230
|
+
baseUrl;
|
|
231
|
+
invocationToken;
|
|
232
|
+
executionId;
|
|
233
|
+
constructor(baseUrl, invocationToken, executionId) {
|
|
234
|
+
this.baseUrl = baseUrl;
|
|
235
|
+
this.invocationToken = invocationToken;
|
|
236
|
+
this.executionId = executionId;
|
|
237
|
+
}
|
|
238
|
+
url(path) {
|
|
239
|
+
return `${this.baseUrl}/api/v1/managed-executions/${this.executionId}${path}`;
|
|
240
|
+
}
|
|
241
|
+
async json(method, path, value, deadline, retry = false) {
|
|
242
|
+
const body = JSON.stringify(json(value, MIB));
|
|
243
|
+
const bytes = await this.request(this.url(path), {
|
|
244
|
+
method,
|
|
245
|
+
headers: {
|
|
246
|
+
authorization: `Bearer ${this.invocationToken}`,
|
|
247
|
+
"content-type": "application/json",
|
|
248
|
+
},
|
|
249
|
+
body,
|
|
250
|
+
}, MIB, deadline, retry);
|
|
251
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
252
|
+
}
|
|
253
|
+
bytes(path, maximum, deadline) {
|
|
254
|
+
return this.request(this.url(path), { headers: { authorization: `Bearer ${this.invocationToken}` } }, maximum, deadline, false);
|
|
255
|
+
}
|
|
256
|
+
async upload(url, data, headers, deadline) {
|
|
257
|
+
const remaining = deadline - Date.now();
|
|
258
|
+
if (remaining <= 0)
|
|
259
|
+
throw new Error("deadline");
|
|
260
|
+
const signal = AbortSignal.timeout(remaining);
|
|
261
|
+
await new Promise((resolve, reject) => {
|
|
262
|
+
let activeResponse;
|
|
263
|
+
const finish = (error) => {
|
|
264
|
+
signal.removeEventListener("abort", abort);
|
|
265
|
+
if (error)
|
|
266
|
+
reject(error);
|
|
267
|
+
else
|
|
268
|
+
resolve();
|
|
269
|
+
};
|
|
270
|
+
const abort = () => {
|
|
271
|
+
activeResponse?.destroy();
|
|
272
|
+
request.destroy();
|
|
273
|
+
finish(new Error("Hue managed upload failed"));
|
|
274
|
+
};
|
|
275
|
+
// Signed writes must not be replayed by fetch, redirect handling or a
|
|
276
|
+
// reused-socket retry. A lost response is settled by Hue's verified read.
|
|
277
|
+
const request = (new URL(url).protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
|
278
|
+
method: "PUT",
|
|
279
|
+
agent: false,
|
|
280
|
+
signal,
|
|
281
|
+
maxHeaderSize: 16 * 1024,
|
|
282
|
+
headers: { ...headers, "content-length": String(data.byteLength) },
|
|
283
|
+
}, (response) => {
|
|
284
|
+
activeResponse = response;
|
|
285
|
+
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
|
286
|
+
response.destroy();
|
|
287
|
+
finish(new Error("Hue managed upload failed"));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
void (async () => {
|
|
291
|
+
let length = 0;
|
|
292
|
+
for await (const chunk of response) {
|
|
293
|
+
signal.throwIfAborted();
|
|
294
|
+
length += Buffer.byteLength(chunk);
|
|
295
|
+
if (length > MIB)
|
|
296
|
+
throw new Error("Upload response too large");
|
|
297
|
+
}
|
|
298
|
+
signal.throwIfAborted();
|
|
299
|
+
finish();
|
|
300
|
+
})().catch(() => {
|
|
301
|
+
response.destroy();
|
|
302
|
+
finish(new Error("Hue managed upload failed"));
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
request.once("error", () => finish(new Error("Hue managed upload failed")));
|
|
306
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
307
|
+
request.end(Buffer.from(data));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async request(url, init, maximum, deadline, retry) {
|
|
311
|
+
for (let attempt = 0;; attempt++) {
|
|
312
|
+
const remaining = deadline - Date.now();
|
|
313
|
+
if (remaining <= 0)
|
|
314
|
+
throw new Error("deadline");
|
|
315
|
+
try {
|
|
316
|
+
const signal = AbortSignal.timeout(remaining);
|
|
317
|
+
const response = await fetch(url, { ...init, redirect: "error", signal });
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
await response.body?.cancel();
|
|
320
|
+
if (![429, 500, 502, 503, 504].includes(response.status))
|
|
321
|
+
throw new PermanentError();
|
|
322
|
+
throw new Error();
|
|
323
|
+
}
|
|
324
|
+
return await readBytes(response, maximum, signal);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!retry ||
|
|
328
|
+
attempt >= 1 ||
|
|
329
|
+
error instanceof PermanentError ||
|
|
330
|
+
Date.now() + 100 >= deadline)
|
|
331
|
+
throw new Error("Hue managed request failed");
|
|
332
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
class PermanentError extends Error {
|
|
338
|
+
}
|
|
339
|
+
function reply(status, body) {
|
|
340
|
+
return Response.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
341
|
+
}
|
|
342
|
+
function sha256(data) {
|
|
343
|
+
return createHash("sha256").update(data).digest("hex");
|
|
344
|
+
}
|
|
345
|
+
function token(value) {
|
|
346
|
+
if (typeof value !== "string" || !value || value.length > 8192 || /[\s\u0000]/u.test(value))
|
|
347
|
+
throw new TypeError("Invalid credential");
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
function bounded(value, minimum, maximum) {
|
|
351
|
+
if (typeof value !== "number" ||
|
|
352
|
+
!Number.isSafeInteger(value) ||
|
|
353
|
+
value < minimum ||
|
|
354
|
+
value > maximum)
|
|
355
|
+
throw new TypeError("Invalid integer");
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
function object(value) {
|
|
359
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
360
|
+
throw new TypeError("Expected object");
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
function filename(value) {
|
|
364
|
+
if (typeof value !== "string" ||
|
|
365
|
+
!value.trim() ||
|
|
366
|
+
value.length > 255 ||
|
|
367
|
+
/[\x00-\x1f\x7f/\\]/u.test(value) ||
|
|
368
|
+
value === "." ||
|
|
369
|
+
value === "..")
|
|
370
|
+
throw new TypeError("Invalid filename");
|
|
371
|
+
return value;
|
|
372
|
+
}
|
|
373
|
+
function shortString(value, maximum = 255) {
|
|
374
|
+
if (typeof value !== "string" ||
|
|
375
|
+
!value ||
|
|
376
|
+
value.length > maximum ||
|
|
377
|
+
/[\x00-\x1f\x7f]/u.test(value))
|
|
378
|
+
throw new TypeError("Invalid string");
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
function validateInvocation(value) {
|
|
382
|
+
const v = object(json(value, MIB));
|
|
383
|
+
if (Object.keys(v).some((key) => ![
|
|
384
|
+
"protocolVersion",
|
|
385
|
+
"executionId",
|
|
386
|
+
"attempt",
|
|
387
|
+
"input",
|
|
388
|
+
"config",
|
|
389
|
+
"inputFiles",
|
|
390
|
+
"deadline",
|
|
391
|
+
"traceparent",
|
|
392
|
+
].includes(key)))
|
|
393
|
+
throw new TypeError("Unknown invocation field");
|
|
394
|
+
if (v.protocolVersion !== 1 || !Object.hasOwn(v, "input") || !Object.hasOwn(v, "config"))
|
|
395
|
+
throw new TypeError("Invalid protocol");
|
|
396
|
+
uuid(v.executionId);
|
|
397
|
+
bounded(v.attempt, 1, Number.MAX_SAFE_INTEGER);
|
|
398
|
+
if (typeof v.deadline !== "string" ||
|
|
399
|
+
v.deadline.trim() !== v.deadline ||
|
|
400
|
+
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(v.deadline) ||
|
|
401
|
+
!Number.isFinite(Date.parse(v.deadline)))
|
|
402
|
+
throw new TypeError("Invalid deadline");
|
|
403
|
+
if (typeof v.traceparent !== "string" ||
|
|
404
|
+
v.traceparent.length !== 55 ||
|
|
405
|
+
!/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/.test(v.traceparent) ||
|
|
406
|
+
/^00-0{32}-/.test(v.traceparent) ||
|
|
407
|
+
/-0{16}-01$/.test(v.traceparent))
|
|
408
|
+
throw new TypeError("A sampled W3C traceparent is required");
|
|
409
|
+
if (!Array.isArray(v.inputFiles) || v.inputFiles.length > 16)
|
|
410
|
+
throw new TypeError("Invalid files");
|
|
411
|
+
let total = 0;
|
|
412
|
+
const ids = new Set();
|
|
413
|
+
for (const raw of v.inputFiles) {
|
|
414
|
+
const file = object(raw);
|
|
415
|
+
const id = uuid(file.artifactId);
|
|
416
|
+
if (Object.keys(file).some((key) => !["artifactId", "filename", "contentType", "byteSize", "sha256", "role"].includes(key)))
|
|
417
|
+
throw new TypeError("Unknown file field");
|
|
418
|
+
if (ids.has(id))
|
|
419
|
+
throw new TypeError("Duplicate input file");
|
|
420
|
+
ids.add(id);
|
|
421
|
+
filename(file.filename);
|
|
422
|
+
shortString(file.contentType);
|
|
423
|
+
shortString(file.role, 64);
|
|
424
|
+
total += bounded(file.byteSize, 0, MAX_FILE);
|
|
425
|
+
if (typeof file.sha256 !== "string" ||
|
|
426
|
+
file.sha256.length !== 64 ||
|
|
427
|
+
!/^[0-9a-f]{64}$/.test(file.sha256))
|
|
428
|
+
throw new TypeError("Invalid hash");
|
|
429
|
+
}
|
|
430
|
+
if (total > MAX_TOTAL)
|
|
431
|
+
throw new TypeError("Input files too large");
|
|
432
|
+
return v;
|
|
433
|
+
}
|
|
434
|
+
function validateResult(result) {
|
|
435
|
+
object(result);
|
|
436
|
+
if (result.state && !["succeeded", "error", "cancelled"].includes(result.state))
|
|
437
|
+
throw new TypeError("Invalid outcome");
|
|
438
|
+
if (Object.hasOwn(result, "output"))
|
|
439
|
+
json(result.output);
|
|
440
|
+
if (result.error) {
|
|
441
|
+
shortString(result.error.type, 64);
|
|
442
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(result.error.type))
|
|
443
|
+
throw new TypeError("Invalid error type");
|
|
444
|
+
if (result.error.message !== undefined)
|
|
445
|
+
shortString(result.error.message, 1000);
|
|
446
|
+
}
|
|
447
|
+
if (result.usage)
|
|
448
|
+
for (const count of Object.values(result.usage))
|
|
449
|
+
bounded(count, 0, Number.MAX_SAFE_INTEGER);
|
|
450
|
+
if (result.files !== undefined && (!Array.isArray(result.files) || result.files.length > 16))
|
|
451
|
+
throw new TypeError("Invalid files");
|
|
452
|
+
let total = 0;
|
|
453
|
+
let primary = 0;
|
|
454
|
+
for (const file of result.files ?? []) {
|
|
455
|
+
filename(file.filename);
|
|
456
|
+
shortString(file.contentType);
|
|
457
|
+
if (!(file.data instanceof Uint8Array))
|
|
458
|
+
throw new TypeError("File data must be bytes");
|
|
459
|
+
total += bounded(file.data.byteLength, 0, MAX_FILE);
|
|
460
|
+
if (file.primary !== undefined && typeof file.primary !== "boolean")
|
|
461
|
+
throw new TypeError("Invalid primary");
|
|
462
|
+
if (file.primary)
|
|
463
|
+
primary++;
|
|
464
|
+
}
|
|
465
|
+
if (total > MAX_TOTAL || primary > 1)
|
|
466
|
+
throw new TypeError("Invalid output files");
|
|
467
|
+
}
|
|
468
|
+
function safeUploadUrl(value) {
|
|
469
|
+
if (typeof value !== "string" || value.length > 8192 || /[\x00-\x20\x7f]/u.test(value))
|
|
470
|
+
throw new TypeError("Invalid upload URL");
|
|
471
|
+
const url = new URL(value);
|
|
472
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
473
|
+
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
|
|
474
|
+
url.username ||
|
|
475
|
+
url.password ||
|
|
476
|
+
url.hash)
|
|
477
|
+
throw new TypeError("Invalid upload URL");
|
|
478
|
+
// Validate without rewriting the provider's signed capability.
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
function uploadHeaders(value, contentType) {
|
|
482
|
+
const headers = { "content-type": contentType };
|
|
483
|
+
if (value === undefined || value === null)
|
|
484
|
+
return headers;
|
|
485
|
+
for (const [name, raw] of Object.entries(object(value))) {
|
|
486
|
+
const lower = name.toLowerCase();
|
|
487
|
+
const value = shortString(raw);
|
|
488
|
+
if (lower === "content-type" && value === contentType)
|
|
489
|
+
headers[lower] = value;
|
|
490
|
+
else if (lower === "x-vercel-blob-access" && value === "private")
|
|
491
|
+
headers[lower] = value;
|
|
492
|
+
else
|
|
493
|
+
throw new TypeError("Unsupported upload header");
|
|
494
|
+
}
|
|
495
|
+
return headers;
|
|
496
|
+
}
|
|
497
|
+
async function within(promise, signal) {
|
|
498
|
+
signal.throwIfAborted();
|
|
499
|
+
let abort;
|
|
500
|
+
try {
|
|
501
|
+
return await Promise.race([
|
|
502
|
+
promise,
|
|
503
|
+
new Promise((_, reject) => {
|
|
504
|
+
abort = () => reject(new Error("Deadline exceeded"));
|
|
505
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
506
|
+
}),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
signal.removeEventListener("abort", abort);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function readBytes(source, maximum, signal) {
|
|
514
|
+
const declared = source.headers.get("content-length");
|
|
515
|
+
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maximum))
|
|
516
|
+
throw new Error("Oversized body");
|
|
517
|
+
if (!source.body)
|
|
518
|
+
return new Uint8Array();
|
|
519
|
+
const reader = source.body.getReader();
|
|
520
|
+
const chunks = [];
|
|
521
|
+
let size = 0;
|
|
522
|
+
try {
|
|
523
|
+
while (true) {
|
|
524
|
+
const next = await within(reader.read(), signal);
|
|
525
|
+
if (next.done)
|
|
526
|
+
break;
|
|
527
|
+
size += next.value.byteLength;
|
|
528
|
+
if (size > maximum)
|
|
529
|
+
throw new Error("Oversized body");
|
|
530
|
+
chunks.push(next.value);
|
|
531
|
+
}
|
|
532
|
+
return Buffer.concat(chunks, size);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
void reader.cancel().catch(() => { });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HueOptions, TraceVerification, VerifyTraceOptions } from "./types.js";
|
|
2
|
+
export declare class HueTraceVerificationError extends Error {
|
|
3
|
+
readonly code: "authentication" | "http" | "invalid_response" | "transport";
|
|
4
|
+
readonly status?: number | undefined;
|
|
5
|
+
constructor(code: "authentication" | "http" | "invalid_response" | "transport", message: string, status?: number | undefined);
|
|
6
|
+
}
|
|
7
|
+
/** Observe persisted evidence after the application and its exporter have finished. */
|
|
8
|
+
export declare function verifyTrace(connection: Pick<HueOptions, "apiKey"> & {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
}, traceId: string, options?: VerifyTraceOptions): Promise<TraceVerification>;
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const fields = ["input", "output", "model", "usage", "session"];
|
|
2
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
3
|
+
export class HueTraceVerificationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
constructor(code, message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "HueTraceVerificationError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function invalidResponse() {
|
|
14
|
+
throw new HueTraceVerificationError("invalid_response", "Hue returned an invalid trace receipt. Check the server and SDK versions.");
|
|
15
|
+
}
|
|
16
|
+
function validId(value, length) {
|
|
17
|
+
return (typeof value === "string" &&
|
|
18
|
+
value.length === length &&
|
|
19
|
+
new RegExp(`^[0-9a-f]{${length}}$`).test(value) &&
|
|
20
|
+
!/^0+$/.test(value));
|
|
21
|
+
}
|
|
22
|
+
function record(value) {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function parseReceipt(value, traceId, expected, origin) {
|
|
26
|
+
if (!record(value) ||
|
|
27
|
+
value.traceId !== traceId ||
|
|
28
|
+
!Number.isSafeInteger(value.spanCount) ||
|
|
29
|
+
value.spanCount < 0 ||
|
|
30
|
+
!Number.isSafeInteger(value.revision) ||
|
|
31
|
+
value.revision < 0 ||
|
|
32
|
+
!record(value.fields) ||
|
|
33
|
+
fields.some((field) => typeof value.fields[field] !== "boolean") ||
|
|
34
|
+
typeof value.traceUrl !== "string" ||
|
|
35
|
+
value.traceUrl.length > 2048)
|
|
36
|
+
invalidResponse();
|
|
37
|
+
let traceUrl;
|
|
38
|
+
try {
|
|
39
|
+
traceUrl = new URL(value.traceUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
invalidResponse();
|
|
43
|
+
}
|
|
44
|
+
if (traceUrl.origin !== origin || traceUrl.username || traceUrl.password)
|
|
45
|
+
invalidResponse();
|
|
46
|
+
const matched = value.matchedSpanIds, missing = value.missingSpanIds;
|
|
47
|
+
if (!Array.isArray(matched) ||
|
|
48
|
+
!Array.isArray(missing) ||
|
|
49
|
+
matched.length + missing.length !== expected.length ||
|
|
50
|
+
[...matched, ...missing].some((id) => !validId(id, 16) || !expected.includes(id)) ||
|
|
51
|
+
new Set([...matched, ...missing]).size !== expected.length ||
|
|
52
|
+
matched.length > value.spanCount)
|
|
53
|
+
invalidResponse();
|
|
54
|
+
// Return only the documented fields, never arbitrary response content.
|
|
55
|
+
return {
|
|
56
|
+
traceId,
|
|
57
|
+
spanCount: value.spanCount,
|
|
58
|
+
revision: value.revision,
|
|
59
|
+
fields: Object.fromEntries(fields.map((field) => [field, value.fields[field]])),
|
|
60
|
+
matchedSpanIds: expected.filter((id) => matched.includes(id)),
|
|
61
|
+
missingSpanIds: expected.filter((id) => missing.includes(id)),
|
|
62
|
+
traceUrl: traceUrl.href,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function readJson(response) {
|
|
66
|
+
const length = response.headers.get("content-length");
|
|
67
|
+
if (length && /^\d+$/.test(length) && Number(length) > MAX_RESPONSE_BYTES) {
|
|
68
|
+
await response.body?.cancel();
|
|
69
|
+
invalidResponse();
|
|
70
|
+
}
|
|
71
|
+
const reader = response.body?.getReader();
|
|
72
|
+
if (!reader)
|
|
73
|
+
invalidResponse();
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let size = 0;
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
const next = await reader.read();
|
|
79
|
+
if (next.done)
|
|
80
|
+
break;
|
|
81
|
+
size += next.value.byteLength;
|
|
82
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
83
|
+
await reader.cancel();
|
|
84
|
+
invalidResponse();
|
|
85
|
+
}
|
|
86
|
+
chunks.push(next.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
reader.releaseLock();
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
invalidResponse();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function retryDelay(value) {
|
|
100
|
+
if (!value)
|
|
101
|
+
return 0;
|
|
102
|
+
if (/^\d+$/.test(value))
|
|
103
|
+
return Number(value) * 1000;
|
|
104
|
+
const date = Date.parse(value);
|
|
105
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
|
|
106
|
+
}
|
|
107
|
+
async function pause(milliseconds, signal) {
|
|
108
|
+
if (signal.aborted)
|
|
109
|
+
return;
|
|
110
|
+
await new Promise((resolve) => {
|
|
111
|
+
const finish = () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
signal.removeEventListener("abort", finish);
|
|
114
|
+
resolve();
|
|
115
|
+
};
|
|
116
|
+
const timer = setTimeout(finish, milliseconds);
|
|
117
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Observe persisted evidence after the application and its exporter have finished. */
|
|
121
|
+
export async function verifyTrace(connection, traceId, options = {}) {
|
|
122
|
+
if (!validId(traceId, 32))
|
|
123
|
+
throw new TypeError("traceId must be a nonzero lowercase 32-character OpenTelemetry trace ID");
|
|
124
|
+
if (options === null || typeof options !== "object" || Array.isArray(options))
|
|
125
|
+
throw new TypeError("Trace verification options must be an object");
|
|
126
|
+
const expected = options.expectedSpanIds === undefined ? [] : options.expectedSpanIds;
|
|
127
|
+
if (!Array.isArray(expected) ||
|
|
128
|
+
expected.length > 100 ||
|
|
129
|
+
expected.some((id) => !validId(id, 16)) ||
|
|
130
|
+
new Set(expected).size !== expected.length)
|
|
131
|
+
throw new TypeError("expectedSpanIds must contain at most 100 unique nonzero lowercase 16-character span IDs");
|
|
132
|
+
const required = options.requiredFields === undefined ? [] : options.requiredFields;
|
|
133
|
+
if (!Array.isArray(required) ||
|
|
134
|
+
required.some((field) => !fields.includes(field)) ||
|
|
135
|
+
new Set(required).size !== required.length)
|
|
136
|
+
throw new TypeError("requiredFields must contain unique receipt field names: input, output, model, usage, session");
|
|
137
|
+
const timeout = options.timeoutMillis === undefined ? 10_000 : options.timeoutMillis;
|
|
138
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 60_000)
|
|
139
|
+
throw new TypeError("timeoutMillis must be greater than zero and at most 60000");
|
|
140
|
+
// Snapshot caller arrays so concurrent mutation cannot alter the verification criteria.
|
|
141
|
+
const expectedIds = [...expected], requiredFields = [...required];
|
|
142
|
+
const url = new URL(`/api/v1/traces/${traceId}/receipt`, connection.baseUrl);
|
|
143
|
+
for (const id of expectedIds)
|
|
144
|
+
url.searchParams.append("expectedSpanId", id);
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const deadline = performance.now() + timeout;
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
148
|
+
let receipt = null;
|
|
149
|
+
let delay = 250;
|
|
150
|
+
try {
|
|
151
|
+
while (!controller.signal.aborted && performance.now() < deadline) {
|
|
152
|
+
const response = await fetch(url, {
|
|
153
|
+
headers: { Authorization: `Bearer ${connection.apiKey}`, Accept: "application/json" },
|
|
154
|
+
redirect: "manual",
|
|
155
|
+
credentials: "omit",
|
|
156
|
+
cache: "no-store",
|
|
157
|
+
signal: controller.signal,
|
|
158
|
+
});
|
|
159
|
+
let retryAfter = 0;
|
|
160
|
+
if (response.status === 200) {
|
|
161
|
+
receipt = parseReceipt(await readJson(response), traceId, expectedIds, url.origin);
|
|
162
|
+
if (!controller.signal.aborted &&
|
|
163
|
+
performance.now() < deadline &&
|
|
164
|
+
receipt.missingSpanIds.length === 0 &&
|
|
165
|
+
requiredFields.every((field) => receipt.fields[field]))
|
|
166
|
+
return { verified: true, receipt };
|
|
167
|
+
}
|
|
168
|
+
else if (response.status === 404) {
|
|
169
|
+
const body = await readJson(response);
|
|
170
|
+
if (!record(body) || body.code !== "TRACE_NOT_FOUND")
|
|
171
|
+
throw new HueTraceVerificationError("http", "This Hue server does not support trace receipts. Check the server version and baseUrl.", 404);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
if (response.status === 429 || response.status === 503) {
|
|
176
|
+
retryAfter = retryDelay(response.headers.get("retry-after"));
|
|
177
|
+
}
|
|
178
|
+
else if (response.status === 401 || response.status === 403) {
|
|
179
|
+
throw new HueTraceVerificationError("authentication", "Hue denied trace verification. Check the project key, its access, and expiration.", response.status);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
throw new HueTraceVerificationError("http", "Hue refused trace verification. Check the server version and configured origin; redirects are not followed.", response.status);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const remaining = deadline - performance.now();
|
|
186
|
+
if (remaining <= 0)
|
|
187
|
+
break;
|
|
188
|
+
const wait = Math.max(delay, retryAfter);
|
|
189
|
+
await pause(Math.min(wait, remaining), controller.signal);
|
|
190
|
+
// A truncated backoff exhausts this call even if a timer wakes just early.
|
|
191
|
+
if (wait >= remaining)
|
|
192
|
+
break;
|
|
193
|
+
delay = Math.min(delay * 2, 1000);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!controller.signal.aborted && performance.now() < deadline) {
|
|
198
|
+
if (error instanceof HueTraceVerificationError)
|
|
199
|
+
throw error;
|
|
200
|
+
throw new HueTraceVerificationError("transport", "Hue trace verification could not reach the server. Check the network and configured origin.");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
return { verified: false, receipt };
|
|
207
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -61,3 +61,24 @@ export interface ProjectConnection {
|
|
|
61
61
|
organizationId: string;
|
|
62
62
|
slug: string;
|
|
63
63
|
}
|
|
64
|
+
export type TraceReceiptField = "input" | "output" | "model" | "usage" | "session";
|
|
65
|
+
export interface TraceReceipt {
|
|
66
|
+
traceId: string;
|
|
67
|
+
spanCount: number;
|
|
68
|
+
revision: number;
|
|
69
|
+
/** Presence of stored normalized fields; not a judgment of content correctness. */
|
|
70
|
+
fields: Record<TraceReceiptField, boolean>;
|
|
71
|
+
matchedSpanIds: string[];
|
|
72
|
+
missingSpanIds: string[];
|
|
73
|
+
traceUrl: string;
|
|
74
|
+
}
|
|
75
|
+
export interface VerifyTraceOptions {
|
|
76
|
+
expectedSpanIds?: string[];
|
|
77
|
+
requiredFields?: TraceReceiptField[];
|
|
78
|
+
/** Total request/retry budget, including response bodies. Default 10000; maximum 60000. */
|
|
79
|
+
timeoutMillis?: number;
|
|
80
|
+
}
|
|
81
|
+
export interface TraceVerification {
|
|
82
|
+
verified: boolean;
|
|
83
|
+
receipt: TraceReceipt | null;
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hue-run/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"README.md",
|
|
21
21
|
"EVALUATIONS.md",
|
|
22
|
+
"MANAGED_TARGETS.md",
|
|
22
23
|
"LICENSE"
|
|
23
24
|
],
|
|
24
25
|
"type": "module",
|
|
@@ -34,6 +35,10 @@
|
|
|
34
35
|
"./evals": {
|
|
35
36
|
"types": "./dist/evals.d.ts",
|
|
36
37
|
"import": "./dist/evals.js"
|
|
38
|
+
},
|
|
39
|
+
"./managed": {
|
|
40
|
+
"types": "./dist/managed.d.ts",
|
|
41
|
+
"import": "./dist/managed.js"
|
|
37
42
|
}
|
|
38
43
|
},
|
|
39
44
|
"scripts": {
|