@skaile/transform-proxy 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as TransformModule, a as TransformProxyOptions, c as TransformSpec, d as SessionFactPublisher, f as SessionFacts, g as TransformFactory, h as Transform, i as TransformProxy, l as Logger, m as TextLeafKind, n as SESSION_HEADER, o as UPSTREAM_HEADER, p as TextLeaf, r as TOKEN_HEADER, s as createTransformProxy, t as PIPELINE_HEADER, u as SessionFact, v as TransformSessionContext } from "./server-C_eKyCTV.mjs";
1
+ import { _ as TraceSink, a as TransformProxyOptions, b as TransformModule, c as TransformSpec, d as SessionFactPublisher, f as SessionFacts, g as TraceLeak, h as TraceEvent, i as TransformProxy, l as Logger, m as TextLeafKind, n as SESSION_HEADER, o as UPSTREAM_HEADER, p as TextLeaf, r as TOKEN_HEADER, s as createTransformProxy, t as PIPELINE_HEADER, u as SessionFact, v as Transform, x as TransformSessionContext, y as TransformFactory } from "./server-DdXMr4yF.mjs";
2
2
  /** One stage's streamed inbound transformer, as returned by `Transform.inboundStream()`. */
3
3
  interface InboundStream {
4
4
  push(chunk: string): string;
@@ -26,6 +26,11 @@ interface ProxyConfig {
26
26
  readonly pipelines: Record<string, readonly string[]>;
27
27
  readonly defaultPipeline?: string;
28
28
  readonly allowUpstreams?: readonly string[];
29
+ /** Trace file sink; `dir` is absolute (resolved against `baseDir`). */
30
+ readonly trace?: {
31
+ readonly dir: string;
32
+ readonly maxChars?: number;
33
+ };
29
34
  }
30
35
  interface ResolvedProxyConfig {
31
36
  readonly path: string;
@@ -50,6 +55,18 @@ export declare function resolveProxyConfig(lookup: ConfigLookup): ResolvedProxyC
50
55
  export declare function loadProxyConfig(path: string): ProxyConfig;
51
56
  /** Validates an already-parsed config object. Exported for hosts that load YAML themselves. */
52
57
  export declare function parseProxyConfig(source: unknown, baseDir: string): ProxyConfig;
58
+ export declare const DEFAULT_TRACE_MAX_CHARS = 262144;
59
+ /**
60
+ * One JSON line (no trailing newline). When the JSON of `content` is longer than `maxChars`,
61
+ * `content` becomes that JSON text cut to `maxChars` and `truncated` is set.
62
+ */
63
+ export declare function serializeTraceEvent(event: TraceEvent, maxChars?: number): string;
64
+ interface FileTraceSinkOptions {
65
+ readonly dir: string;
66
+ readonly maxChars?: number;
67
+ }
68
+ /** Appends each event to `<dir>/<session>.jsonl`. The files hold whatever leaked, so they are owner-only. */
69
+ export declare function createFileTraceSink(options: FileTraceSinkOptions): TraceSink;
53
70
  interface WireContext {
54
71
  /** The session's composed pipeline. */
55
72
  readonly transform: Transform;
@@ -58,6 +75,13 @@ interface WireContext {
58
75
  * Walkers namespace their keys (`anthropic:<signature>`, `responses:<itemId>`).
59
76
  */
60
77
  readonly signatures: Map<string, string>;
78
+ /**
79
+ * Called by a stream transformer once, on `flush`, with the model's complete reply as it came
80
+ * from the provider (placeholders, before restore), in the shape of the protocol's non-streamed
81
+ * response. A throw is the caller's problem: transformers must catch it so the stream is never
82
+ * disturbed. Absent = nobody records the reply; walkers skip the assembly work.
83
+ */
84
+ readonly observe?: (message: unknown) => void;
61
85
  }
62
86
  /** Thrown by `request` for attachments (image, document, file parts); the server answers 400 `unsupported_content`. */
63
87
  export declare class UnsupportedContentError extends Error {
@@ -85,4 +109,4 @@ interface WireWalker {
85
109
  export declare const anthropicWalker: WireWalker;
86
110
  export declare const openaiChatWalker: WireWalker;
87
111
  export declare const openaiResponsesWalker: WireWalker;
88
- export { type ConfigLookup, type InboundStream, type Logger, PIPELINE_HEADER, type ProxyConfig, type ResolvedProxyConfig, SESSION_HEADER, type SessionFact, type SessionFactPublisher, type SessionFactStore, type SessionFacts, type SseTransformer, TOKEN_HEADER, type TextLeaf, type TextLeafKind, type Transform, type TransformFactory, type TransformModule, type TransformProxy, type TransformProxyOptions, type TransformSessionContext, type TransformSpec, UPSTREAM_HEADER, type WireContext, type WireWalker, createTransformProxy };
112
+ export { type ConfigLookup, type FileTraceSinkOptions, type InboundStream, type Logger, PIPELINE_HEADER, type ProxyConfig, type ResolvedProxyConfig, SESSION_HEADER, type SessionFact, type SessionFactPublisher, type SessionFactStore, type SessionFacts, type SseTransformer, TOKEN_HEADER, type TextLeaf, type TextLeafKind, type TraceEvent, type TraceLeak, type TraceSink, type Transform, type TransformFactory, type TransformModule, type TransformProxy, type TransformProxyOptions, type TransformSessionContext, type TransformSpec, UPSTREAM_HEADER, type WireContext, type WireWalker, createTransformProxy };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { t as CONFIG_FILE_NAME, s as PIPELINE_HEADER, c as SESSION_HEADER, l as TOKEN_HEADER, u as UPSTREAM_HEADER, h as UnsupportedContentError, m as anthropicWalker, _ as composeTransforms, n as configCandidates, g as createSessionFacts, d as createTransformProxy, r as findProxyConfig, i as loadProxyConfig, p as openaiChatWalker, f as openaiResponsesWalker, a as parseProxyConfig, o as resolveProxyConfig, y as silentLogger } from "./config-BZsthgP-.mjs";
1
+ export { t as CONFIG_FILE_NAME, f as DEFAULT_TRACE_MAX_CHARS, s as PIPELINE_HEADER, c as SESSION_HEADER, l as TOKEN_HEADER, u as UPSTREAM_HEADER, v as UnsupportedContentError, _ as anthropicWalker, b as composeTransforms, n as configCandidates, p as createFileTraceSink, y as createSessionFacts, d as createTransformProxy, r as findProxyConfig, i as loadProxyConfig, g as openaiChatWalker, h as openaiResponsesWalker, a as parseProxyConfig, o as resolveProxyConfig, m as serializeTraceEvent, S as silentLogger } from "./config-DHxYF3Tv.mjs";
@@ -1,7 +1,7 @@
1
- import { c as SESSION_HEADER, i as loadProxyConfig, l as TOKEN_HEADER, n as configCandidates, r as findProxyConfig, u as UPSTREAM_HEADER, v as errorMessage } from "../config-BZsthgP-.mjs";
1
+ import { c as SESSION_HEADER, i as loadProxyConfig, l as TOKEN_HEADER, n as configCandidates, r as findProxyConfig, u as UPSTREAM_HEADER, x as errorMessage } from "../config-DHxYF3Tv.mjs";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { homedir } from "node:os";
7
7
  import { spawn } from "node:child_process";
@@ -16,6 +16,8 @@ const NODE_ENV = "SKAILE_TRANSFORM_PROXY_NODE";
16
16
  const MODE_ENV = "SKAILE_TRANSFORM_PROXY_MODE";
17
17
  /** Overrides the path of the saved default (`/anon on|off --global`). */
18
18
  const STATE_ENV = "SKAILE_TRANSFORM_PROXY_OMP_STATE";
19
+ /** The proxy's trace directory (relative to the cwd); the own proxy child inherits it with the rest of the environment. */
20
+ const TRACE_DIR_ENV = "SKAILE_TRANSFORM_PROXY_TRACE_DIR";
19
21
  /** The footer status key. */
20
22
  const STATUS_KEY = "anon";
21
23
  /** The model-registry source the proxy overrides are registered under; `/anon off` clears it in one reload. */
@@ -100,6 +102,8 @@ function createAnonController(deps) {
100
102
  let endpoint;
101
103
  let starting;
102
104
  let sessionId;
105
+ /** The context of the latest proxy-bound hook; leak notices of the proxy child go to its UI. */
106
+ let uiCtx;
103
107
  function stateOf(registry) {
104
108
  let state = registries.get(registry);
105
109
  if (state === void 0) {
@@ -112,6 +116,7 @@ function createAnonController(deps) {
112
116
  return state;
113
117
  }
114
118
  async function resolveEndpoint(ctx) {
119
+ uiCtx = ctx;
115
120
  if (endpoint !== void 0 && endpoint.child?.exited !== true) return endpoint;
116
121
  const url = env[URL_ENV];
117
122
  if (url !== void 0 && url !== "") {
@@ -177,12 +182,28 @@ function createAnonController(deps) {
177
182
  url: await waitForListening(child, {
178
183
  node,
179
184
  token,
180
- timeoutMs: listenTimeoutMs
185
+ timeoutMs: listenTimeoutMs,
186
+ onLeak: leakNotifier()
181
187
  }),
182
188
  token,
183
189
  pipeline: options.defaultPipeline,
184
190
  source: `child process ${child.pid}, config ${path}`,
185
- child: state
191
+ child: state,
192
+ ...options.trace === void 0 ? {} : { traceDir: options.trace.dir }
193
+ };
194
+ }
195
+ /** One child's leak warnings as notices: once per session and kind for the child's life, never with the value. */
196
+ function leakNotifier() {
197
+ const noticed = /* @__PURE__ */ new Set();
198
+ return (report) => {
199
+ const firstAt = /* @__PURE__ */ new Map();
200
+ for (const leak of report.leaks) if (!firstAt.has(leak.kind)) firstAt.set(leak.kind, leak.at);
201
+ for (const [kind, at] of firstAt) {
202
+ const key = JSON.stringify([report.sessionId, kind]);
203
+ if (noticed.has(key)) continue;
204
+ noticed.add(key);
205
+ uiCtx?.ui.notify(`anon: a known ${kind} value was sent to the provider in clear text (at ${at}, route ${report.route}). Value not shown.`, "warning");
206
+ }
186
207
  };
187
208
  }
188
209
  function currentSessionId(ctx) {
@@ -335,6 +356,7 @@ function createAnonController(deps) {
335
356
  lines.push(`pipeline: ${endpoint.pipeline ?? "the proxy default"}`);
336
357
  }
337
358
  if (sessionId !== void 0) lines.push(`session: ${sessionId}`);
359
+ lines.push(`trace: ${traceTarget(ctx.cwd)}`);
338
360
  if (imagesBlocked) lines.push("images: not sent (omp images.blockImages)");
339
361
  const routed = [...state.routed.keys()].sort();
340
362
  const refused = routed.flatMap((provider) => {
@@ -347,6 +369,12 @@ function createAnonController(deps) {
347
369
  if (preRouted.length > 0) lines.push(`routed by their own config (${preRouted.length}): ${preRouted.sort().join(", ")}`);
348
370
  return lines.join("\n");
349
371
  }
372
+ /** The proxy's trace directory, or `off`. With an external proxy only the env var is known here. */
373
+ function traceTarget(cwd) {
374
+ const fromEnv = env[TRACE_DIR_ENV];
375
+ if (fromEnv !== void 0 && fromEnv !== "") return resolve(cwd, fromEnv);
376
+ return endpoint?.traceDir ?? "off";
377
+ }
350
378
  /**
351
379
  * Session entry: rotate the proxy session for a new root, then (re-)apply routing when wanted.
352
380
  * Rotation only switches the session header: the proxy keeps the old session's state in memory
@@ -433,6 +461,7 @@ function createAnonController(deps) {
433
461
  if (enabled !== was) showStatus(ctx);
434
462
  },
435
463
  providerRequest(ctx) {
464
+ uiCtx = ctx;
436
465
  if (!wanted) return void 0;
437
466
  let reason;
438
467
  try {
@@ -470,11 +499,45 @@ function spawnNode(command, args, options) {
470
499
  }
471
500
  /** The stderr lines a startup failure quotes. */
472
501
  const STDERR_TAIL = 10;
502
+ /** An unterminated stderr line longer than this is dropped, so a child that never prints a newline cannot grow omp's memory. */
503
+ const STDERR_LINE_MAX = 65536;
504
+ /** The proxy's leak warning (`log.warn` with `{ sessionId, route, leaks }`, printed as JSON after the message). */
505
+ const LEAK_MARKER = "transform proxy: known original sent upstream";
506
+ /** The leak report on one stderr line of the proxy child; `undefined` for any other or malformed line. */
507
+ function parseLeakLine(line) {
508
+ const index = line.indexOf(LEAK_MARKER);
509
+ if (index === -1) return void 0;
510
+ let parsed;
511
+ try {
512
+ parsed = JSON.parse(line.slice(index + 45));
513
+ } catch {
514
+ return;
515
+ }
516
+ if (typeof parsed !== "object" || parsed === null) return void 0;
517
+ const { sessionId, route, leaks } = parsed;
518
+ if (typeof sessionId !== "string" || typeof route !== "string" || !Array.isArray(leaks)) return void 0;
519
+ const out = [];
520
+ for (const leak of leaks) {
521
+ if (typeof leak !== "object" || leak === null) return void 0;
522
+ const { kind, at } = leak;
523
+ if (typeof kind !== "string" || typeof at !== "string") return void 0;
524
+ out.push({
525
+ kind,
526
+ at
527
+ });
528
+ }
529
+ return out.length === 0 ? void 0 : {
530
+ sessionId,
531
+ route,
532
+ leaks: out
533
+ };
534
+ }
473
535
  /**
474
536
  * Resolves with the URL of the child's `listening <url>` line. A child that exits first, fails
475
537
  * to spawn or stays silent past `timeoutMs` (it is then killed) rejects, quoting its last stderr
476
538
  * lines with the token redacted. omp exiting kills the child; once listening, the child and its
477
539
  * pipes no longer hold omp's event loop, and stdin stays open so the child sees omp go away.
540
+ * After startup every leak warning on stderr goes to `onLeak`.
478
541
  */
479
542
  function waitForListening(child, how) {
480
543
  const killOnExit = () => {
@@ -511,11 +574,21 @@ function waitForListening(child, how) {
511
574
  child.kill("SIGKILL");
512
575
  }, how.timeoutMs);
513
576
  child.stderr?.on("data", (chunk) => {
514
- if (settled) return;
515
577
  const lines = (stderrPartial + String(chunk)).split("\n");
516
578
  stderrPartial = lines.pop() ?? "";
517
- tail.push(...lines.map((line) => line.replace(/\r$/, "")));
518
- tail.splice(0, Math.max(0, tail.length - STDERR_TAIL));
579
+ if (!settled) {
580
+ tail.push(...lines.map((line) => line.replace(/\r$/, "")));
581
+ tail.splice(0, Math.max(0, tail.length - STDERR_TAIL));
582
+ return;
583
+ }
584
+ if (stderrPartial.length > STDERR_LINE_MAX) stderrPartial = "";
585
+ for (const line of lines) {
586
+ const report = parseLeakLine(line);
587
+ if (report === void 0) continue;
588
+ try {
589
+ how.onLeak(report);
590
+ } catch {}
591
+ }
519
592
  });
520
593
  child.stdout?.on("data", (chunk) => {
521
594
  if (settled) return;
@@ -25,6 +25,16 @@ interface Transform {
25
25
  };
26
26
  /** Called once when the proxy drops the session. */
27
27
  dispose?(): void | Promise<void>;
28
+ /** Host-only state of this instance (e.g. the anon mapping), read through TransformProxy.inspect. Never called by the walkers, never sent anywhere. */
29
+ inspect?(): unknown;
30
+ /**
31
+ * Originals this instance has already replaced in this session (forge-anon: its mapping), each with its kind.
32
+ * Used only by the proxy's leak check, which reports kind and JSON path, never the value; never sent anywhere.
33
+ */
34
+ knownOriginals?(): Iterable<{
35
+ readonly kind: string;
36
+ readonly value: string;
37
+ }>;
28
38
  }
29
39
  interface SessionFact {
30
40
  readonly kind: string;
@@ -56,6 +66,51 @@ interface TransformModule {
56
66
  readonly log: Logger;
57
67
  }): Promise<TransformFactory>;
58
68
  }
69
+ /** One leak-check finding. Never carries the value itself. */
70
+ interface TraceLeak {
71
+ /** Fact/original kind, e.g. 'person'. */
72
+ readonly kind: string;
73
+ /** Fact source (publisher id), or 'pipeline' for `Transform.knownOriginals`. */
74
+ readonly source: string;
75
+ /** JSON path in the rewritten request body, e.g. 'tools[0].description'. */
76
+ readonly at: string;
77
+ }
78
+ /** One line of the anon trace: what left for the provider (request) or what it answered before restore (response/error). */
79
+ interface TraceEvent {
80
+ /** ISO timestamp. */
81
+ readonly at: string;
82
+ /** Per proxied request; links its request/response/error lines. */
83
+ readonly requestId: string;
84
+ readonly sessionId: string;
85
+ readonly pipeline: string;
86
+ /** `WireRoute.name`, e.g. 'anthropic.messages'. */
87
+ readonly route: string;
88
+ /** Forward target origin + path, no query string. */
89
+ readonly upstream: string;
90
+ /** Request body `model` when a string. */
91
+ readonly model?: string;
92
+ readonly phase: 'request' | 'response' | 'error';
93
+ /** response/error: HTTP status (502 when the upstream was unreachable). */
94
+ readonly status?: number;
95
+ /** response/error: ms since the request event. */
96
+ readonly durationMs?: number;
97
+ /**
98
+ * request: the delta, keyed by JSON path (`{ 'messages[4]': {...}, system: [...] }`), only entries not yet traced in
99
+ * this session; `{}` when nothing is new.
100
+ * response: the model reply before restore (streamed: assembled in the protocol's non-streamed shape; `undefined` when
101
+ * the stream ended before a complete reply).
102
+ * error: the upstream error body as text, cut to 4096 chars, or the fetch error message.
103
+ */
104
+ readonly content?: unknown;
105
+ /** Set only by `serializeTraceEvent`. */
106
+ readonly truncated?: boolean;
107
+ /** request: leak-check findings; response/error: []. */
108
+ readonly leaks: readonly TraceLeak[];
109
+ }
110
+ /** Synchronous. The proxy catches a throw, logs it, and never lets it reach the client. */
111
+ interface TraceSink {
112
+ record(event: TraceEvent): void;
113
+ }
59
114
  /** A configured transform: a module (object, npm specifier or relative path) and its config. */
60
115
  interface TransformSpec {
61
116
  readonly module: string | TransformModule;
@@ -77,6 +132,8 @@ interface TransformProxyOptions {
77
132
  /** Origins. Absent = any https origin plus loopback http. */
78
133
  allowUpstreams?: readonly string[];
79
134
  log?: Logger;
135
+ /** Records what goes upstream after the pipeline and what comes back before restore. Absent = no trace. */
136
+ trace?: TraceSink;
80
137
  }
81
138
  interface TransformProxy {
82
139
  readonly url: string;
@@ -86,6 +143,12 @@ interface TransformProxy {
86
143
  };
87
144
  /** Disposes the session's transform instances and forgets its facts and caches. */
88
145
  dropSession(id: string): Promise<void>;
146
+ /**
147
+ * Host-only, in-process: each stage's `inspect()` result of the session's pipeline instance, keyed by transform id.
148
+ * `{}` when the session or pipeline is unknown, the instance is not built, or its build failed.
149
+ * Never builds an instance or creates session state; there is no HTTP route for it.
150
+ */
151
+ inspect(sessionId: string, pipelineId?: string): Promise<Record<string, unknown>>;
89
152
  close(): Promise<void>;
90
153
  }
91
154
  declare const UPSTREAM_HEADER = "x-skaile-transform-upstream";
@@ -93,4 +156,4 @@ declare const SESSION_HEADER = "x-skaile-transform-session";
93
156
  declare const PIPELINE_HEADER = "x-skaile-transform-pipeline";
94
157
  declare const TOKEN_HEADER = "x-skaile-transform-token";
95
158
  declare function createTransformProxy(options: TransformProxyOptions): Promise<TransformProxy>;
96
- export { TransformModule as _, TransformProxyOptions as a, TransformSpec as c, SessionFactPublisher as d, SessionFacts as f, TransformFactory as g, Transform as h, TransformProxy as i, Logger as l, TextLeafKind as m, SESSION_HEADER as n, UPSTREAM_HEADER as o, TextLeaf as p, TOKEN_HEADER as r, createTransformProxy as s, PIPELINE_HEADER as t, SessionFact as u, TransformSessionContext as v };
159
+ export { TraceSink as _, TransformProxyOptions as a, TransformModule as b, TransformSpec as c, SessionFactPublisher as d, SessionFacts as f, TraceLeak as g, TraceEvent as h, TransformProxy as i, Logger as l, TextLeafKind as m, SESSION_HEADER as n, UPSTREAM_HEADER as o, TextLeaf as p, TOKEN_HEADER as r, createTransformProxy as s, PIPELINE_HEADER as t, SessionFact as u, Transform as v, TransformSessionContext as x, TransformFactory as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/transform-proxy",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Same-protocol passthrough proxy that runs model requests and replies through a chain of text transforms (Anthropic Messages, OpenAI chat, OpenAI responses)",
6
6
  "license": "MIT",