@skaile/transform-proxy 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +73 -5
- package/dist/cli.d.mts +3 -1
- package/dist/cli.mjs +18 -6
- package/dist/{config-Ceb5AF_a.mjs → config-DHxYF3Tv.mjs} +487 -24
- package/dist/index.d.mts +26 -2
- package/dist/index.mjs +1 -1
- package/dist/omp/index.mjs +80 -7
- package/dist/{server-CicvlQ7q.d.mts → server-DdXMr4yF.d.mts} +56 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- Library: the anon trace records what the proxy sends to the provider **after** the pipeline and what the model answers **before** restore, so a reviewer can check that no clear-text personal data reached the provider. `TransformProxyOptions.trace?: TraceSink` (synchronous `record(event: TraceEvent)`) gets one `request` line per proxied request on the wire routes and exactly one `response` or `error` line for it, linked by `requestId`. Without `trace`, nothing is recorded or hashed. A sink that throws is logged (`transform proxy: trace sink failed: <msg>`) and never reaches the client.
|
|
6
|
+
- Library: a `request` line holds only the delta: the entries of the rewritten body not yet traced in this session, keyed by JSON path (`messages[i]` / `input[i]` per element, every other top-level key such as `system`, `tools`, `instructions` as a whole; `{}` when nothing is new). The harness resends the whole history each round, so a full body per line would repeat the conversation. `dropSession` forgets what was traced.
|
|
7
|
+
- Library: a `response` line holds the reply before restore: the parsed JSON body, or for SSE the message the walker assembles in the protocol's non-streamed shape (Anthropic text, thinking, redacted thinking and `tool_use` blocks with parsed `input`; OpenAI chat `chat.completion` with content, reasoning and tool calls; OpenAI responses the final `response` object). The SSE bytes to the client are the same with or without a trace. A stream that ends before a complete reply still gets its line, with no `content`. A non-2xx reply gives an `error` line with the status and the body cut to 4096 characters; an unreachable upstream gives `status: 502` and the fetch error.
|
|
8
|
+
- Library: `createFileTraceSink({ dir, maxChars? })` appends one JSON line per event to `<dir>/<sessionId>.jsonl` (an id outside `[A-Za-z0-9_-]{1,128}` becomes `sha256-<32 hex>`). The directory is created with mode 0700, files with 0600: they hold exactly the leaks the trace is meant to find. `serializeTraceEvent(event, maxChars?)` is the line format; `content` whose JSON is longer than `maxChars` (default `DEFAULT_TRACE_MAX_CHARS`, 262144) becomes that JSON text cut to `maxChars`, with `truncated: true`.
|
|
9
|
+
- Library: trace lines name the route: `anthropic.messages`, `anthropic.count_tokens`, `openai.chat` or `openai.responses`.
|
|
10
|
+
- Library: an always-on leak check scans every string value of the rewritten body, including fields no transform sees (`tools`, metadata, unknown fields), for known originals: the session's facts (source = publisher id) and the originals the pipeline has already replaced (`Transform.knownOriginals`, source `pipeline`). A value that one place replaced but another sends in clear text (a name in a tool description, or one the detector missed in a new context) is otherwise invisible. Matching is case-sensitive; values shorter than 3 characters are skipped (chance hits). A finding logs `log.warn('transform proxy: known original sent upstream', { sessionId, route, leaks })` with kind, source and JSON path of each finding, never the value, and goes into the `request` line's `leaks`. The request is still sent: the check warns, it does not block.
|
|
11
|
+
- Library: `Transform.knownOriginals?()` returns the originals an instance has already replaced in this session, each with its kind (forge-anon: its mapping). Without connectors (omp) there are no facts, so the leak check needs these to compare against. `composeTransforms` returns the union of all stages; a stage whose `knownOriginals()` throws is logged and skipped. The values are used only by the leak check and never sent or logged.
|
|
12
|
+
- Config: top-level key `trace: { dir, maxChars? }` in `transform-proxy.yaml`; `dir` resolves against the config file's directory, `maxChars` must be a positive integer.
|
|
13
|
+
- CLI: `--trace-dir <dir>` (relative to the cwd) turns the file trace on; `SKAILE_TRANSFORM_PROXY_TRACE_DIR` (relative to the cwd) overrides the flag and the config's `trace.dir`. With a trace, the CLI prints `[transform-proxy info] trace: <dir>` on stderr at start.
|
|
14
|
+
- CLI, **behaviour change**: log lines print non-string arguments as JSON (`JSON.stringify`) instead of `String(arg)`, so the leak warning reads `[transform-proxy warn] transform proxy: known original sent upstream {"sessionId":"…","route":"…","leaks":[…]}` instead of `[object Object]`. Anything that parses the proxy's stderr sees JSON where it saw `[object Object]` before.
|
|
15
|
+
- omp extension: `/anon status` shows `trace: <dir>` or `trace: off`. The own proxy child inherits `SKAILE_TRANSFORM_PROXY_TRACE_DIR`; without it, the status shows the config's `trace.dir`. With an external proxy (`SKAILE_TRANSFORM_PROXY_URL`) only the env var is known.
|
|
16
|
+
- omp extension: the extension keeps reading its proxy child's stderr after `listening <url>` (before, it dropped it) and turns each leak warning into a warning notice, once per session and kind for the child's life: `anon: a known <kind> value was sent to the provider in clear text (at <path>, route <route>). Value not shown.` Before, the warning was lost, and repeating it every round would flood the UI.
|
|
17
|
+
|
|
3
18
|
## 0.4.0
|
|
4
19
|
|
|
5
20
|
- Library: `TransformProxy.inspect(sessionId, pipelineId?)` returns host-only transform state, `{ <transformId>: state }` for each stage of the session's pipeline instance (default `defaultPipeline`) that implements the new optional `Transform.inspect?()`. A host such as anon-ai reads forge-anon's mapping through it to show what was anonymized. It is in-process only (no HTTP route, never sent), returns `{}` for an unknown session, an unbuilt pipeline or a failed build, and never builds an instance or creates session state.
|
package/README.md
CHANGED
|
@@ -53,20 +53,25 @@ pipelines:
|
|
|
53
53
|
defaultPipeline: anon
|
|
54
54
|
allowUpstreams: # optional; default: any https origin, plus loopback http
|
|
55
55
|
- https://api.anthropic.com
|
|
56
|
+
trace: # optional; see "Trace"
|
|
57
|
+
dir: anon-traces # relative to this file's directory
|
|
58
|
+
maxChars: 262144 # optional; positive integer, default 262144
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
Keys are exactly the option fields. An unknown top-level key is an error. Module specifiers resolve against the config file's directory.
|
|
61
|
+
Keys are exactly the option fields, except `trace`, which configures the CLI's file sink (see [Trace](#trace)). An unknown top-level key, or an unknown key under `trace`, is an error. Module specifiers resolve against the config file's directory.
|
|
59
62
|
|
|
60
63
|
## CLI
|
|
61
64
|
|
|
62
65
|
```sh
|
|
63
|
-
skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]
|
|
66
|
+
skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--trace-dir <dir>] [--exit-with-stdin]
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
- The token comes from `--token` or `SKAILE_TRANSFORM_PROXY_TOKEN`.
|
|
67
70
|
- Without `--config`, the proxy reads `./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`.
|
|
68
71
|
- On start it prints `listening <url>`. It stops on SIGINT or SIGTERM.
|
|
69
72
|
- `--exit-with-stdin` also stops it when its stdin closes. A parent process that spawns the proxy with a stdin pipe and never writes to it takes the proxy down when it exits, even on SIGKILL.
|
|
73
|
+
- `--trace-dir <dir>` (relative to the cwd) writes the [trace](#trace) there; `SKAILE_TRANSFORM_PROXY_TRACE_DIR` overrides it. With a trace, the CLI prints `[transform-proxy info] trace: <dir>` on stderr at start.
|
|
74
|
+
- Warnings and errors go to stderr as `[transform-proxy warn] <message>` / `[transform-proxy error] <message>`, followed by each argument: strings as they are, everything else as JSON.
|
|
70
75
|
|
|
71
76
|
## Library
|
|
72
77
|
|
|
@@ -87,6 +92,65 @@ await proxy.close()
|
|
|
87
92
|
|
|
88
93
|
`await proxy.inspect('s1')` lets the host read what the session's transforms hold, e.g. forge-anon's mapping for highlights: `{ <transformId>: state }`, one entry per stage of the pipeline (`inspect('s1', 'anon')`; default `defaultPipeline`) whose `Transform` implements `inspect()`. It is in-process only: there is no HTTP route, and the result is never sent anywhere. An unknown session, a pipeline that has not been built for the session yet, or a failed build gives `{}`; `inspect` never builds an instance or creates session state.
|
|
89
94
|
|
|
95
|
+
`trace: createFileTraceSink({ dir })` in the options turns on the [trace](#trace). Any object with a synchronous `record(event: TraceEvent)` is a `TraceSink`.
|
|
96
|
+
|
|
97
|
+
## Trace
|
|
98
|
+
|
|
99
|
+
The trace records what the proxy sends to the provider **after** the pipeline, and what the model answers **before** restore. It exists to check that no clear-text personal data reaches the provider.
|
|
100
|
+
|
|
101
|
+
Turn it on in one of three places; the first that is set wins:
|
|
102
|
+
|
|
103
|
+
1. `SKAILE_TRANSFORM_PROXY_TRACE_DIR=<dir>` (relative to the cwd; an empty value counts as unset)
|
|
104
|
+
2. `--trace-dir <dir>` (relative to the cwd)
|
|
105
|
+
3. `trace: { dir, maxChars? }` in `transform-proxy.yaml` (`dir` relative to the config file)
|
|
106
|
+
|
|
107
|
+
`maxChars` comes from the config only. In the library, pass `trace: createFileTraceSink({ dir, maxChars? })`, or any `TraceSink`.
|
|
108
|
+
|
|
109
|
+
`createFileTraceSink` writes one file per session, `<dir>/<sessionId>.jsonl`, and appends one JSON line per event. A session id outside `[A-Za-z0-9_-]{1,128}` becomes `sha256-` plus the first 32 hex characters of its sha256. The directory is created with mode 0700, the files with 0600. A sink that throws is logged (`transform proxy: trace sink failed: <msg>`) and does not change what the client gets.
|
|
110
|
+
|
|
111
|
+
Each proxied request on a wire route (`…/messages`, `…/messages/count_tokens`, `…/chat/completions`, `…/responses`) gives one `request` line, then exactly one `response` or `error` line. `GET …/models` is not traced. A line (`TraceEvent`) has these fields:
|
|
112
|
+
|
|
113
|
+
| Field | Content |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `at` | ISO timestamp. |
|
|
116
|
+
| `requestId` | Random UUID per proxied request; links its request and response/error lines. |
|
|
117
|
+
| `sessionId` | The proxy session. |
|
|
118
|
+
| `pipeline` | The pipeline id. |
|
|
119
|
+
| `route` | `anthropic.messages`, `anthropic.count_tokens`, `openai.chat` or `openai.responses`. |
|
|
120
|
+
| `upstream` | The forward target, origin + path, without the query string. |
|
|
121
|
+
| `model` | The request body's `model`, when it is a string. |
|
|
122
|
+
| `phase` | `request`, `response` or `error`. |
|
|
123
|
+
| `status` | response/error: the HTTP status (502 when the upstream was unreachable). |
|
|
124
|
+
| `durationMs` | response/error: milliseconds since the request line. |
|
|
125
|
+
| `content` | See below. |
|
|
126
|
+
| `truncated` | `true` when `content` was cut. |
|
|
127
|
+
| `leaks` | request: the [leak check](#leak-check) findings (`[]` when none); response/error: `[]`. |
|
|
128
|
+
|
|
129
|
+
`content` by phase:
|
|
130
|
+
|
|
131
|
+
- `request`: only the delta, an object keyed by JSON path, e.g. `{ "messages[4]": {…}, "system": […], "tools": […] }`. Each element of `messages` and `input` is one entry (`messages[i]`, `input[i]`); every other top-level key of the rewritten body is one entry as a whole. An entry is written once per session: the proxy keeps a sha256 of each written entry, and a later request writes only the entries it has not seen, `{}` when nothing is new. Harnesses resend the whole history every round, so round two shows only the new turn. A harness that changes an earlier message (omp strips thinking blocks, for example) makes that message show up again. Placeholders are stable per session, so the hashes are too. `dropSession` forgets the hashes with the rest of the session.
|
|
132
|
+
- `response`: the reply before restore. A JSON reply is the parsed body. An SSE reply is the message the walker assembles from the stream, in the protocol's non-streamed shape: an Anthropic `message` with its text, thinking and `tool_use` blocks (`input` parsed from the streamed JSON), an OpenAI `chat.completion` with content, `reasoning_content` and `tool_calls`, or the `response` object of the final OpenAI responses event. The SSE bytes the client gets are the same with or without a trace. A stream that ends before a complete reply has no `content`.
|
|
133
|
+
- `error`: a non-2xx reply's body as text, cut to 4096 characters, or the fetch error when the upstream was unreachable.
|
|
134
|
+
|
|
135
|
+
When the JSON of `content` is longer than `maxChars` (default 262144, `DEFAULT_TRACE_MAX_CHARS`), `content` becomes that JSON text cut to `maxChars`, and `truncated` is `true`. `serializeTraceEvent(event, maxChars?)` gives the line without the trailing newline.
|
|
136
|
+
|
|
137
|
+
### Leak check
|
|
138
|
+
|
|
139
|
+
The leak check runs on every request on a wire route, with or without a trace. After the pipeline has rewritten the request, it scans every string value of the whole body, fields no transform sees included (`tools`, metadata, unknown fields), for known originals:
|
|
140
|
+
|
|
141
|
+
- the session's facts, with the publisher id as `source`;
|
|
142
|
+
- the originals the pipeline has already replaced in this session (`Transform.knownOriginals()`, e.g. forge-anon's mapping), with `source` `pipeline`. Without connectors (omp) there are no facts, so these are what the check compares against: a name that the transform replaced in one place and that appears in clear text in another, in a tool description or in a context the detector missed.
|
|
143
|
+
|
|
144
|
+
Matching is case-sensitive and by substring; values shorter than 3 characters are skipped, because they match by chance. Object keys are not scanned. Each finding has `kind`, `source` and `at` (the JSON path, e.g. `tools[0].description`), once per kind, source and path. A request with findings logs
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
transform proxy: known original sent upstream { sessionId, route, leaks: [{ kind, source, at }] }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
as a warning (the CLI prints `[transform-proxy warn] transform proxy: known original sent upstream {"sessionId":"…","route":"…","leaks":[…]}`), and the findings go into the request line's `leaks`. The warning never contains the value. The request is still sent: the check warns, it does not block.
|
|
151
|
+
|
|
152
|
+
Trace files contain exactly the leaks they are meant to find, in clear text. Keep them local, never commit them, and delete them once reviewed.
|
|
153
|
+
|
|
90
154
|
## omp extension
|
|
91
155
|
|
|
92
156
|
The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform-proxy/omp`, declared in the package manifest under `omp.extensions`):
|
|
@@ -117,7 +181,8 @@ statusLine:
|
|
|
117
181
|
- While anon is on or in error, images are not sent: the extension sets omp's `images.blockImages` in omp's in-memory runtime settings layer, and omp replaces every image in the request, history included, with the note `Image reading is disabled.`. The proxy cannot anonymize images and refuses every request that carries one (400 `unsupported_content`), and omp resends the whole history each turn, so a single screenshot would otherwise block the session. `/anon off` drops the override, so your own `images.blockImages` setting applies again; the override is never written to `config.yml`. An omp without this settings API gets a warning instead, and the proxy keeps refusing images.
|
|
118
182
|
- The proxy session follows the root omp session: on `/new`, `/resume`, `/fork` (`session_switch`) and on a root `session_start` with a new session id, every provider is re-routed with the new `x-skaile-transform-session`. The proxy keeps the old session's state in memory until it exits; no request carries that id again. A `session_start` of a headless `print` session (how omp runs subagents) keeps the parent's id, so subagents share its pseudonyms.
|
|
119
183
|
- `/anon off` restores the original base URLs and headers.
|
|
120
|
-
- `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, whether images are blocked, and the routed and refused providers.
|
|
184
|
+
- `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, the trace target (`trace: <dir>` or `trace: off`), whether images are blocked, and the routed and refused providers. The own proxy child inherits `SKAILE_TRANSFORM_PROXY_TRACE_DIR` with the rest of omp's environment, so the [trace](#trace) is on when that variable is set (shown resolved against the cwd) or when the config sets `trace.dir`. With an external proxy (`SKAILE_TRANSFORM_PROXY_URL`) only the variable is known; the status shows `trace: off` without it, whatever that proxy does.
|
|
185
|
+
- The extension keeps reading its proxy child's stderr after `listening <url>`. Each [leak warning](#leak-check) becomes a warning notice, once per session and kind while the child lives, so a leak in the history does not repeat every round: `anon: a known <kind> value was sent to the provider in clear text (at <path>, route <route>). Value not shown.` Leak warnings of an external proxy stay in that proxy's log.
|
|
121
186
|
- `/anon on` and `/anon off` switch only the omp process they run in: that window, its subagents, and the sessions it opens with `/new`, `/resume` and `/fork`. Other omp windows are not touched, and the saved default does not change.
|
|
122
187
|
- `/anon on --global` and `/anon off --global` also save the default for omp processes started later, in `skaile-transform-proxy.json` in omp's agent dir (`~/.omp/agent`; `SKAILE_TRANSFORM_PROXY_OMP_STATE` overrides the path). A session start applies a saved `on` until the first successful `/anon on|off` in that process; from then on the process follows its own choice. A failed `/anon on --global` saves nothing.
|
|
123
188
|
- `SKAILE_TRANSFORM_PROXY_MODE=required` (set by a skaile session in required mode) turns routing on at session start and refuses `/anon off`.
|
|
@@ -130,7 +195,8 @@ A module default-exports a `TransformModule`:
|
|
|
130
195
|
|
|
131
196
|
- `open(config, { baseDir, log })` runs once per proxy. It validates the config and builds shared state. Throwing rejects startup.
|
|
132
197
|
- It returns a `TransformFactory`, which runs once per `(session, pipeline)`: `(ctx: { sessionId, facts, log }) => Transform`.
|
|
133
|
-
- A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()`, `dispose?()
|
|
198
|
+
- A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()`, `dispose?()`, `inspect?()` (host-only state, read through `proxy.inspect`; the walkers never call it) and `knownOriginals?()`.
|
|
199
|
+
- `knownOriginals?(): Iterable<{ kind, value }>` returns the originals the instance has already replaced in this session, each with its kind (forge-anon: its mapping). Only the [leak check](#leak-check) reads them, as source `pipeline`; the values are never sent or logged. `composeTransforms` returns the union of all stages that implement it; a stage that throws is logged and skipped.
|
|
134
200
|
|
|
135
201
|
The Anthropic walker appends the pipeline `instruction` after the host's system prompt: a string `system` gets it after a blank line, a block array gets it as one more text block at the end, and an absent `system` becomes the instruction. The host's leading blocks stay first, because Claude Code gateways (TeamClaude) refuse Sonnet requests with 429 `rate_limit_error` when the system prompt does not start with the Claude Code blocks. The OpenAI walkers still put the instruction first: a leading `system` message in chat completions, a prefix of `instructions` in responses.
|
|
136
202
|
|
|
@@ -145,4 +211,6 @@ Connectors publish facts through `proxy.session(id).publisher(source)`. Facts ar
|
|
|
145
211
|
- The default host is `127.0.0.1`. Set a token before binding elsewhere.
|
|
146
212
|
- Use `allowUpstreams` to limit where requests can go.
|
|
147
213
|
- Clear text never leaves the proxy in a transformed field. Auth headers are relayed, not logged.
|
|
148
|
-
-
|
|
214
|
+
- Fields the walkers do not rewrite (`tools`, metadata, unknown fields) are forwarded as they are. The [leak check](#leak-check) warns when one of them carries a known original; it does not block the request.
|
|
215
|
+
- Leak warnings carry kind, source and JSON path, never the value. Trace files and any other `TraceSink` get the clear text that leaked: keep them local, never commit them, delete them once reviewed.
|
|
216
|
+
- Mappings, facts, the signature cache and the trace hashes are kept in memory and are lost on restart.
|
package/dist/cli.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { i as TransformProxy } from "./server-
|
|
1
|
+
import { i as TransformProxy } from "./server-DdXMr4yF.mjs";
|
|
2
2
|
/** Environment variable that supplies the client token when `--token` is absent. */
|
|
3
3
|
export declare const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
|
|
4
|
+
/** Environment variable with the trace directory (relative to cwd); overrides `--trace-dir` and the config's `trace.dir`. */
|
|
5
|
+
export declare const TRACE_DIR_ENV = "SKAILE_TRANSFORM_PROXY_TRACE_DIR";
|
|
4
6
|
export interface CliEnvironment {
|
|
5
7
|
readonly cwd: string;
|
|
6
8
|
readonly home: string;
|
package/dist/cli.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { d as createTransformProxy, o as resolveProxyConfig,
|
|
2
|
+
import { d as createTransformProxy, o as resolveProxyConfig, p as createFileTraceSink, x as errorMessage } from "./config-DHxYF3Tv.mjs";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
7
8
|
// src/cli.ts
|
|
8
9
|
/** Environment variable that supplies the client token when `--token` is absent. */
|
|
9
10
|
const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
|
|
10
|
-
|
|
11
|
+
/** Environment variable with the trace directory (relative to cwd); overrides `--trace-dir` and the config's `trace.dir`. */
|
|
12
|
+
const TRACE_DIR_ENV = "SKAILE_TRANSFORM_PROXY_TRACE_DIR";
|
|
13
|
+
const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--trace-dir <dir>] [--exit-with-stdin]";
|
|
11
14
|
/** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
|
|
12
15
|
async function runCli(argv, io) {
|
|
13
16
|
let values;
|
|
@@ -20,6 +23,7 @@ async function runCli(argv, io) {
|
|
|
20
23
|
port: { type: "string" },
|
|
21
24
|
host: { type: "string" },
|
|
22
25
|
token: { type: "string" },
|
|
26
|
+
"trace-dir": { type: "string" },
|
|
23
27
|
help: {
|
|
24
28
|
type: "boolean",
|
|
25
29
|
short: "h"
|
|
@@ -52,9 +56,12 @@ async function runCli(argv, io) {
|
|
|
52
56
|
io.stderr(errorMessage(err));
|
|
53
57
|
return { code: 1 };
|
|
54
58
|
}
|
|
59
|
+
const { trace: traceConfig, ...proxyOptions } = config.options;
|
|
60
|
+
const envTraceDir = io.env[TRACE_DIR_ENV];
|
|
61
|
+
const traceDir = envTraceDir !== void 0 && envTraceDir !== "" ? resolve(io.cwd, envTraceDir) : values["trace-dir"] !== void 0 ? resolve(io.cwd, values["trace-dir"]) : traceConfig?.dir;
|
|
55
62
|
try {
|
|
56
63
|
const proxy = await createTransformProxy({
|
|
57
|
-
...
|
|
64
|
+
...proxyOptions,
|
|
58
65
|
host: values.host,
|
|
59
66
|
port,
|
|
60
67
|
token: values.token ?? io.env["SKAILE_TRANSFORM_PROXY_TOKEN"],
|
|
@@ -63,8 +70,13 @@ async function runCli(argv, io) {
|
|
|
63
70
|
info: () => {},
|
|
64
71
|
warn: (msg, ...args) => io.stderr(format("warn", msg, args)),
|
|
65
72
|
error: (msg, ...args) => io.stderr(format("error", msg, args))
|
|
66
|
-
}
|
|
73
|
+
},
|
|
74
|
+
...traceDir === void 0 ? {} : { trace: createFileTraceSink({
|
|
75
|
+
dir: traceDir,
|
|
76
|
+
maxChars: traceConfig?.maxChars
|
|
77
|
+
}) }
|
|
67
78
|
});
|
|
79
|
+
if (traceDir !== void 0) io.stderr(format("info", `trace: ${traceDir}`, []));
|
|
68
80
|
io.stdout(`listening ${proxy.url}`);
|
|
69
81
|
return {
|
|
70
82
|
code: 0,
|
|
@@ -82,7 +94,7 @@ function parsePort(value) {
|
|
|
82
94
|
return port <= 65535 ? port : void 0;
|
|
83
95
|
}
|
|
84
96
|
function format(level, msg, args) {
|
|
85
|
-
return [`[transform-proxy ${level}] ${msg}`, ...args.map(
|
|
97
|
+
return [`[transform-proxy ${level}] ${msg}`, ...args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg))].join(" ");
|
|
86
98
|
}
|
|
87
99
|
/** True when this module is the executed entry point (the bin, or `bun src/cli.ts`). */
|
|
88
100
|
function isEntryPoint() {
|
|
@@ -123,4 +135,4 @@ if (isEntryPoint()) {
|
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
|
-
export { TOKEN_ENV, runCli };
|
|
138
|
+
export { TOKEN_ENV, TRACE_DIR_ENV, runCli };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { parse } from "yaml";
|
|
@@ -46,9 +46,32 @@ function composeTransforms(stages, log = silentLogger) {
|
|
|
46
46
|
} catch (err) {
|
|
47
47
|
log.error(`transform dispose failed: ${errorMessage(err)}`);
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
},
|
|
50
|
+
...stages.some((stage) => stage.knownOriginals !== void 0) ? { knownOriginals: () => unionKnownOriginals(stages, log) } : {}
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
/** The originals of every stage, deduped by kind and value; a stage that throws is logged and skipped. */
|
|
54
|
+
function unionKnownOriginals(stages, log) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const seen = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const stage of stages) {
|
|
58
|
+
if (stage.knownOriginals === void 0) continue;
|
|
59
|
+
try {
|
|
60
|
+
for (const { kind, value } of stage.knownOriginals()) {
|
|
61
|
+
const key = JSON.stringify([kind, value]);
|
|
62
|
+
if (seen.has(key)) continue;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
out.push({
|
|
65
|
+
kind,
|
|
66
|
+
value
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
log.error(`transform knownOriginals failed: ${errorMessage(err)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
52
75
|
/**
|
|
53
76
|
* Streams stage by stage. `streams` is in pipeline order (stage 1 first).
|
|
54
77
|
* push feeds stage n first and passes each result down to stage 1.
|
|
@@ -102,6 +125,74 @@ function createSessionFacts() {
|
|
|
102
125
|
}
|
|
103
126
|
};
|
|
104
127
|
}
|
|
128
|
+
// src/leaks.ts
|
|
129
|
+
/** Shorter values match by chance too often to be worth a warning. */
|
|
130
|
+
const MIN_LEAK_LENGTH = 3;
|
|
131
|
+
const PLAIN_KEY = /^[A-Za-z_$][\w$]*$/;
|
|
132
|
+
/**
|
|
133
|
+
* The values the leak check compares against: the session's facts, each with its publisher as source, plus what the
|
|
134
|
+
* pipeline instance reports via `knownOriginals()` as source `pipeline`. If that throws, only the facts are returned.
|
|
135
|
+
*/
|
|
136
|
+
function knownOriginals(facts, instance) {
|
|
137
|
+
const out = facts.list().map(({ kind, source, value }) => ({
|
|
138
|
+
kind,
|
|
139
|
+
source,
|
|
140
|
+
value
|
|
141
|
+
}));
|
|
142
|
+
if (instance?.knownOriginals === void 0) return out;
|
|
143
|
+
try {
|
|
144
|
+
const fromPipeline = [];
|
|
145
|
+
for (const { kind, value } of instance.knownOriginals()) fromPipeline.push({
|
|
146
|
+
kind,
|
|
147
|
+
source: "pipeline",
|
|
148
|
+
value
|
|
149
|
+
});
|
|
150
|
+
out.push(...fromPipeline);
|
|
151
|
+
} catch {}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Every place in `body` (the rewritten request) where a string value contains a known original, case-sensitively.
|
|
156
|
+
* Only string values are scanned, not object keys. The result holds kind, source and JSON path, never the value.
|
|
157
|
+
*/
|
|
158
|
+
function findLeaks(body, originals) {
|
|
159
|
+
const candidates = [];
|
|
160
|
+
for (const original of originals) if (original.value.length >= MIN_LEAK_LENGTH) candidates.push(original);
|
|
161
|
+
if (candidates.length === 0) return [];
|
|
162
|
+
const leaks = [];
|
|
163
|
+
const seen = /* @__PURE__ */ new Set();
|
|
164
|
+
const visit = (node, path) => {
|
|
165
|
+
if (typeof node === "string") {
|
|
166
|
+
for (const { kind, source, value } of candidates) {
|
|
167
|
+
if (!node.includes(value)) continue;
|
|
168
|
+
const key = JSON.stringify([
|
|
169
|
+
kind,
|
|
170
|
+
source,
|
|
171
|
+
path
|
|
172
|
+
]);
|
|
173
|
+
if (seen.has(key)) continue;
|
|
174
|
+
seen.add(key);
|
|
175
|
+
leaks.push({
|
|
176
|
+
kind,
|
|
177
|
+
source,
|
|
178
|
+
at: path
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(node)) {
|
|
184
|
+
node.forEach((item, i) => visit(item, `${path}[${i}]`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (node !== null && typeof node === "object") for (const [key, value] of Object.entries(node)) visit(value, joinKey(path, key));
|
|
188
|
+
};
|
|
189
|
+
visit(body, "");
|
|
190
|
+
return leaks;
|
|
191
|
+
}
|
|
192
|
+
function joinKey(path, key) {
|
|
193
|
+
if (!PLAIN_KEY.test(key)) return `${path}[${JSON.stringify(key)}]`;
|
|
194
|
+
return path === "" ? key : `${path}.${key}`;
|
|
195
|
+
}
|
|
105
196
|
// src/modules.ts
|
|
106
197
|
/** Loads and opens every configured transform once. Any failure rejects with the transform id. */
|
|
107
198
|
async function openTransforms(specs, baseDir, log) {
|
|
@@ -497,8 +588,81 @@ function tailDelta(kind, tail) {
|
|
|
497
588
|
partial_json: tail
|
|
498
589
|
};
|
|
499
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Rebuilds the Anthropic message from the raw upstream events (before restore) for `ctx.observe`.
|
|
593
|
+
* Errors are swallowed: recording the reply must never disturb the stream.
|
|
594
|
+
*/
|
|
595
|
+
function createStreamObserver(observe) {
|
|
596
|
+
let message;
|
|
597
|
+
const content = [];
|
|
598
|
+
const toolJson = /* @__PURE__ */ new Map();
|
|
599
|
+
const settleTool = (index) => {
|
|
600
|
+
const json = toolJson.get(index);
|
|
601
|
+
const block = content[index];
|
|
602
|
+
if (json === void 0 || block === void 0) return;
|
|
603
|
+
toolJson.delete(index);
|
|
604
|
+
if (json === "") return;
|
|
605
|
+
try {
|
|
606
|
+
block.input = JSON.parse(json);
|
|
607
|
+
} catch {
|
|
608
|
+
block.input = json;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
return {
|
|
612
|
+
event(payload) {
|
|
613
|
+
try {
|
|
614
|
+
const index = typeof payload.index === "number" ? payload.index : void 0;
|
|
615
|
+
switch (payload.type) {
|
|
616
|
+
case "message_start":
|
|
617
|
+
if (isObj$2(payload.message)) message = { ...payload.message };
|
|
618
|
+
return;
|
|
619
|
+
case "content_block_start":
|
|
620
|
+
if (index === void 0 || !isObj$2(payload.content_block)) return;
|
|
621
|
+
content[index] = { ...payload.content_block };
|
|
622
|
+
if (payload.content_block.type === "tool_use") toolJson.set(index, "");
|
|
623
|
+
return;
|
|
624
|
+
case "content_block_delta": {
|
|
625
|
+
const block = index === void 0 ? void 0 : content[index];
|
|
626
|
+
const delta = payload.delta;
|
|
627
|
+
if (block === void 0 || index === void 0 || !isObj$2(delta)) return;
|
|
628
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") block.text = String(block.text ?? "") + delta.text;
|
|
629
|
+
else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") block.thinking = String(block.thinking ?? "") + delta.thinking;
|
|
630
|
+
else if (delta.type === "signature_delta" && typeof delta.signature === "string") block.signature = String(block.signature ?? "") + delta.signature;
|
|
631
|
+
else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") toolJson.set(index, (toolJson.get(index) ?? "") + delta.partial_json);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
case "content_block_stop":
|
|
635
|
+
if (index !== void 0) settleTool(index);
|
|
636
|
+
return;
|
|
637
|
+
case "message_delta":
|
|
638
|
+
if (message === void 0) return;
|
|
639
|
+
if (isObj$2(payload.delta)) {
|
|
640
|
+
if ("stop_reason" in payload.delta) message.stop_reason = payload.delta.stop_reason;
|
|
641
|
+
if ("stop_sequence" in payload.delta) message.stop_sequence = payload.delta.stop_sequence;
|
|
642
|
+
}
|
|
643
|
+
if (isObj$2(payload.usage)) message.usage = {
|
|
644
|
+
...isObj$2(message.usage) ? message.usage : {},
|
|
645
|
+
...payload.usage
|
|
646
|
+
};
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
} catch {}
|
|
650
|
+
},
|
|
651
|
+
finish() {
|
|
652
|
+
try {
|
|
653
|
+
for (const index of [...toolJson.keys()]) settleTool(index);
|
|
654
|
+
if (message !== void 0) observe({
|
|
655
|
+
...message,
|
|
656
|
+
content: content.filter((block) => block !== void 0)
|
|
657
|
+
});
|
|
658
|
+
} catch {}
|
|
659
|
+
message = void 0;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
500
663
|
function createStreamTransformer$2(ctx) {
|
|
501
664
|
const blocks = /* @__PURE__ */ new Map();
|
|
665
|
+
const observer = ctx.observe === void 0 ? void 0 : createStreamObserver(ctx.observe);
|
|
502
666
|
const handle = (block) => {
|
|
503
667
|
let payload;
|
|
504
668
|
try {
|
|
@@ -507,6 +671,7 @@ function createStreamTransformer$2(ctx) {
|
|
|
507
671
|
return block.raw;
|
|
508
672
|
}
|
|
509
673
|
if (!isObj$2(payload)) return block.raw;
|
|
674
|
+
observer?.event(payload);
|
|
510
675
|
const index = typeof payload.index === "number" ? payload.index : void 0;
|
|
511
676
|
switch (payload.type) {
|
|
512
677
|
case "content_block_start": {
|
|
@@ -568,6 +733,7 @@ function createStreamTransformer$2(ctx) {
|
|
|
568
733
|
}
|
|
569
734
|
};
|
|
570
735
|
const finish = () => {
|
|
736
|
+
observer?.finish();
|
|
571
737
|
let out = "";
|
|
572
738
|
for (const [index, state] of blocks) {
|
|
573
739
|
const tail = state.restore.flush();
|
|
@@ -807,10 +973,93 @@ function flushChoice(state) {
|
|
|
807
973
|
state.tools.clear();
|
|
808
974
|
return Object.keys(delta).length === 0 ? void 0 : delta;
|
|
809
975
|
}
|
|
976
|
+
/** Rebuilds the non-streamed `chat.completion` from raw (pre-restore) chunks for `ctx.observe`. */
|
|
977
|
+
function createChatAssembler() {
|
|
978
|
+
let seen = false;
|
|
979
|
+
let envelope = {};
|
|
980
|
+
let usage;
|
|
981
|
+
const choices = /* @__PURE__ */ new Map();
|
|
982
|
+
return {
|
|
983
|
+
add(chunk) {
|
|
984
|
+
seen = true;
|
|
985
|
+
envelope = chunk;
|
|
986
|
+
if (chunk.usage !== void 0 && chunk.usage !== null) usage = chunk.usage;
|
|
987
|
+
for (const choice of chunk.choices) {
|
|
988
|
+
if (!isObj$1(choice)) continue;
|
|
989
|
+
const index = typeof choice.index === "number" ? choice.index : 0;
|
|
990
|
+
let state = choices.get(index);
|
|
991
|
+
if (state === void 0) {
|
|
992
|
+
state = {
|
|
993
|
+
content: null,
|
|
994
|
+
tools: /* @__PURE__ */ new Map(),
|
|
995
|
+
finish: null
|
|
996
|
+
};
|
|
997
|
+
choices.set(index, state);
|
|
998
|
+
}
|
|
999
|
+
if (choice.finish_reason !== null && choice.finish_reason !== void 0) state.finish = choice.finish_reason;
|
|
1000
|
+
const delta = choice.delta;
|
|
1001
|
+
if (!isObj$1(delta)) continue;
|
|
1002
|
+
if (typeof delta.content === "string") state.content = (state.content ?? "") + delta.content;
|
|
1003
|
+
if (typeof delta.reasoning_content === "string") state.reasoning = (state.reasoning ?? "") + delta.reasoning_content;
|
|
1004
|
+
if (!Array.isArray(delta.tool_calls)) continue;
|
|
1005
|
+
for (const call of delta.tool_calls) {
|
|
1006
|
+
if (!isObj$1(call)) continue;
|
|
1007
|
+
const callIndex = typeof call.index === "number" ? call.index : 0;
|
|
1008
|
+
let tool = state.tools.get(callIndex);
|
|
1009
|
+
if (tool === void 0) {
|
|
1010
|
+
tool = {
|
|
1011
|
+
name: "",
|
|
1012
|
+
arguments: ""
|
|
1013
|
+
};
|
|
1014
|
+
state.tools.set(callIndex, tool);
|
|
1015
|
+
}
|
|
1016
|
+
if (call.id !== void 0 && call.id !== null) tool.id = call.id;
|
|
1017
|
+
const fn = call.function;
|
|
1018
|
+
if (isObj$1(fn)) {
|
|
1019
|
+
if (typeof fn.name === "string") tool.name += fn.name;
|
|
1020
|
+
if (typeof fn.arguments === "string") tool.arguments += fn.arguments;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
take() {
|
|
1026
|
+
if (!seen) return void 0;
|
|
1027
|
+
seen = false;
|
|
1028
|
+
const out = {
|
|
1029
|
+
id: envelope.id,
|
|
1030
|
+
object: "chat.completion",
|
|
1031
|
+
model: envelope.model,
|
|
1032
|
+
choices: [...choices].sort(([a], [b]) => a - b).map(([index, state]) => {
|
|
1033
|
+
const message = {
|
|
1034
|
+
role: "assistant",
|
|
1035
|
+
content: state.content
|
|
1036
|
+
};
|
|
1037
|
+
if (state.reasoning !== void 0) message.reasoning_content = state.reasoning;
|
|
1038
|
+
if (state.tools.size > 0) message.tool_calls = [...state.tools].sort(([a], [b]) => a - b).map(([, t]) => ({
|
|
1039
|
+
id: t.id,
|
|
1040
|
+
type: "function",
|
|
1041
|
+
function: {
|
|
1042
|
+
name: t.name,
|
|
1043
|
+
arguments: t.arguments
|
|
1044
|
+
}
|
|
1045
|
+
}));
|
|
1046
|
+
return {
|
|
1047
|
+
index,
|
|
1048
|
+
message,
|
|
1049
|
+
finish_reason: state.finish
|
|
1050
|
+
};
|
|
1051
|
+
})
|
|
1052
|
+
};
|
|
1053
|
+
if (usage !== void 0) out.usage = usage;
|
|
1054
|
+
return out;
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
810
1058
|
function createStreamTransformer$1(ctx) {
|
|
811
1059
|
const choices = /* @__PURE__ */ new Map();
|
|
812
1060
|
/** The latest chunk's envelope (id, model, ...), reused for tail chunks. */
|
|
813
1061
|
let envelope = { object: "chat.completion.chunk" };
|
|
1062
|
+
const assembler = ctx.observe === void 0 ? void 0 : createChatAssembler();
|
|
814
1063
|
const choiceState = (index) => {
|
|
815
1064
|
let state = choices.get(index);
|
|
816
1065
|
if (state === void 0) {
|
|
@@ -889,6 +1138,7 @@ function createStreamTransformer$1(ctx) {
|
|
|
889
1138
|
return block.raw;
|
|
890
1139
|
}
|
|
891
1140
|
if (!isObj$1(payload) || !Array.isArray(payload.choices)) return block.raw;
|
|
1141
|
+
assembler?.add(payload);
|
|
892
1142
|
const { choices: _choices, ...rest } = payload;
|
|
893
1143
|
envelope = rest;
|
|
894
1144
|
let changed = false;
|
|
@@ -920,7 +1170,20 @@ function createStreamTransformer$1(ctx) {
|
|
|
920
1170
|
choices: rewritten
|
|
921
1171
|
}) : block.raw);
|
|
922
1172
|
};
|
|
923
|
-
|
|
1173
|
+
const framer = createSseFramer(handle, () => flushAll(void 0));
|
|
1174
|
+
if (assembler === void 0) return framer;
|
|
1175
|
+
const observe = ctx.observe;
|
|
1176
|
+
return {
|
|
1177
|
+
push: (chunk) => framer.push(chunk),
|
|
1178
|
+
flush() {
|
|
1179
|
+
const out = framer.flush();
|
|
1180
|
+
const message = assembler.take();
|
|
1181
|
+
if (message !== void 0) try {
|
|
1182
|
+
observe(message);
|
|
1183
|
+
} catch {}
|
|
1184
|
+
return out;
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
924
1187
|
}
|
|
925
1188
|
const openaiChatWalker = {
|
|
926
1189
|
async request(body, ctx) {
|
|
@@ -1165,6 +1428,8 @@ function restoreResponse(body, ctx) {
|
|
|
1165
1428
|
}
|
|
1166
1429
|
function createStreamTransformer(ctx) {
|
|
1167
1430
|
const streams = /* @__PURE__ */ new Map();
|
|
1431
|
+
/** Raw (pre-restore) terminal `response` object, kept only for `ctx.observe`. */
|
|
1432
|
+
let final;
|
|
1168
1433
|
const open = (key, factory) => {
|
|
1169
1434
|
let state = streams.get(key);
|
|
1170
1435
|
if (state === void 0) {
|
|
@@ -1256,10 +1521,16 @@ function createStreamTransformer(ctx) {
|
|
|
1256
1521
|
item: restoreItem(payload.item, ctx)
|
|
1257
1522
|
}) : block.raw;
|
|
1258
1523
|
case "response.completed":
|
|
1259
|
-
case "response.incomplete":
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1524
|
+
case "response.incomplete":
|
|
1525
|
+
if (!isObj(payload.response)) return block.raw;
|
|
1526
|
+
if (ctx.observe !== void 0) final = payload.response;
|
|
1527
|
+
return formatSseEvent(block.event, {
|
|
1528
|
+
...payload,
|
|
1529
|
+
response: restoreResponse(payload.response, ctx)
|
|
1530
|
+
});
|
|
1531
|
+
case "response.failed":
|
|
1532
|
+
if (ctx.observe !== void 0 && isObj(payload.response)) final = payload.response;
|
|
1533
|
+
return block.raw;
|
|
1263
1534
|
default: return block.raw;
|
|
1264
1535
|
}
|
|
1265
1536
|
};
|
|
@@ -1279,7 +1550,21 @@ function createStreamTransformer(ctx) {
|
|
|
1279
1550
|
streams.clear();
|
|
1280
1551
|
return out;
|
|
1281
1552
|
};
|
|
1282
|
-
|
|
1553
|
+
const framer = createSseFramer(handle, finish);
|
|
1554
|
+
const observe = ctx.observe;
|
|
1555
|
+
if (observe === void 0) return framer;
|
|
1556
|
+
return {
|
|
1557
|
+
push: (chunk) => framer.push(chunk),
|
|
1558
|
+
flush() {
|
|
1559
|
+
const out = framer.flush();
|
|
1560
|
+
const response = final;
|
|
1561
|
+
final = void 0;
|
|
1562
|
+
if (response !== void 0) try {
|
|
1563
|
+
observe(response);
|
|
1564
|
+
} catch {}
|
|
1565
|
+
return out;
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1283
1568
|
}
|
|
1284
1569
|
const openaiResponsesWalker = {
|
|
1285
1570
|
async request(body, ctx) {
|
|
@@ -1315,21 +1600,25 @@ const openaiResponsesWalker = {
|
|
|
1315
1600
|
*/
|
|
1316
1601
|
const WIRE_ROUTES = [
|
|
1317
1602
|
{
|
|
1603
|
+
name: "anthropic.count_tokens",
|
|
1318
1604
|
suffix: "/messages/count_tokens",
|
|
1319
1605
|
wire: anthropicWalker,
|
|
1320
1606
|
rewriteResponse: false
|
|
1321
1607
|
},
|
|
1322
1608
|
{
|
|
1609
|
+
name: "anthropic.messages",
|
|
1323
1610
|
suffix: "/messages",
|
|
1324
1611
|
wire: anthropicWalker,
|
|
1325
1612
|
rewriteResponse: true
|
|
1326
1613
|
},
|
|
1327
1614
|
{
|
|
1615
|
+
name: "openai.chat",
|
|
1328
1616
|
suffix: "/chat/completions",
|
|
1329
1617
|
wire: openaiChatWalker,
|
|
1330
1618
|
rewriteResponse: true
|
|
1331
1619
|
},
|
|
1332
1620
|
{
|
|
1621
|
+
name: "openai.responses",
|
|
1333
1622
|
suffix: "/responses",
|
|
1334
1623
|
wire: openaiResponsesWalker,
|
|
1335
1624
|
rewriteResponse: true
|
|
@@ -1350,7 +1639,8 @@ function createSessionState() {
|
|
|
1350
1639
|
return {
|
|
1351
1640
|
facts: createSessionFacts(),
|
|
1352
1641
|
signatures: /* @__PURE__ */ new Map(),
|
|
1353
|
-
instances: /* @__PURE__ */ new Map()
|
|
1642
|
+
instances: /* @__PURE__ */ new Map(),
|
|
1643
|
+
traced: /* @__PURE__ */ new Set()
|
|
1354
1644
|
};
|
|
1355
1645
|
}
|
|
1356
1646
|
/**
|
|
@@ -1380,6 +1670,65 @@ async function disposeInstances(state, log) {
|
|
|
1380
1670
|
}
|
|
1381
1671
|
}
|
|
1382
1672
|
}
|
|
1673
|
+
// src/trace.ts
|
|
1674
|
+
const DEFAULT_TRACE_MAX_CHARS = 262144;
|
|
1675
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1676
|
+
/** Top-level body keys whose array elements are traced one by one: the conversation history the harness resends each round. */
|
|
1677
|
+
const PER_ELEMENT_KEYS = {
|
|
1678
|
+
messages: true,
|
|
1679
|
+
input: true
|
|
1680
|
+
};
|
|
1681
|
+
/**
|
|
1682
|
+
* One JSON line (no trailing newline). When the JSON of `content` is longer than `maxChars`,
|
|
1683
|
+
* `content` becomes that JSON text cut to `maxChars` and `truncated` is set.
|
|
1684
|
+
*/
|
|
1685
|
+
function serializeTraceEvent(event, maxChars = DEFAULT_TRACE_MAX_CHARS) {
|
|
1686
|
+
const content = JSON.stringify(event.content);
|
|
1687
|
+
if (content === void 0 || content.length <= maxChars) return JSON.stringify(event);
|
|
1688
|
+
return JSON.stringify({
|
|
1689
|
+
...event,
|
|
1690
|
+
content: content.slice(0, maxChars),
|
|
1691
|
+
truncated: true
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
/** Appends each event to `<dir>/<session>.jsonl`. The files hold whatever leaked, so they are owner-only. */
|
|
1695
|
+
function createFileTraceSink(options) {
|
|
1696
|
+
const maxChars = options.maxChars ?? 262144;
|
|
1697
|
+
let created = false;
|
|
1698
|
+
return { record(event) {
|
|
1699
|
+
if (!created) {
|
|
1700
|
+
mkdirSync(options.dir, {
|
|
1701
|
+
recursive: true,
|
|
1702
|
+
mode: 448
|
|
1703
|
+
});
|
|
1704
|
+
created = true;
|
|
1705
|
+
}
|
|
1706
|
+
const file = join(options.dir, `${safeSessionId(event.sessionId)}.jsonl`);
|
|
1707
|
+
appendFileSync(file, `${serializeTraceEvent(event, maxChars)}\n`, { mode: 384 });
|
|
1708
|
+
} };
|
|
1709
|
+
}
|
|
1710
|
+
/** The id itself when it is a safe file name, otherwise a stable hash of it. */
|
|
1711
|
+
function safeSessionId(id) {
|
|
1712
|
+
if (SAFE_SESSION_ID.test(id)) return id;
|
|
1713
|
+
return `sha256-${createHash("sha256").update(id).digest("hex").slice(0, 32)}`;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* The request entries not yet traced in this session, keyed by JSON path; adds their hashes to `traced`.
|
|
1717
|
+
* `messages`/`input` arrays count per element, every other top-level key as a whole.
|
|
1718
|
+
*/
|
|
1719
|
+
function requestDelta(body, traced) {
|
|
1720
|
+
const delta = {};
|
|
1721
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return delta;
|
|
1722
|
+
const add = (key, path, value) => {
|
|
1723
|
+
const hash = createHash("sha256").update(`${key}\u0000${JSON.stringify(value)}`).digest("hex");
|
|
1724
|
+
if (traced.has(hash)) return;
|
|
1725
|
+
traced.add(hash);
|
|
1726
|
+
delta[path] = value;
|
|
1727
|
+
};
|
|
1728
|
+
for (const [key, value] of Object.entries(body)) if (Object.hasOwn(PER_ELEMENT_KEYS, key) && Array.isArray(value)) value.forEach((element, i) => add(key, `${key}[${i}]`, element));
|
|
1729
|
+
else add(key, key, value);
|
|
1730
|
+
return delta;
|
|
1731
|
+
}
|
|
1383
1732
|
// src/server.ts
|
|
1384
1733
|
const UPSTREAM_HEADER = "x-skaile-transform-upstream";
|
|
1385
1734
|
const SESSION_HEADER = "x-skaile-transform-session";
|
|
@@ -1415,6 +1764,8 @@ const RESPONSE_SKIP = {
|
|
|
1415
1764
|
"content-encoding": true,
|
|
1416
1765
|
"set-cookie": true
|
|
1417
1766
|
};
|
|
1767
|
+
/** Upstream error bodies in the trace are cut to this many characters. */
|
|
1768
|
+
const ERROR_TRACE_CHARS = 4096;
|
|
1418
1769
|
async function createTransformProxy(options) {
|
|
1419
1770
|
const log = options.log ?? silentLogger;
|
|
1420
1771
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -1433,6 +1784,28 @@ async function createTransformProxy(options) {
|
|
|
1433
1784
|
sessions.set(id, created);
|
|
1434
1785
|
return created;
|
|
1435
1786
|
}
|
|
1787
|
+
/** A throwing sink is logged and otherwise ignored: the trace must never change what the client gets. */
|
|
1788
|
+
function recordTrace(event) {
|
|
1789
|
+
if (options.trace === void 0) return;
|
|
1790
|
+
try {
|
|
1791
|
+
options.trace.record(event);
|
|
1792
|
+
} catch (err) {
|
|
1793
|
+
log.error(`transform proxy: trace sink failed: ${errorMessage(err)}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
/** Records this request's single response or error line; a no-op without a trace. */
|
|
1797
|
+
function recordReply(trace, phase, status, content) {
|
|
1798
|
+
if (trace === void 0) return;
|
|
1799
|
+
recordTrace({
|
|
1800
|
+
...trace.base,
|
|
1801
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1802
|
+
phase,
|
|
1803
|
+
status,
|
|
1804
|
+
durationMs: Date.now() - trace.startedAt,
|
|
1805
|
+
content,
|
|
1806
|
+
leaks: []
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1436
1809
|
async function buildInstance(state, sessionId, pipelineId) {
|
|
1437
1810
|
const stages = [];
|
|
1438
1811
|
const ids = pipelines[pipelineId] ?? [];
|
|
@@ -1481,7 +1854,8 @@ async function createTransformProxy(options) {
|
|
|
1481
1854
|
const method = req.method ?? "GET";
|
|
1482
1855
|
const classified = classify(method, url.pathname);
|
|
1483
1856
|
if (classified === void 0) return sendError(res, 404, "unsupported_route", `${method} ${url.pathname} is not proxied`);
|
|
1484
|
-
const
|
|
1857
|
+
const forwardPath = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}`;
|
|
1858
|
+
const forwardTo = `${forwardPath}${url.search}`;
|
|
1485
1859
|
const headers = forwardRequestHeaders(req.headers);
|
|
1486
1860
|
if (classified.kind === "passthrough") return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, void 0), void 0);
|
|
1487
1861
|
const { route } = classified;
|
|
@@ -1512,12 +1886,36 @@ async function createTransformProxy(options) {
|
|
|
1512
1886
|
if (err instanceof UnsupportedContentError) return sendError(res, 400, "unsupported_content", err.message);
|
|
1513
1887
|
return sendError(res, 502, "transform_failed", errorMessage(err));
|
|
1514
1888
|
}
|
|
1515
|
-
|
|
1889
|
+
const leaks = findLeaks(rewritten, knownOriginals(state.facts.facts, instance));
|
|
1890
|
+
if (leaks.length > 0) log.warn("transform proxy: known original sent upstream", {
|
|
1891
|
+
sessionId,
|
|
1892
|
+
route: route.name,
|
|
1893
|
+
leaks
|
|
1894
|
+
});
|
|
1895
|
+
const trace = options.trace === void 0 ? void 0 : {
|
|
1896
|
+
startedAt: Date.now(),
|
|
1897
|
+
base: {
|
|
1898
|
+
requestId: randomUUID(),
|
|
1899
|
+
sessionId,
|
|
1900
|
+
pipeline: pipelineId,
|
|
1901
|
+
route: route.name,
|
|
1902
|
+
upstream: forwardPath,
|
|
1903
|
+
...modelOf(rewritten)
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
if (trace !== void 0) recordTrace({
|
|
1907
|
+
...trace.base,
|
|
1908
|
+
at: new Date(trace.startedAt).toISOString(),
|
|
1909
|
+
phase: "request",
|
|
1910
|
+
content: requestDelta(rewritten, state.traced),
|
|
1911
|
+
leaks
|
|
1912
|
+
});
|
|
1913
|
+
return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten), trace), route.rewriteResponse ? {
|
|
1516
1914
|
wire: route.wire,
|
|
1517
1915
|
ctx
|
|
1518
|
-
} : void 0);
|
|
1916
|
+
} : void 0, trace);
|
|
1519
1917
|
}
|
|
1520
|
-
async function fetchUpstream(res, target, method, headers, body) {
|
|
1918
|
+
async function fetchUpstream(res, target, method, headers, body, trace) {
|
|
1521
1919
|
try {
|
|
1522
1920
|
return await fetch(target, {
|
|
1523
1921
|
method,
|
|
@@ -1526,21 +1924,61 @@ async function createTransformProxy(options) {
|
|
|
1526
1924
|
redirect: "manual"
|
|
1527
1925
|
});
|
|
1528
1926
|
} catch (err) {
|
|
1927
|
+
recordReply(trace, "error", 502, errorMessage(err));
|
|
1529
1928
|
sendError(res, 502, "upstream_unreachable", errorMessage(err));
|
|
1530
1929
|
return;
|
|
1531
1930
|
}
|
|
1532
1931
|
}
|
|
1533
|
-
/**
|
|
1534
|
-
|
|
1932
|
+
/**
|
|
1933
|
+
* Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. With `trace`
|
|
1934
|
+
* (wire routes only), the reply before restore is recorded as exactly one response or error line.
|
|
1935
|
+
*/
|
|
1936
|
+
async function relayUpstream(res, upstreamRes, rewrite, trace) {
|
|
1535
1937
|
if (upstreamRes === void 0) return;
|
|
1536
1938
|
const contentType = (upstreamRes.headers.get("content-type") ?? "").toLowerCase();
|
|
1537
1939
|
const headers = relayHeaders(upstreamRes.headers);
|
|
1538
|
-
if (
|
|
1539
|
-
|
|
1540
|
-
|
|
1940
|
+
if (trace !== void 0 && !upstreamRes.ok) {
|
|
1941
|
+
const bytes = Buffer.from(await upstreamRes.arrayBuffer());
|
|
1942
|
+
recordReply(trace, "error", upstreamRes.status, bytes.toString("utf8").slice(0, ERROR_TRACE_CHARS));
|
|
1943
|
+
res.writeHead(upstreamRes.status, headers);
|
|
1944
|
+
res.end(bytes);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) {
|
|
1948
|
+
if (trace === void 0) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
|
|
1949
|
+
let observed = false;
|
|
1950
|
+
const observe = (message) => {
|
|
1951
|
+
observed = true;
|
|
1952
|
+
recordReply(trace, "response", upstreamRes.status, message);
|
|
1953
|
+
};
|
|
1954
|
+
await pipeStream(res, upstreamRes, headers, rewrite.wire.stream({
|
|
1955
|
+
...rewrite.ctx,
|
|
1956
|
+
observe
|
|
1957
|
+
}), log);
|
|
1958
|
+
if (!observed) recordReply(trace, "response", upstreamRes.status, void 0);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (upstreamRes.ok && contentType.includes("application/json") && (rewrite !== void 0 || trace !== void 0)) {
|
|
1962
|
+
const bytes = Buffer.from(await upstreamRes.arrayBuffer());
|
|
1963
|
+
let body;
|
|
1964
|
+
try {
|
|
1965
|
+
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
recordReply(trace, "error", 502, errorMessage(err));
|
|
1968
|
+
if (rewrite !== void 0) return sendError(res, 502, "transform_failed", errorMessage(err));
|
|
1969
|
+
res.writeHead(upstreamRes.status, headers);
|
|
1970
|
+
res.end(bytes);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
recordReply(trace, "response", upstreamRes.status, body);
|
|
1974
|
+
if (rewrite === void 0) {
|
|
1975
|
+
res.writeHead(upstreamRes.status, headers);
|
|
1976
|
+
res.end(bytes);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1541
1979
|
let restored;
|
|
1542
1980
|
try {
|
|
1543
|
-
restored = JSON.stringify(rewrite.wire.response(
|
|
1981
|
+
restored = JSON.stringify(rewrite.wire.response(body, rewrite.ctx));
|
|
1544
1982
|
} catch (err) {
|
|
1545
1983
|
return sendError(res, 502, "transform_failed", errorMessage(err));
|
|
1546
1984
|
}
|
|
@@ -1548,8 +1986,10 @@ async function createTransformProxy(options) {
|
|
|
1548
1986
|
res.end(restored);
|
|
1549
1987
|
return;
|
|
1550
1988
|
}
|
|
1989
|
+
const bytes = Buffer.from(await upstreamRes.arrayBuffer());
|
|
1990
|
+
recordReply(trace, "response", upstreamRes.status, void 0);
|
|
1551
1991
|
res.writeHead(upstreamRes.status, headers);
|
|
1552
|
-
res.end(
|
|
1992
|
+
res.end(bytes);
|
|
1553
1993
|
}
|
|
1554
1994
|
const server = createServer((req, res) => {
|
|
1555
1995
|
handle(req, res).catch((err) => {
|
|
@@ -1613,6 +2053,12 @@ function validatePipelines(options) {
|
|
|
1613
2053
|
for (const [pipelineId, ids] of Object.entries(options.pipelines)) for (const id of ids) if (!Object.hasOwn(options.transforms, id)) throw new Error(`pipeline "${pipelineId}" names unknown transform "${id}"`);
|
|
1614
2054
|
if (options.defaultPipeline !== void 0 && !Object.hasOwn(options.pipelines, options.defaultPipeline)) throw new Error(`defaultPipeline "${options.defaultPipeline}" is not a configured pipeline`);
|
|
1615
2055
|
}
|
|
2056
|
+
/** `{ model }` when the request body names its model as a string, otherwise `{}`. */
|
|
2057
|
+
function modelOf(body) {
|
|
2058
|
+
if (typeof body !== "object" || body === null) return {};
|
|
2059
|
+
const model = body.model;
|
|
2060
|
+
return typeof model === "string" ? { model } : {};
|
|
2061
|
+
}
|
|
1616
2062
|
function originOf(value) {
|
|
1617
2063
|
const url = parseHttpUrl(value);
|
|
1618
2064
|
if (url === void 0) throw new Error(`allowUpstreams entry "${value}" is not an http(s) URL`);
|
|
@@ -1748,7 +2194,12 @@ const TOP_LEVEL_KEYS = {
|
|
|
1748
2194
|
transforms: true,
|
|
1749
2195
|
pipelines: true,
|
|
1750
2196
|
defaultPipeline: true,
|
|
1751
|
-
allowUpstreams: true
|
|
2197
|
+
allowUpstreams: true,
|
|
2198
|
+
trace: true
|
|
2199
|
+
};
|
|
2200
|
+
const TRACE_KEYS = {
|
|
2201
|
+
dir: true,
|
|
2202
|
+
maxChars: true
|
|
1752
2203
|
};
|
|
1753
2204
|
/** Reads and validates a YAML config file. Unknown keys are rejected so that typos fail loudly. */
|
|
1754
2205
|
function loadProxyConfig(path) {
|
|
@@ -1768,7 +2219,8 @@ function parseProxyConfig(source, baseDir) {
|
|
|
1768
2219
|
transforms: parseTransforms(source.transforms),
|
|
1769
2220
|
pipelines: parsePipelines(source.pipelines),
|
|
1770
2221
|
...source.defaultPipeline === void 0 ? {} : { defaultPipeline: requireString(source.defaultPipeline, "defaultPipeline") },
|
|
1771
|
-
...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") }
|
|
2222
|
+
...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") },
|
|
2223
|
+
...source.trace === void 0 ? {} : { trace: parseTrace(source.trace, baseDir) }
|
|
1772
2224
|
};
|
|
1773
2225
|
}
|
|
1774
2226
|
function parseTransforms(value) {
|
|
@@ -1792,6 +2244,17 @@ function parsePipelines(value) {
|
|
|
1792
2244
|
for (const [id, stages] of Object.entries(value)) out[id] = requireStringList(stages, `pipelines.${id}`);
|
|
1793
2245
|
return out;
|
|
1794
2246
|
}
|
|
2247
|
+
function parseTrace(value, baseDir) {
|
|
2248
|
+
if (!isRecord(value)) throw new Error("trace must be a mapping with a dir");
|
|
2249
|
+
for (const key of Object.keys(value)) if (!Object.hasOwn(TRACE_KEYS, key)) throw new Error(`unknown key "trace.${key}"`);
|
|
2250
|
+
const dir = resolve(baseDir, requireString(value.dir, "trace.dir"));
|
|
2251
|
+
if (value.maxChars === void 0) return { dir };
|
|
2252
|
+
if (typeof value.maxChars !== "number" || !Number.isInteger(value.maxChars) || value.maxChars <= 0) throw new Error("trace.maxChars must be a positive integer");
|
|
2253
|
+
return {
|
|
2254
|
+
dir,
|
|
2255
|
+
maxChars: value.maxChars
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
1795
2258
|
function requireString(value, field) {
|
|
1796
2259
|
if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
|
|
1797
2260
|
return value;
|
|
@@ -1803,4 +2266,4 @@ function requireStringList(value, field) {
|
|
|
1803
2266
|
function isRecord(value) {
|
|
1804
2267
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1805
2268
|
}
|
|
1806
|
-
export {
|
|
2269
|
+
export { silentLogger as S, anthropicWalker as _, parseProxyConfig as a, composeTransforms as b, SESSION_HEADER as c, createTransformProxy as d, DEFAULT_TRACE_MAX_CHARS as f, openaiChatWalker as g, openaiResponsesWalker as h, loadProxyConfig as i, TOKEN_HEADER as l, serializeTraceEvent as m, configCandidates as n, resolveProxyConfig as o, createFileTraceSink as p, findProxyConfig as r, PIPELINE_HEADER as s, CONFIG_FILE_NAME as t, UPSTREAM_HEADER as u, UnsupportedContentError as v, errorMessage as x, createSessionFacts as y };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as
|
|
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,
|
|
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";
|
package/dist/omp/index.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
518
|
-
|
|
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;
|
|
@@ -27,6 +27,14 @@ interface Transform {
|
|
|
27
27
|
dispose?(): void | Promise<void>;
|
|
28
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
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
|
+
}>;
|
|
30
38
|
}
|
|
31
39
|
interface SessionFact {
|
|
32
40
|
readonly kind: string;
|
|
@@ -58,6 +66,51 @@ interface TransformModule {
|
|
|
58
66
|
readonly log: Logger;
|
|
59
67
|
}): Promise<TransformFactory>;
|
|
60
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
|
+
}
|
|
61
114
|
/** A configured transform: a module (object, npm specifier or relative path) and its config. */
|
|
62
115
|
interface TransformSpec {
|
|
63
116
|
readonly module: string | TransformModule;
|
|
@@ -79,6 +132,8 @@ interface TransformProxyOptions {
|
|
|
79
132
|
/** Origins. Absent = any https origin plus loopback http. */
|
|
80
133
|
allowUpstreams?: readonly string[];
|
|
81
134
|
log?: Logger;
|
|
135
|
+
/** Records what goes upstream after the pipeline and what comes back before restore. Absent = no trace. */
|
|
136
|
+
trace?: TraceSink;
|
|
82
137
|
}
|
|
83
138
|
interface TransformProxy {
|
|
84
139
|
readonly url: string;
|
|
@@ -101,4 +156,4 @@ declare const SESSION_HEADER = "x-skaile-transform-session";
|
|
|
101
156
|
declare const PIPELINE_HEADER = "x-skaile-transform-pipeline";
|
|
102
157
|
declare const TOKEN_HEADER = "x-skaile-transform-token";
|
|
103
158
|
declare function createTransformProxy(options: TransformProxyOptions): Promise<TransformProxy>;
|
|
104
|
-
export {
|
|
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
|
+
"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",
|