@lionhummer/constellation 0.9.1 → 0.9.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/README.md CHANGED
@@ -341,8 +341,67 @@ tools: {
341
341
  },
342
342
  ```
343
343
 
344
+ Work you do before the first model step, such as loading context and building the prompt, can travel with the request that starts the run: pass `telemetry: { spans, logs }` to `requests.submit`. Those spans belong to the request rather than a step: they sit at the top of the timeline at their own timestamps, before the first step (set `stepId` or `parentSpanId` to nest them elsewhere).
345
+
344
346
  The SDK records `client.processing` and one span per tool automatically. A response carries at most 512 spans, 128 logs and 1 MiB of telemetry; the SDK trims larger batches and records what it omitted. Do not put secrets or personal data in span names, attributes or log messages.
345
347
 
348
+ ## Opening lines
349
+
350
+ Show a one-sentence opener while the real step is prepared. The operator configures its model and prompt in Constellation.
351
+
352
+ ```ts
353
+ const opener = await client.openers.create({
354
+ idempotencyKey: `${turnId}`,
355
+ workspaceId, userId, externalId: threadId,
356
+ message,
357
+ history: recentTurns, // [{ role: "user" | "assistant", text }], oldest first
358
+ })
359
+ if (opener) void client.openers.sentence(opener.id).then((sentence) => {
360
+ if (sentence && !realOutputStarted) show(sentence)
361
+ })
362
+ ```
363
+
364
+ Start the real request without waiting for the opener. `parseOpenerSentence(text)` applies the same checks to text you already have.
365
+
366
+ ## Telemetry queries
367
+
368
+ Ask aggregate questions without SQL. Fields, filters and metrics are typed per dataset.
369
+
370
+ ```ts
371
+ // How many fireworks-direct attempts failed yesterday, and why?
372
+ const failures = await client.telemetry.query({
373
+ dataset: "attempts",
374
+ from: "2026-10-15T00:00:00Z", to: "2026-10-16T00:00:00Z",
375
+ where: { offeringId: { startsWith: "fireworks-direct:" }, status: "failed" },
376
+ groupBy: ["errorCode", "httpStatus"],
377
+ metrics: ["count", "uniq:requestId"],
378
+ })
379
+
380
+ // How often did a provider say "Response too long", per day?
381
+ const mentions = await client.telemetry.query({
382
+ dataset: "attempts",
383
+ from: "2026-10-01T00:00:00Z", to: "2026-10-16T00:00:00Z",
384
+ where: { errorMessage: { contains: "Response too long" } },
385
+ groupBy: ["day"],
386
+ })
387
+ ```
388
+
389
+ Usage and costs come from the permanent billing record (`usage`, one row per settled request, or `usageAttempts`, one row per provider attempt for provider/model splits). Any range works:
390
+
391
+ ```ts
392
+ // Customer charges, supplier cost and margin per workspace and provider last month, without QA
393
+ const byProvider = await client.telemetry.query({
394
+ dataset: "usageAttempts",
395
+ from: "2026-09-01T00:00:00Z", to: "2026-10-01T00:00:00Z",
396
+ where: { workspaceId: { notIn: ["qa"] } },
397
+ groupBy: ["workspaceId", "providerId"],
398
+ metrics: ["uniq:requestId", "sum:customerCost", "sum:supplierCost", "sum:margin"],
399
+ limit: 10000,
400
+ })
401
+ ```
402
+
403
+ A scoped key only ever sees its workspace and user. Logs and usage need an unscoped key. Telemetry ranges are at most 92 days and results at most 1,000 rows; usage allows any range and 10,000 rows. Group by `minute`, `hour`, `day`, `week` or `month` for time series.
404
+
346
405
  ## Limits and timeouts
347
406
 
348
407
  | Setting | Default | Notes |
@@ -367,6 +426,7 @@ A failed provider call falls back to the next provider only before any output ha
367
426
  | `requests.submit(input)` | Start a request, continue it (`inReplyTo`), or stop it (`action: "observe"`). |
368
427
  | `requests.preview(input)` | Validate a complete input and show routing, without calling a provider. |
369
428
  | `requests.cancel(id)` | Cancel a request. |
429
+ | `openers.create(input)`, `openers.sentence(id)` | Start an opening line for a just-sent message (null when openers are turned off), then wait for its sentence (null when skipped or failed). |
370
430
  | `requests.getState(id)` | Committed state: status, steps, attempt outcomes, usage and cost. |
371
431
  | `requests.getSummary(id)`, `requests.get(id)` | Request detail, without or with spans and logs. |
372
432
  | `requests.list(query)` | Page through requests, e.g. by `externalId` or `status`. |
@@ -375,6 +435,7 @@ A failed provider call falls back to the next provider only before any output ha
375
435
  | `requests.getTimeline(id)`, `requests.getTimings(id)` | Trace and timing breakdown. |
376
436
  | `requests.getHttpExchange(id, attemptId)`, `requests.getHttpBody(...)` | The provider HTTP call captured for one attempt. |
377
437
  | `webhooks.verify(request, token)` | Authenticate and parse an incoming webhook. |
438
+ | `telemetry.query(query)` | Count, group and aggregate attempts, requests, logs or permanent usage, e.g. failed attempts per offering and error code, or cost per workspace and provider. Needs telemetry permission; the key's scope always applies. |
378
439
  | `telemetry.*` | Read-only reporting. Returns metadata only unless you pass `include: "content"`. Needs telemetry permission. |
379
440
  | `management.registry.*` | Edit the model registry. Needs management permission. |
380
441
  | `discover()` | The OpenAPI document for exactly what this key may call. |
package/dist/index.js CHANGED
@@ -1,4 +1,15 @@
1
1
  // ../client-core/src/index.ts
2
+ function parseOpenerSentence(raw, maxWords = 25) {
3
+ const text = raw.trim();
4
+ if (!text || /^SKIP\b/i.test(text))
5
+ return null;
6
+ const sentence = (text.match(/^.+?[.!](?=\s|[A-Z]|$)/)?.[0] ?? text).trim();
7
+ if (sentence.includes("`") || sentence.includes(`
8
+ `) || sentence.split(/\s+/).length > maxWords)
9
+ return null;
10
+ return sentence;
11
+ }
12
+
2
13
  class ConstellationError extends Error {
3
14
  status;
4
15
  code;
@@ -300,6 +311,30 @@ class RequestClient {
300
311
  resolve: (input, signal) => this.#call("/v1/registry/resolve", "POST", input, signal)
301
312
  };
302
313
  }
314
+ get openers() {
315
+ return {
316
+ create: async (input, signal) => await this.#call("/v1/openers", "POST", input, signal) ?? null,
317
+ sentence: async (id, options = {}) => {
318
+ let text = "";
319
+ for await (const event of this.#events(id, { signal: options.signal })) {
320
+ if (event.type === "model.completed") {
321
+ const message = event.data.message;
322
+ text = message?.text ?? text;
323
+ break;
324
+ }
325
+ if (event.type === "model.failed" || event.type === "model.cancelled")
326
+ return null;
327
+ if (event.type === "model.delta") {
328
+ for (const entry of event.data.events ?? []) {
329
+ if (entry.event?.type === "text_delta")
330
+ text += entry.event.delta ?? "";
331
+ }
332
+ }
333
+ }
334
+ return parseOpenerSentence(text, options.maxWords);
335
+ }
336
+ };
337
+ }
303
338
  get requests() {
304
339
  return {
305
340
  submit: (input, signal) => this.#submit(input, signal),
@@ -729,6 +764,10 @@ class Constellation extends RequestClient {
729
764
  get: (id, query = {}, signal) => this.#call(`/v1/telemetry/notifications/${encodeURIComponent(id)}${queryString2(query)}`, "GET", undefined, signal),
730
765
  listEvents: (query = {}, signal) => this.#call(`/v1/telemetry/notifications/events${queryString2(query)}`, "GET", undefined, signal)
731
766
  },
767
+ query: (query, signal) => {
768
+ const iso = (value) => value instanceof Date ? value.toISOString() : value;
769
+ return this.#call("/v1/telemetry/query", "POST", { ...query, from: iso(query.from), to: iso(query.to) }, signal);
770
+ },
732
771
  logs: {
733
772
  searchSystem: (query = {}, signal) => this.#call(`/v1/telemetry/logs/system${queryString2(query)}`, "GET", undefined, signal),
734
773
  searchRequests: (query = {}, signal) => this.#call(`/v1/telemetry/logs/requests${queryString2(query)}`, "GET", undefined, signal),
@@ -770,6 +809,7 @@ export {
770
809
  uuid7,
771
810
  processEvent,
772
811
  prepareResponse,
812
+ parseOpenerSentence,
773
813
  normalizeTimestamp,
774
814
  PermanentEventError,
775
815
  PERMANENT_RESPONSE_CODES,
@@ -304,6 +304,24 @@ export type ModelDeltaEvent = {
304
304
  };
305
305
  };
306
306
  export type RequestEvent = WebhookEvent | ModelDeltaEvent;
307
+ export type OpenerTurn = {
308
+ role: "user" | "assistant";
309
+ text: string;
310
+ };
311
+ export type OpenerInput = {
312
+ /** Stable per turn, so a retry never admits a second opener. */
313
+ idempotencyKey: string;
314
+ workspaceId: string;
315
+ userId?: string;
316
+ externalId?: string;
317
+ planId?: string;
318
+ /** The message the user just sent. */
319
+ message: string;
320
+ /** Earlier turns, oldest first; operator settings decide how many are used. */
321
+ history?: OpenerTurn[];
322
+ };
323
+ /** The opener line to show: one sentence, no code, within `maxWords`; null for SKIP or anything else. */
324
+ export declare function parseOpenerSentence(raw: string, maxWords?: number): string | null;
307
325
  export type Page<T> = {
308
326
  items: T[];
309
327
  total: number;
@@ -503,6 +521,22 @@ export declare class RequestClient {
503
521
  list: (scope: ModelScope, signal?: AbortSignal) => Promise<Json>;
504
522
  resolve: (input: ModelResolution, signal?: AbortSignal) => Promise<Json>;
505
523
  };
524
+ /**
525
+ * Opening lines: an operator-configured short model call, made when the user
526
+ * sends, naming what the assistant does first. create admits it as an
527
+ * ordinary request; sentence waits for it to end and returns the line to
528
+ * show, or null for SKIP, a failure, or an answer that breaks the format.
529
+ */
530
+ get openers(): {
531
+ /** The opener request, or null when the operator has turned openers off. */
532
+ create: (input: OpenerInput, signal?: AbortSignal) => Promise<{
533
+ id: string;
534
+ } | null>;
535
+ sentence: (id: string, options?: {
536
+ signal?: AbortSignal;
537
+ maxWords?: number;
538
+ }) => Promise<string | null>;
539
+ };
506
540
  /** Durable requests. create returns an ID; execution continues independently. */
507
541
  get requests(): {
508
542
  submit: (input: Admission, signal?: AbortSignal) => Promise<AdmissionReceipt>;
@@ -1,7 +1,7 @@
1
1
  /** External server SDK. Operator APIs and cookie sessions are internal-only clients. */
2
2
  import { RequestClient, type WebhookEvent, type Json, type Page, type PageQuery, type ModelResolution, type RequestRecord, type RequestEvent, type RecordTypes, type Timeline, type TimelineRow, type RequestListQuery, type RequestRecordsQuery, type RequestTimings, type TokenUsage } from "../../client-core/src/index";
3
- export { ConstellationError, uuid7, normalizeTimestamp } from "../../client-core/src/index";
4
- export type { InferenceParameters, Json, Tool, ToolCall, Message, RequestEventModes, RequestInput, Admission, AdmissionReceipt, ResponseOutcome, ObservationResponse, Observations, EventReference, ModelCompletedEvent, ModelDeltaEvent, ModelStreamFrame, RequestTimings, TimingInterval, TokenUsage, TokenPrice, CustomerPricing, BillingFacts, Status, RequestRecord, Step, Attempt, Span, TimelineRow, Timeline, Log, Inspection, RecordTypes, RequestEvent, Page, PageQuery, ModelScope, ModelResolution, WebhookEvent, RequestRecordsQuery, RequestListQuery } from "../../client-core/src/index";
3
+ export { ConstellationError, uuid7, normalizeTimestamp, parseOpenerSentence } from "../../client-core/src/index";
4
+ export type { InferenceParameters, Json, Tool, ToolCall, Message, RequestEventModes, RequestInput, Admission, AdmissionReceipt, ResponseOutcome, ObservationResponse, Observations, EventReference, ModelCompletedEvent, ModelDeltaEvent, ModelStreamFrame, RequestTimings, TimingInterval, TokenUsage, TokenPrice, CustomerPricing, BillingFacts, Status, RequestRecord, Step, Attempt, Span, TimelineRow, Timeline, Log, Inspection, RecordTypes, RequestEvent, Page, PageQuery, ModelScope, ModelResolution, WebhookEvent, RequestRecordsQuery, RequestListQuery, OpenerInput, OpenerTurn } from "../../client-core/src/index";
5
5
  /** OpenAPI contract filtered to this key's permissions and scope. */
6
6
  export type DiscoveryDocument = {
7
7
  openapi: string;
@@ -28,6 +28,63 @@ export type TelemetryPage<T> = Page<T> & {
28
28
  export type TelemetryQuery = {
29
29
  include?: "content";
30
30
  };
31
+ /** Fields of each aggregate dataset, as POST /v1/telemetry/query accepts them. */
32
+ export type AttemptField = "workspaceId" | "userId" | "externalId" | "requestId" | "providerId" | "offeringId" | "model" | "requestedModel" | "status" | "fallback" | "dispatched" | "errorCode" | "faultClass" | "httpStatus" | "errorMessage";
33
+ export type AttemptNumber = "ttftMs" | "latencyMs" | "outputTokens" | "supplierCost" | "customerCost" | "httpStatus";
34
+ export type RequestField = "workspaceId" | "userId" | "externalId" | "requestId" | "requestedModel" | "status";
35
+ export type RequestNumber = "durationMs" | "supplierCost" | "customerCost";
36
+ export type LogField = "level" | "name" | "status" | "requestId" | "message";
37
+ /** Permanent billing record per settled request (unscoped telemetry keys only). */
38
+ export type UsageField = "workspaceId" | "userId" | "externalId" | "requestId" | "requestedModel" | "planId" | "status";
39
+ type UsageTotals = "supplierCost" | "customerCost" | "margin" | "inputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "cacheWriteOneHourTokens" | "outputTokens" | "reasoningTokens";
40
+ export type UsageNumber = UsageTotals | "durationMs";
41
+ /** Permanent billing record per provider attempt; `status` is the attempt's, `requestStatus` the request's. */
42
+ export type UsageAttemptField = Exclude<UsageField, "status"> | "requestStatus" | "providerId" | "model" | "providerModel" | "status";
43
+ export type UsageAttemptNumber = UsageTotals;
44
+ export type AggregateTimeGroup = "minute" | "hour" | "day" | "week" | "month";
45
+ export type AggregateScalar = string | number | boolean;
46
+ /** A value, any of several values, or operators combined with AND. `contains` is case-insensitive. */
47
+ export type AggregateFilter = AggregateScalar | AggregateScalar[] | {
48
+ eq?: AggregateScalar;
49
+ in?: AggregateScalar[];
50
+ not?: AggregateScalar;
51
+ notIn?: AggregateScalar[];
52
+ contains?: string;
53
+ startsWith?: string;
54
+ gte?: number;
55
+ gt?: number;
56
+ lte?: number;
57
+ lt?: number;
58
+ /** true: the field has a value; false: it is empty. */
59
+ exists?: boolean;
60
+ };
61
+ export type AggregateMetric<N extends string, F extends string> = "count" | `uniq:${F}` | `${"sum" | "avg" | "min" | "max" | "p50" | "p90" | "p95" | "p99"}:${N}`;
62
+ type AggregateShape<D extends string, F extends string, N extends string> = {
63
+ dataset: D;
64
+ /** Inclusive start and exclusive end; at most 92 days apart (usage datasets: any range). */
65
+ from: string | Date;
66
+ to: string | Date;
67
+ where?: Partial<Record<F | N, AggregateFilter>>;
68
+ /** At most four. Numeric fields other than httpStatus are aggregated, not grouped. */
69
+ groupBy?: (F | AggregateTimeGroup)[];
70
+ /** One to eight; default ["count"]. */
71
+ metrics?: AggregateMetric<N, F>[];
72
+ orderBy?: {
73
+ by: string;
74
+ descending?: boolean;
75
+ };
76
+ /** Default 100, at most 1,000 rows (usage datasets: 10,000). */
77
+ limit?: number;
78
+ };
79
+ /** A structured aggregate over telemetry facts or permanent usage. The key's workspace/user scope always applies; logs and usage need an unscoped key. */
80
+ export type AggregateQuery = AggregateShape<"attempts", AttemptField, AttemptNumber> | AggregateShape<"requests", RequestField, RequestNumber> | AggregateShape<"logs", LogField, never> | AggregateShape<"usage", UsageField, UsageNumber> | AggregateShape<"usageAttempts", UsageAttemptField, UsageAttemptNumber>;
81
+ export type AggregateResult = {
82
+ /** Groupings first, then metrics, in request order. */
83
+ columns: string[];
84
+ rows: Record<string, Json>[];
85
+ /** More groups matched than `limit`. */
86
+ truncated: boolean;
87
+ };
31
88
  export type TelemetryReadOptions = TelemetryQuery & {
32
89
  signal?: AbortSignal;
33
90
  };
@@ -191,6 +248,8 @@ export declare class Constellation extends RequestClient {
191
248
  after?: number;
192
249
  }, signal?: AbortSignal) => Promise<Json>;
193
250
  };
251
+ /** Count, group and aggregate attempts, requests or logs, e.g. failed attempts per offering and error code. */
252
+ query: (query: AggregateQuery, signal?: AbortSignal) => Promise<AggregateResult>;
194
253
  logs: {
195
254
  searchSystem: (query?: LogSearchQuery & TelemetryQuery, signal?: AbortSignal) => Promise<Page<Json>>;
196
255
  searchRequests: (query?: Omit<LogSearchQuery, "level" | "levelNot" | "target" | "targetNot"> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionhummer/constellation",
3
- "version": "0.9.1",
3
+ "version": "0.9.4",
4
4
  "description": "TypeScript client for durable model requests and model events",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",