@arnilo/prism 0.1.6 → 0.2.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-08-13
4
+
5
+ ### Changed
6
+ - **Release 0.2.0 (plan 020)** is the first cut of the 0.2.x review-remediation line — fail-closed runtime and sandbox security, closing the three blockers from the 2026-08-12 comprehensive review. API surface **additive-only** vs 0.1.7 (plain compat gate at 0.2.0: 0 breaking deltas; no `--allow-break`), three documented security-motivated behavior tightenings in `docs/migration.md` `0.1.7 → 0.2.0`: (1) **durable-resume input validation** (`resume-validation`, core) — `assertValidAgentRunResume` runs once at the top of `prepareAgentRunResume` before any state claim/checkpoint write/tool execution, covering all four public resume entrypoints; unknown legacy decisions (`"sideways"`), malformed batches, oversized reasons/elicitation, and duplicate approval ids fail closed with stable `ERR_PRISM_DECISION_*` codes, version untouched, zero tool calls; the server parser stays defense in depth. (2) **work-tool environment isolation** (`work-tools-env`, `@arnilo/prism-work-tools`) — `createCliRunner` children no longer inherit ambient host env: fixed base allow-list (PATH/LANG/LC_ALL/TZ + Windows system keys), explicit validated `env`, forced HOME/telemetry controls, late-bound per-identity tokens, 64-name/64-KiB caps (`ERR_PRISM_WORK_ENV`), absolute `binary`/`configDir` required, linear output capture (single final `Buffer.concat`). (3) **explicit sandbox capabilities** (`sandbox-capabilities`, `@arnilo/prism-coding-security`) — `SandboxAdapter.capabilities` (`workspaceCoherent`/`filesystemIsolated`/`networkIsolated`/`processIsolated`/`privilegeIsolated`/`egressRestricted`); omission/malformed metadata resolves all isolation `false`; `SandboxCodingComposition.capabilities` resolved from real wiring + validated adapter metadata; `containmentClaim` retained as `@deprecated` conservative projection (`workspaceCoherent && filesystemIsolated && networkIsolated && processIsolated`); Docker reports only verified controls, native reports filesystem/process/privilege `false`; authorization reads individual capabilities (docs/coding-security.md capability table + docs/host-security.md). New regression surface: `scripts/phase20-security.test.mjs` (public built entrypoints, all three blockers + gate accounting, wired into `security:threat-suites`), packed plain-JS consumer regressions in install-smoke, and the sandbox-browser workflow now records Docker/native capability evidence with a fail-loud 0.2.0 blocker gate (never a passing skip). Release graph stays **50** publishable manifests (root + 49 workspace packages) at exact **0.2.0**; zero new runtime dependency names (dependency fingerprint unchanged). Store compatibility with 0.1.7: **compatible, no migration** (no persisted-shape change). Exit gate green (core + script gates incl. phase20-freeze done-phase, `sdk:ready`, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain reviewed compat gate at 0.2.0, Docker daemon + native netns protected evidence, evidence in `scripts/phase20-baseline.json`). **Publication remains the operator handoff** (`docs/release-and-install.md` `0.2.0 publish handoff` — signed `v0.2.0` tag + npm OIDC).
7
+
8
+ ## [0.1.7] - 2026-08-12
9
+
10
+ ### Changed
11
+ - **Release 0.1.7 (plan 019)** is the performance-and-DX patch on the frozen 0.1.x line — additive-only vs 0.1.6 (freeze manifest `scripts/phase19-freeze-manifest.json`; every task's diff stayed inside its allowed files, enforced by the phase19 freeze machine; the async `AgUiProjection` item is a verification closeout, not new code). (1) **Prompt-cache telemetry surface** (`cache-telemetry`): dependency-free `createCacheTelemetry()` aggregator in core — host-activated (nothing subscribes by import), consumes `Usage` + `ModelConfig` pairs from the usage `ProviderEvent` or run-ledger records, and reports per-provider/model request counts, aggregate hit rate via the existing `cacheHitRate` math, cache-read/write token totals, and estimated savings via `cacheSavings` when cost metadata exists; bounded cardinality (default cap 256 distinct provider/model keys, overflow collapses into the `CACHE_TELEMETRY_OVERFLOW_KEY` `__overflow__` bucket with an `overflowed` flag, ponytail ceiling named — host-configurable caps or LRU eviction only if a real deployment exceeds it); reports carry token counters/rates only — never prompt content, cache keys, or identity; `record()` is O(1) with validated finite non-negative inputs; no OTel metric emission (demand-gated follow-up). (2) **Model-router selection policies** (`router-selection`): additive `selection` hook on `CreateModelRouterOptions` — `ModelRouterSelectionPolicy` (`name`/`rank`/`observe`); default ordered behavior byte-identical (regression test); reference `createCostLatencySelection` ranks candidates by `ModelCost` (input/output/cacheRead with per-million normalization) then in-memory EMA latency fed from `recordOutcome`'s new optional `latencyMs` (`latencyWeight` 0–1, default 0.5; pure-cost order on cold start); the policy is a permutation-only reorder of already-allowed candidates so it cannot widen allow-list/residency/budget decisions, and any drop/add/duplicate misbehavior fails closed with `ERR_PRISM_MODEL_ROUTER_POLICY`; policy name rides the still-redacted diagnostics; durable latency stats are a demand-gated follow-up requiring a `ModelRouterStateStore` contract change. (3) **Async AgUiProjection closeout** (`async-hooks`): plan 009 Task 15 surface verified with evidence — hooks are typed `Awaitable<T>` (17 hooks), `getMessages` accepts `() => readonly AgUiMessage[] | Promise<...>` so `messagesFromSession` can call async host APIs like `session.entries()`, snapshots are awaited strictly in event order (never `Promise.all`), rejection fails closed per event with sibling hooks still projected, caps apply to awaited values; evidence in `scripts/phase19-baseline.json` `asyncHooks` (`verified: true`, `gapFound: false`). (4) **`prism providers add <name>` scaffold** (`provider-scaffold`): new CLI subcommand (stdlib-only, mirrors `prism init`) scaffolds an OpenAI-compatible provider package into `./<name>` — `package.json` (peer dep `@arnilo/prism`, `sideEffects: false`, publish metadata), `tsconfig.json`, `README.md`, `CHANGELOG.md`, `src/index.ts` (`defineProviderPackage` + `api_key` auth-method registration), `src/provider.ts` (built on `createOpenAICompatibleProvider`), `src/models.ts` (starter `ModelConfig` list), `src/cache.ts` (cache-hint mapping via the shared core helpers), `src/__tests__/provider.test.ts` (offline conformance: stream shape + usage, header ownership, tool-call delta reconstruction, serialized-content coverage, secret-leak redaction), and `docs/providers/<name>.md` stub; flags `--base-url` (http(s) validated), `--env-key` (shell-safe identifier), `--model`, `--force`; npm package-name validation, path-traversal and symlink-escape refusal (nothing can land outside the destination), usage errors exit 2 with nothing written, generated code contains placeholders only — never secrets; scaffold output is host-chosen and never auto-registered into repo workspaces or resolvers; a fixture test proves the generated package typechecks and passes its conformance test offline against the repo build. Release graph stays **50** publishable manifests (root + 49 workspace packages — 14 provider adapters, 9 `prism-*` family/profile, 26 capability incl. `@arnilo/prism-document-reader`) at exact **0.1.7**. Exit gate green (core tests + script gates incl. phase19-freeze done-phase, `sdk:ready`, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain compat gate at 0.1.7 with 0 breaking deltas then version-literal baseline refresh — no `--allow-break` anywhere, evidence in `scripts/phase19-baseline.json`). Store compatibility with 0.1.6: **compatible, no migration** (additive-only; no persisted-shape change; `docs/migration.md` gains no entries). **Publication remains the operator handoff** (`docs/release-and-install.md` `0.1.7 publish handoff` — signed `v0.1.7` tag + npm OIDC). **CI hardening after the exit gate** (same day): the release `verify` job now runs the sdk:ready legs phase-by-phase (explicit rc per leg so a silent failure still names the failing phase) and uploads `sdk-ready.log` as an artifact on failure; the examples demo test compiles the demos in place with the repo tsc before spawning them, so the spawned children are plain JS instead of loading the amaro type-stripping WASM module (whose large per-process virtual reservation fails with `WebAssembly.Instance(): Out of memory` on memory-constrained CI runners); emitted .js files are removed in a finally block.
12
+
3
13
  ## [0.1.6] - 2026-08-11
4
14
 
5
15
  ### Changed
@@ -1,8 +1,17 @@
1
1
  import type { StoredAgentRunState } from "./agent-run-state.js";
2
- import { Agent, AgentRunState, AgentRunStateOptions, DecisionScope, NestedRunOutcome, PendingDecision, RunDecision, StickyDecision, ToolResult } from "./contracts.js";
2
+ import { Agent, AgentRunResume, AgentRunState, AgentRunStateOptions, DecisionScope, NestedRunOutcome, PendingDecision, RunDecision, StickyDecision, ToolResult } from "./contracts.js";
3
3
  import type { AgentIdentity } from "./identity.js";
4
4
  /** Pending decisions of a suspended state, synthesizing the legacy single-approval shape. */
5
5
  export declare function pendingDecisionsOf(state: StoredAgentRunState): readonly PendingDecision[] | undefined;
6
+ /**
7
+ * Transport-neutral shape validation for the complete `AgentRunResume` input (plan 020 Task 2).
8
+ * Runs before any checkpoint read/write, agent resolution, subscription, or tool execution so
9
+ * untyped callers (plain JavaScript, `as any`) cannot make resume fall through to approval or
10
+ * crash with raw TypeErrors. State-dependent checks (foreign/stale/duplicate ids, scope,
11
+ * schema, policy) stay in {@link resolveRunDecisions}. Errors never include tool arguments,
12
+ * elicitation payloads, credentials, or foreign approval details.
13
+ */
14
+ export declare function assertValidAgentRunResume(resume: AgentRunResume): void;
6
15
  interface ResolvedRunDecisions {
7
16
  readonly decisionsById: ReadonlyMap<string, RunDecision>;
8
17
  readonly stickyDecisions: readonly StickyDecision[];
@@ -21,6 +21,87 @@ export function pendingDecisionsOf(state) {
21
21
  }
22
22
  return undefined;
23
23
  }
24
+ /**
25
+ * Transport-neutral shape validation for the complete `AgentRunResume` input (plan 020 Task 2).
26
+ * Runs before any checkpoint read/write, agent resolution, subscription, or tool execution so
27
+ * untyped callers (plain JavaScript, `as any`) cannot make resume fall through to approval or
28
+ * crash with raw TypeErrors. State-dependent checks (foreign/stale/duplicate ids, scope,
29
+ * schema, policy) stay in {@link resolveRunDecisions}. Errors never include tool arguments,
30
+ * elicitation payloads, credentials, or foreign approval details.
31
+ */
32
+ export function assertValidAgentRunResume(resume) {
33
+ const invalid = (message) => new AgentDecisionError("ERR_PRISM_DECISION_INVALID", message);
34
+ if (resume === null || typeof resume !== "object" || Array.isArray(resume)) {
35
+ throw invalid("Resume must be a non-null object");
36
+ }
37
+ if (!Number.isSafeInteger(resume.expectedVersion) || resume.expectedVersion <= 0) {
38
+ throw invalid("Resume expectedVersion must be a positive safe integer");
39
+ }
40
+ const hasDecision = resume.decision !== undefined;
41
+ const hasDecisions = resume.decisions !== undefined;
42
+ if (hasDecision && hasDecisions)
43
+ throw invalid("Resume accepts exactly one of decision or decisions");
44
+ if (!hasDecision && !hasDecisions)
45
+ throw invalid("Resume requires a decision or decisions");
46
+ if (hasDecision) {
47
+ if (resume.decision !== "approve" && resume.decision !== "deny") {
48
+ throw invalid("Unknown legacy decision");
49
+ }
50
+ return;
51
+ }
52
+ const decisions = resume.decisions;
53
+ if (!Array.isArray(decisions))
54
+ throw invalid("Decision batch must be an array");
55
+ if (decisions.length === 0)
56
+ throw invalid("Decision batch must not be empty");
57
+ if (decisions.length > HARD_MAX_PENDING_DECISIONS) {
58
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision batch exceeds ${HARD_MAX_PENDING_DECISIONS} entries`);
59
+ }
60
+ const seen = new Set();
61
+ for (const decision of decisions) {
62
+ if (decision === null || typeof decision !== "object" || Array.isArray(decision)) {
63
+ throw invalid("Decision batch entries must be objects");
64
+ }
65
+ if (typeof decision.approvalId !== "string" || decision.approvalId.length === 0 || decision.approvalId.length > 128) {
66
+ throw invalid("Decision approvalId must be a bounded non-empty string");
67
+ }
68
+ if (seen.has(decision.approvalId)) {
69
+ throw new AgentDecisionError("ERR_PRISM_DECISION_DUPLICATE", "Duplicate approval decision in batch");
70
+ }
71
+ seen.add(decision.approvalId);
72
+ if (decision.outcome !== "allow_once" &&
73
+ decision.outcome !== "allow_for_run" &&
74
+ decision.outcome !== "reject_once" &&
75
+ decision.outcome !== "reject_for_run") {
76
+ throw invalid("Unknown approval outcome");
77
+ }
78
+ if (decision.reason !== undefined) {
79
+ if (typeof decision.reason !== "string")
80
+ throw invalid("Decision reason must be a string");
81
+ if (Buffer.byteLength(decision.reason, "utf8") > MAX_DECISION_REASON_BYTES) {
82
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision reason exceeds ${MAX_DECISION_REASON_BYTES} bytes`);
83
+ }
84
+ }
85
+ for (const key of ["modifiedArguments", "elicitation"]) {
86
+ const payload = decision[key];
87
+ if (payload === undefined)
88
+ continue;
89
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
90
+ throw invalid(`Decision ${key} must be a JSON object`);
91
+ }
92
+ let text;
93
+ try {
94
+ text = JSON.stringify(payload);
95
+ }
96
+ catch {
97
+ throw invalid(`Decision ${key} must be a JSON object`);
98
+ }
99
+ if (text === undefined || Buffer.byteLength(text, "utf8") > MAX_ELICITATION_BYTES) {
100
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision ${key} exceeds ${MAX_ELICITATION_BYTES} bytes`);
101
+ }
102
+ }
103
+ }
104
+ }
24
105
  /**
25
106
  * Validate one decision batch against the suspended state. Fail-closed and atomic: any
26
107
  * invalid entry rejects the whole batch before any CAS, leaving state and version untouched.
@@ -1,6 +1,6 @@
1
1
  import { agentFingerprint, loadAgentRunState, publicState, saveAgentRunState } from "./agent-run-state.js";
2
- import { AgentDecisionError, AgentRunStateError } from "./contracts.js";
3
- import { pendingDecisionsOf, resolveRunDecisions } from "./agent-approval.js";
2
+ import { AgentRunStateError } from "./contracts.js";
3
+ import { assertValidAgentRunResume, pendingDecisionsOf, resolveRunDecisions } from "./agent-approval.js";
4
4
  import { RuntimeAgentSession, throwIfAbortedSignal } from "./agent-session.js";
5
5
  function assertAgentId(actual, expected) {
6
6
  if (expected !== undefined && actual !== expected)
@@ -82,6 +82,10 @@ export async function* resumeAgentRunStream(agent, ref, resume, options) {
82
82
  }
83
83
  async function prepareAgentRunResume(agent, ref, resume, options, signal) {
84
84
  throwIfAbortedSignal(signal);
85
+ // Plan 020 Task 2: one shared shape assertion before any checkpoint read/write, agent
86
+ // resolution, subscription, or tool execution. Unknown legacy decisions (e.g. "sideways")
87
+ // and malformed untyped batches fail closed here instead of falling through to approval.
88
+ assertValidAgentRunResume(resume);
85
89
  const { record, state } = await loadAgentRunState(options.checkpoints, ref, options.ownership);
86
90
  if (state.definitionRevision !== options.definitionRevision ||
87
91
  state.agentId !== (agent.config.id ?? agent.config.name) ||
@@ -102,9 +106,6 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
102
106
  if (options.persistSessionState && options.includeSkillBodies && state.sessionState?.loadedSkillBodies) {
103
107
  session.restoreLoadedSkillBodies(state.sessionState.loadedSkillBodies);
104
108
  }
105
- if (resume.decision !== undefined && resume.decisions !== undefined) {
106
- throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Resume accepts exactly one of decision or decisions");
107
- }
108
109
  const pendingDecisions = pendingDecisionsOf(state);
109
110
  // Legacy approve maps to allow-once on every pending decision; legacy deny keeps its
110
111
  // terminal-denied behavior. Batch decisions are validated and applied atomically below.
@@ -118,9 +119,6 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
118
119
  signal,
119
120
  })
120
121
  : undefined;
121
- if (resume.decision === undefined && resume.decisions === undefined) {
122
- throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Resume requires a decision or decisions");
123
- }
124
122
  if (resolved && resolved.remaining.length > 0) {
125
123
  throwIfAbortedSignal(signal);
126
124
  const single = resolved.remaining.length === 1 ? resolved.remaining[0] : undefined;
@@ -0,0 +1,58 @@
1
+ import type { ModelConfig, Usage } from "./contracts-core.js";
2
+ /**
3
+ * Prompt-cache telemetry surface (0.1.7, plan 019 Task 2).
4
+ *
5
+ * Dependency-free aggregator hosts attach to their `usage` `ProviderEvent`
6
+ * stream (or run-ledger usage records) to get per-provider/model cache
7
+ * statistics for tuning the `cache_aware` input layout. Explicit activation:
8
+ * nothing subscribes by import — the host calls `record()`.
9
+ */
10
+ /** Single provider/model statistics sample. */
11
+ export interface CacheTelemetrySample {
12
+ readonly provider: string;
13
+ readonly model: string;
14
+ readonly requests: number;
15
+ readonly cacheReadTokens: number;
16
+ readonly cacheWriteTokens: number;
17
+ readonly inputTokens: number;
18
+ /** Cached-input ratio across the sample (`cacheHitRate` math, aggregated). */
19
+ readonly hitRate?: number;
20
+ /** Estimated read-token savings via `cacheSavings` math; present only when
21
+ * the sample's model carries cost metadata (`ModelCost.input`/`cacheRead`). */
22
+ readonly estimatedSavings?: number;
23
+ readonly currency?: string;
24
+ }
25
+ /** Aggregated report. Samples are sorted by provider then model. */
26
+ export interface CacheTelemetryReport {
27
+ readonly samples: readonly CacheTelemetrySample[];
28
+ readonly overflowed: boolean;
29
+ readonly totalRequests: number;
30
+ readonly totalCacheReadTokens: number;
31
+ readonly totalCacheWriteTokens: number;
32
+ }
33
+ export interface CacheTelemetryOptions {
34
+ /** Distinct provider/model keys before excess keys collapse into the
35
+ * `__overflow__` bucket. Default {@link DEFAULT_CACHE_TELEMETRY_CAP}. */
36
+ readonly maxKeys?: number;
37
+ }
38
+ export interface CacheTelemetry {
39
+ /** Aggregate one usage record attributed to `model` (or an unknown bucket
40
+ * when no model is supplied). Rejects non-finite/negative token counts
41
+ * with {@link CacheTelemetryError}; validates before mutating. */
42
+ record(usage: Usage, model?: ModelConfig): void;
43
+ /** Snapshot of all samples (O(keys)); never throws. */
44
+ report(): CacheTelemetryReport;
45
+ /** Clear all samples (host rotation / long-run reset). */
46
+ reset(): void;
47
+ /** Number of distinct provider/model keys held (excluding the overflow bucket). */
48
+ readonly size: number;
49
+ }
50
+ /** Cardinality ceiling: keys beyond this collapse into `__overflow__`. */
51
+ export declare const DEFAULT_CACHE_TELEMETRY_CAP = 256;
52
+ /** Sample bucket key for provider/model keys beyond the cap. */
53
+ export declare const CACHE_TELEMETRY_OVERFLOW_KEY = "__overflow__";
54
+ export declare class CacheTelemetryError extends Error {
55
+ readonly code = "ERR_PRISM_CACHE_TELEMETRY";
56
+ constructor(message: string);
57
+ }
58
+ export declare function createCacheTelemetry(options?: CacheTelemetryOptions): CacheTelemetry;
@@ -0,0 +1,102 @@
1
+ import { cacheHitRate, cacheSavings } from "./cache-helpers.js";
2
+ /** Cardinality ceiling: keys beyond this collapse into `__overflow__`. */
3
+ export const DEFAULT_CACHE_TELEMETRY_CAP = 256;
4
+ /** Sample bucket key for provider/model keys beyond the cap. */
5
+ export const CACHE_TELEMETRY_OVERFLOW_KEY = "__overflow__";
6
+ export class CacheTelemetryError extends Error {
7
+ code = "ERR_PRISM_CACHE_TELEMETRY";
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "CacheTelemetryError";
11
+ }
12
+ }
13
+ function validateTokens(name, value) {
14
+ if (value === undefined)
15
+ return;
16
+ if (!Number.isSafeInteger(value) || value < 0) {
17
+ throw new CacheTelemetryError(`${name} must be a non-negative safe integer, got ${value}`);
18
+ }
19
+ }
20
+ function sampleFor(usage, model) {
21
+ if (model)
22
+ return { provider: model.provider, model: model.model };
23
+ // Provider-only aggregation: no model supplied, attribute to the unknown bucket.
24
+ return { provider: "unknown", model: "unknown" };
25
+ }
26
+ export function createCacheTelemetry(options = {}) {
27
+ const maxKeys = options.maxKeys ?? DEFAULT_CACHE_TELEMETRY_CAP;
28
+ if (!Number.isSafeInteger(maxKeys) || maxKeys < 1) {
29
+ throw new CacheTelemetryError(`maxKeys must be a positive safe integer, got ${options.maxKeys}`);
30
+ }
31
+ // ponytail: fixed per-provider/model key cap with a single __overflow__ bucket;
32
+ // if real deployments exceed it, upgrade to host-configurable caps or LRU eviction.
33
+ const samples = new Map();
34
+ let overflow = false;
35
+ let overflowSample;
36
+ function bucket(provider, model) {
37
+ const key = `${provider}\u0000${model}`;
38
+ let sample = samples.get(key);
39
+ if (sample)
40
+ return sample;
41
+ if (samples.size >= maxKeys) {
42
+ overflow = true;
43
+ if (!overflowSample) {
44
+ overflowSample = {
45
+ provider: CACHE_TELEMETRY_OVERFLOW_KEY,
46
+ model: CACHE_TELEMETRY_OVERFLOW_KEY,
47
+ requests: 0,
48
+ cacheReadTokens: 0,
49
+ cacheWriteTokens: 0,
50
+ inputTokens: 0,
51
+ };
52
+ }
53
+ return overflowSample;
54
+ }
55
+ sample = { provider, model, requests: 0, cacheReadTokens: 0, cacheWriteTokens: 0, inputTokens: 0 };
56
+ samples.set(key, sample);
57
+ return sample;
58
+ }
59
+ return {
60
+ record(usage, model) {
61
+ // Validate everything before mutating: a bad record mutates nothing.
62
+ validateTokens("usage.cacheReadTokens", usage.cacheReadTokens);
63
+ validateTokens("usage.cacheWriteTokens", usage.cacheWriteTokens);
64
+ validateTokens("usage.inputTokens", usage.inputTokens);
65
+ const { provider, model: modelName } = sampleFor(usage, model);
66
+ const sample = bucket(provider, modelName);
67
+ sample.requests += 1;
68
+ sample.cacheReadTokens += usage.cacheReadTokens ?? 0;
69
+ sample.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
70
+ sample.inputTokens += usage.inputTokens ?? 0;
71
+ sample.hitRate = cacheHitRate({
72
+ cacheReadTokens: sample.cacheReadTokens,
73
+ inputTokens: sample.inputTokens,
74
+ });
75
+ if (model?.cost) {
76
+ // cacheSavings depends only on read tokens + cost metadata, so the
77
+ // aggregate equals the sum of per-call savings; feed it the totals
78
+ // to reuse the exact cache-helpers math rather than reimplementing it.
79
+ const savings = cacheSavings({ cacheReadTokens: sample.cacheReadTokens }, model);
80
+ sample.estimatedSavings = savings;
81
+ sample.currency = model.cost.currency;
82
+ }
83
+ },
84
+ report() {
85
+ const samplesAll = [...samples.values()].sort((a, b) => a.provider === b.provider ? a.model.localeCompare(b.model) : a.provider.localeCompare(b.provider));
86
+ const listed = overflowSample ? [...samplesAll, overflowSample] : samplesAll;
87
+ const totalRequests = listed.reduce((sum, s) => sum + s.requests, 0);
88
+ const totalCacheReadTokens = listed.reduce((sum, s) => sum + s.cacheReadTokens, 0);
89
+ const totalCacheWriteTokens = listed.reduce((sum, s) => sum + s.cacheWriteTokens, 0);
90
+ return { samples: listed, overflowed: overflow, totalRequests, totalCacheReadTokens, totalCacheWriteTokens };
91
+ },
92
+ reset() {
93
+ samples.clear();
94
+ overflow = false;
95
+ overflowSample = undefined;
96
+ },
97
+ get size() {
98
+ return samples.size;
99
+ },
100
+ };
101
+ }
102
+ //# sourceMappingURL=cache-telemetry.js.map
@@ -0,0 +1,37 @@
1
+ import type { Writable } from "node:stream";
2
+ export declare class ProviderAddUsageError extends Error {
3
+ }
4
+ export interface ProviderAddOptions {
5
+ /** npm-validated provider/package name; also the target directory name. */
6
+ readonly name: string;
7
+ readonly baseUrl: string;
8
+ /** Shell-safe identifier, e.g. `ACME_API_KEY`. Never a secret value. */
9
+ readonly envKey: string;
10
+ readonly model: string;
11
+ readonly force: boolean;
12
+ readonly help: boolean;
13
+ }
14
+ export interface ProviderAddRuntime {
15
+ readonly stdout: Writable;
16
+ readonly stderr: Writable;
17
+ /** Override template root (tests). Defaults to package `templates/provider`. */
18
+ readonly templatesRoot?: string;
19
+ /** Override package version stamped into the generated package.json. */
20
+ readonly packageVersion?: string;
21
+ /** Working directory used to resolve relative destinations. Defaults to process.cwd(). */
22
+ readonly cwd?: string;
23
+ }
24
+ export interface ProviderAddResult {
25
+ readonly targetDir: string;
26
+ readonly writtenFiles: readonly string[];
27
+ readonly name: string;
28
+ readonly totalBytes: number;
29
+ }
30
+ export declare function getProviderAddUsage(): string;
31
+ export declare const providerAddUsage: string;
32
+ export declare function parseProviderAddArgs(argv: readonly string[]): ProviderAddOptions;
33
+ export declare function runProviderAddCommand(argv: readonly string[], runtime: ProviderAddRuntime): Promise<number>;
34
+ export declare function createProviderProject(options: ProviderAddOptions, runtime?: ProviderAddRuntime): Promise<ProviderAddResult>;
35
+ export declare function defaultProviderTemplatesRoot(): string;
36
+ /** npm package-name rules plus traversal refusal. Throws `ProviderAddUsageError` on violation. */
37
+ export declare function validateProviderName(name: string): void;
@@ -0,0 +1,293 @@
1
+ import { accessSync, constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export class ProviderAddUsageError extends Error {
6
+ }
7
+ const NPM_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
8
+ const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ const MAX_NAME_LENGTH = 214;
10
+ const DEFAULT_BASE_URL = "https://api.example.com/v1";
11
+ export function getProviderAddUsage() {
12
+ return `Usage: prism providers add <name> [options]
13
+
14
+ Scaffold an OpenAI-compatible provider package (manifest, provider, models, cache
15
+ helpers, conformance test, docs stub) into ./<name>.
16
+
17
+ Arguments:
18
+ <name> npm-validated package/provider name (lowercase)
19
+
20
+ Options:
21
+ --base-url <url> Default Chat Completions base URL (default: ${DEFAULT_BASE_URL})
22
+ --env-key <name> Credential environment-var identifier (default: <NAME>_API_KEY)
23
+ --model <id> Starter model id (default: <name>-large)
24
+ --force Overwrite existing generated files
25
+ -h, --help Show this help
26
+
27
+ Examples:
28
+ prism providers add acme --base-url https://api.acme.example/v1 --env-key ACME_API_KEY --model acme-large
29
+ `;
30
+ }
31
+ export const providerAddUsage = getProviderAddUsage();
32
+ export function parseProviderAddArgs(argv) {
33
+ let name;
34
+ let baseUrl = DEFAULT_BASE_URL;
35
+ let envKey;
36
+ let model;
37
+ let force = false;
38
+ let help = false;
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const arg = argv[i];
41
+ if (arg === "-h" || arg === "--help") {
42
+ help = true;
43
+ continue;
44
+ }
45
+ if (arg === "--force") {
46
+ force = true;
47
+ continue;
48
+ }
49
+ if (arg === "--base-url" || arg === "--env-key" || arg === "--model") {
50
+ const value = argv[i + 1];
51
+ if (value === undefined || value.startsWith("-")) {
52
+ throw new ProviderAddUsageError(`Missing value for ${arg}`);
53
+ }
54
+ i += 1;
55
+ if (arg === "--base-url") {
56
+ baseUrl = value;
57
+ }
58
+ else if (arg === "--env-key") {
59
+ envKey = value;
60
+ }
61
+ else {
62
+ model = value;
63
+ }
64
+ continue;
65
+ }
66
+ if (arg.startsWith("-")) {
67
+ throw new ProviderAddUsageError(`Unknown flag: ${arg}`);
68
+ }
69
+ if (name !== undefined) {
70
+ throw new ProviderAddUsageError(`Unexpected argument: ${arg}`);
71
+ }
72
+ name = arg;
73
+ }
74
+ if (!help && name === undefined) {
75
+ throw new ProviderAddUsageError("Missing provider name");
76
+ }
77
+ return {
78
+ name: name ?? "provider",
79
+ baseUrl,
80
+ envKey: envKey ?? `${(name ?? "provider").replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_API_KEY`,
81
+ model: model ?? `${name ?? "provider"}-large`,
82
+ force,
83
+ help,
84
+ };
85
+ }
86
+ export async function runProviderAddCommand(argv, runtime) {
87
+ let options;
88
+ try {
89
+ options = parseProviderAddArgs(argv);
90
+ }
91
+ catch (error) {
92
+ write(runtime.stderr, `${error instanceof Error ? error.message : String(error)}\n${getProviderAddUsage()}`);
93
+ return 2;
94
+ }
95
+ if (options.help) {
96
+ write(runtime.stdout, getProviderAddUsage());
97
+ return 0;
98
+ }
99
+ try {
100
+ validateProviderName(options.name);
101
+ validateBaseUrl(options.baseUrl);
102
+ if (!ENV_KEY_PATTERN.test(options.envKey)) {
103
+ throw new ProviderAddUsageError(`Invalid --env-key: ${options.envKey} (must be a shell-safe identifier)`);
104
+ }
105
+ const result = await createProviderProject(options, runtime);
106
+ write(runtime.stdout, [
107
+ `Scaffolded provider package in ${result.targetDir}`,
108
+ ` name: ${result.name}`,
109
+ ` files: ${result.writtenFiles.length}`,
110
+ ` bytes: ${result.totalBytes}`,
111
+ "",
112
+ "Next:",
113
+ ` cd ${result.name}`,
114
+ " npm install",
115
+ " npm test",
116
+ " Replace the starter model metadata and docs stub with docs-verified values before publishing.",
117
+ "",
118
+ ].join("\n"));
119
+ return 0;
120
+ }
121
+ catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ if (error instanceof ProviderAddUsageError) {
124
+ write(runtime.stderr, `${message}\n${getProviderAddUsage()}`);
125
+ return 2;
126
+ }
127
+ write(runtime.stderr, `${message}\n`);
128
+ return 1;
129
+ }
130
+ }
131
+ export async function createProviderProject(options, runtime = {
132
+ stdout: process.stdout,
133
+ stderr: process.stderr,
134
+ }) {
135
+ validateProviderName(options.name);
136
+ const cwd = runtime.cwd ?? process.cwd();
137
+ const targetDir = resolve(cwd, options.name);
138
+ const templatesRoot = runtime.templatesRoot ?? defaultProviderTemplatesRoot();
139
+ const version = runtime.packageVersion ?? (await readPackageVersion());
140
+ await assertDestinationWritable(targetDir, options.force);
141
+ const tokens = buildTokens({ ...options, version });
142
+ const planned = planProviderFiles(templatesRoot, options.name);
143
+ if (!options.force) {
144
+ for (const file of planned) {
145
+ const dest = join(targetDir, file.relativePath);
146
+ if (await exists(dest)) {
147
+ throw new ProviderAddUsageError(`Refusing to overwrite existing file: ${file.relativePath} (pass --force to overwrite)`);
148
+ }
149
+ }
150
+ }
151
+ const writtenFiles = [];
152
+ let totalBytes = 0;
153
+ for (const file of planned) {
154
+ const dest = join(targetDir, file.relativePath);
155
+ assertPathInside(targetDir, dest);
156
+ const raw = await readFile(file.sourcePath, "utf8");
157
+ const content = applyTokens(raw, tokens);
158
+ await mkdir(dirname(dest), { recursive: true });
159
+ await assertNoSymlinkEscape(targetDir, dirname(dest));
160
+ await writeFile(dest, content, "utf8");
161
+ writtenFiles.push(file.relativePath);
162
+ totalBytes += Buffer.byteLength(content, "utf8");
163
+ }
164
+ return {
165
+ targetDir,
166
+ writtenFiles,
167
+ name: options.name,
168
+ totalBytes,
169
+ };
170
+ }
171
+ export function defaultProviderTemplatesRoot() {
172
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "provider");
173
+ }
174
+ /** npm package-name rules plus traversal refusal. Throws `ProviderAddUsageError` on violation. */
175
+ export function validateProviderName(name) {
176
+ if (name.length === 0)
177
+ throw new ProviderAddUsageError("Missing provider name");
178
+ if (name.includes("\0"))
179
+ throw new ProviderAddUsageError("Invalid provider name");
180
+ if (name.length > MAX_NAME_LENGTH) {
181
+ throw new ProviderAddUsageError(`Invalid provider name: ${name} (max ${MAX_NAME_LENGTH} chars)`);
182
+ }
183
+ if (!NPM_NAME_PATTERN.test(name) || name.includes("..")) {
184
+ throw new ProviderAddUsageError(`Invalid provider name: ${name} (npm names are lowercase, start with a letter/digit, and contain only letters, digits, -, _, .)`);
185
+ }
186
+ }
187
+ function validateBaseUrl(baseUrl) {
188
+ let parsed;
189
+ try {
190
+ parsed = new URL(baseUrl);
191
+ }
192
+ catch {
193
+ throw new ProviderAddUsageError(`Invalid --base-url: ${baseUrl} (must be an http(s) URL)`);
194
+ }
195
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
196
+ throw new ProviderAddUsageError(`Invalid --base-url: ${baseUrl} (must be an http(s) URL)`);
197
+ }
198
+ }
199
+ function buildTokens(input) {
200
+ const pascal = input.name
201
+ .split(/[^a-z0-9]+/i)
202
+ .filter(Boolean)
203
+ .map((part) => part[0].toUpperCase() + part.slice(1))
204
+ .join("");
205
+ return {
206
+ __PROVIDER_ID__: input.name,
207
+ __PROVIDER_UPPER__: input.name.replace(/[^a-z0-9]+/gi, "_").toUpperCase(),
208
+ __PROVIDER_PASCAL__: pascal,
209
+ __PACKAGE_NAME__: input.name,
210
+ __PRISM_VERSION__: input.version,
211
+ __BASE_URL__: input.baseUrl.replace(/\/+$/, ""),
212
+ __ENV_KEY__: input.envKey,
213
+ __MODEL_ID__: input.model,
214
+ };
215
+ }
216
+ function planProviderFiles(templatesRoot, name) {
217
+ const files = [
218
+ { relativePath: "package.json", sourcePath: join(templatesRoot, "package.json.tmpl") },
219
+ { relativePath: "tsconfig.json", sourcePath: join(templatesRoot, "tsconfig.json.tmpl") },
220
+ { relativePath: "README.md", sourcePath: join(templatesRoot, "README.md.tmpl") },
221
+ { relativePath: "CHANGELOG.md", sourcePath: join(templatesRoot, "CHANGELOG.md.tmpl") },
222
+ { relativePath: "src/index.ts", sourcePath: join(templatesRoot, "src/index.ts.tmpl") },
223
+ { relativePath: "src/provider.ts", sourcePath: join(templatesRoot, "src/provider.ts.tmpl") },
224
+ { relativePath: "src/models.ts", sourcePath: join(templatesRoot, "src/models.ts.tmpl") },
225
+ { relativePath: "src/cache.ts", sourcePath: join(templatesRoot, "src/cache.ts.tmpl") },
226
+ { relativePath: "src/__tests__/provider.test.ts", sourcePath: join(templatesRoot, "src/tests/provider.test.ts.tmpl") },
227
+ { relativePath: `docs/providers/${name}.md`, sourcePath: join(templatesRoot, "docs/providers/NAME.md.tmpl") },
228
+ ];
229
+ for (const file of files) {
230
+ try {
231
+ accessSync(file.sourcePath, fsConstants.R_OK);
232
+ }
233
+ catch {
234
+ throw new Error(`Missing provider template: ${file.sourcePath}`);
235
+ }
236
+ }
237
+ return files;
238
+ }
239
+ function applyTokens(template, tokens) {
240
+ let out = template;
241
+ for (const [token, value] of Object.entries(tokens)) {
242
+ out = out.split(token).join(value);
243
+ }
244
+ if (/__[A-Z0-9_]+__/.test(out)) {
245
+ const leftover = out.match(/__[A-Z0-9_]+__/g) ?? [];
246
+ throw new Error(`Unresolved provider template tokens: ${Array.from(new Set(leftover)).join(", ")}`);
247
+ }
248
+ return out;
249
+ }
250
+ async function readPackageVersion() {
251
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
252
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
253
+ if (!pkg.version)
254
+ throw new Error(`Missing version in ${pkgPath}`);
255
+ return pkg.version;
256
+ }
257
+ async function assertDestinationWritable(targetDir, force) {
258
+ if (!(await exists(targetDir))) {
259
+ await mkdir(targetDir, { recursive: true });
260
+ return;
261
+ }
262
+ const entries = await readdir(targetDir);
263
+ if (entries.length > 0 && !force) {
264
+ throw new ProviderAddUsageError(`Destination is not empty: ${targetDir} (pass --force to overwrite generated files)`);
265
+ }
266
+ }
267
+ function assertPathInside(root, candidate) {
268
+ const rel = relative(root, candidate);
269
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
270
+ return;
271
+ throw new ProviderAddUsageError(`Refusing to write outside destination: ${candidate}`);
272
+ }
273
+ /** Refuse writes whose real parent directory escapes the real target (symlinked dirs). */
274
+ async function assertNoSymlinkEscape(targetDir, parentDir) {
275
+ const realTarget = await realpath(targetDir);
276
+ const realParent = await realpath(parentDir);
277
+ if (realParent !== realTarget && !realParent.startsWith(realTarget + sep)) {
278
+ throw new ProviderAddUsageError(`Refusing to write through a symlinked directory: ${parentDir}`);
279
+ }
280
+ }
281
+ async function exists(path) {
282
+ try {
283
+ await access(path, fsConstants.F_OK);
284
+ return true;
285
+ }
286
+ catch {
287
+ return false;
288
+ }
289
+ }
290
+ function write(stream, text) {
291
+ stream.write(text);
292
+ }
293
+ //# sourceMappingURL=cli-provider-add.js.map