@skaile/transform-proxy 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3
4
+
5
+ - omp extension: without `SKAILE_TRANSFORM_PROXY_URL`, `/anon on` starts the proxy as a child process under the system Node (`SKAILE_TRANSFORM_PROXY_NODE`, else `node` from `PATH`; Node ≥ 22) instead of inside omp. omp is a compiled Bun executable whose resolver cannot load transform modules with native or ML dependencies from `node_modules`: under omp 18.14.2, `/anon on` failed with `transform "anon": cannot load module @skaile/forge-anon/proxy: Cannot find package 'ibantools'`. The child runs this package's CLI with the found config, `--port 0 --host 127.0.0.1 --exit-with-stdin` and a random token, which every routed provider sends as `x-skaile-transform-token`. A child that exits before `listening <url>` or stays silent for 60 s fails `/anon on` with its last stderr lines (token redacted). The child exits with omp, does not keep omp's event loop alive, and is replaced on the next turn when it dies. A rotated-out proxy session is no longer dropped: the child keeps its state in memory until it exits, and no request carries that id again.
6
+ - CLI: `--exit-with-stdin` stops the proxy when its stdin closes, so a parent that spawns it with a stdin pipe takes it down on exit, SIGKILL included.
7
+
8
+ ## 0.2.2
9
+
10
+ - Fail closed on malformed request bodies: the Anthropic, OpenAI chat and OpenAI responses walkers refuse a JSON body whose shape they cannot classify (a root that is not an object; a `system`, `messages`, `input`, `instructions`, message `content`, text, thinking, tool-call `arguments` or tool-result slot of an unexpected type) with 400 `unsupported_content` instead of forwarding it untransformed. Unknown part, block and item types still pass through.
11
+ - Upstream response `content-type` is matched case-insensitively, so `Text/Event-Stream` and `Application/JSON; charset=utf-8` replies are restored instead of relayed with placeholders.
12
+ - omp extension: a failed rebind of the active model is an activation failure. `/anon on` rolls the routes back, persists nothing and stays `anon off`; in required mode, or when a later turn's rebind fails, the footer shows `anon error`. While anon is on or in error, a `before_provider_request` handler replaces the payload of any request whose model bypasses the proxy with an empty object (omp has no hook that cancels a provider request).
13
+ - omp extension: the proxy session follows the root omp session. On `session_switch` (`/new`, `/resume`, `/fork`) and on a root `session_start` with a new id, every provider is re-routed with the new `x-skaile-transform-session`, and the in-process proxy drops the old session; headless `print` sessions (subagents) keep the parent's id.
14
+
3
15
  ## 0.2.1
4
16
 
5
17
  - Anthropic walker: the pipeline `instruction` is appended after the host's system prompt (last text block, or after a blank line for a string `system`) instead of being prepended. Claude Code gateways such as TeamClaude answer 429 `rate_limit_error` for Sonnet when the system prompt does not start with the Claude Code blocks; with the instruction first, omp and Claude Code requests for `claude-sonnet-4-5` through the proxy were refused.
package/README.md CHANGED
@@ -31,6 +31,7 @@ Embeddings are refused on purpose: a vector of pseudonymized input cannot be res
31
31
  ## Refusals
32
32
 
33
33
  - Attachments (image, document, file parts) are refused with 400 `unsupported_content`. Nothing is sent upstream.
34
+ - A body that is not JSON is refused with 400 `invalid_json`. A JSON body whose shape the walker cannot classify is refused with 400 `unsupported_content`: a root that is not an object, or a `system`, `messages`, `input`, `instructions`, message `content`, text, thinking, tool-call `arguments` or tool-result slot of an unexpected type. Unknown part, block and item types still pass through untouched.
34
35
  - Unknown routes, pipelines and upstreams are refused before any body is read or transform is built.
35
36
  - A transform that throws refuses the request with 502 `transform_failed`. Nothing is sent upstream.
36
37
  - Bedrock (SigV4 covers the body) and Vertex are not routed. Refuse them in the caller.
@@ -59,12 +60,13 @@ Keys are exactly the option fields. An unknown top-level key is an error. Module
59
60
  ## CLI
60
61
 
61
62
  ```sh
62
- skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>]
63
+ skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]
63
64
  ```
64
65
 
65
66
  - The token comes from `--token` or `SKAILE_TRANSFORM_PROXY_TOKEN`.
66
67
  - Without `--config`, the proxy reads `./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`.
67
68
  - On start it prints `listening <url>`. It stops on SIGINT or SIGTERM.
69
+ - `--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.
68
70
 
69
71
  ## Library
70
72
 
@@ -91,13 +93,16 @@ The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform
91
93
  omp plugin install @skaile/transform-proxy
92
94
  ```
93
95
 
94
- It adds the command `/anon on|off|status` and the footer status `anon on` / `anon off`.
96
+ It adds the command `/anon on|off|status` and the footer status `anon on`, `anon off` or `anon error`. Unless `SKAILE_TRANSFORM_PROXY_URL` names a running proxy, it needs Node ≥ 22 on `PATH` (or its path in `SKAILE_TRANSFORM_PROXY_NODE`).
95
97
 
96
98
  - `/anon on` routes every provider of omp's model registry through the proxy: the provider's `baseUrl` becomes the proxy URL, and the original base URL travels in `x-skaile-transform-upstream`, next to `x-skaile-transform-session` (the omp session id) and the token. The provider's own headers and auth stay in place and are relayed.
97
99
  - With `SKAILE_TRANSFORM_PROXY_URL` (and `SKAILE_TRANSFORM_PROXY_TOKEN`) set, it uses that running proxy, and the proxy applies its `defaultPipeline`.
98
- - Otherwise it starts one proxy inside the omp process, from the config lookup of the CLI (`./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`). That config must set `defaultPipeline`. Without a config, `/anon on` refuses and says where to put one.
100
+ - Otherwise it starts one proxy per omp process as a child process: the system Node (`SKAILE_TRANSFORM_PROXY_NODE`, else `node` from `PATH`) runs this package's CLI with the config lookup of the CLI (`./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`), `--port 0 --host 127.0.0.1 --exit-with-stdin` and a random token in `SKAILE_TRANSFORM_PROXY_TOKEN`, which every routed provider sends. That config must set `defaultPipeline`. Without a config, `/anon on` refuses and says where to put one. A child that exits before printing `listening <url>`, or stays silent for 60 s, fails `/anon on` with its last stderr lines.
101
+ - omp is a compiled Bun executable, and its resolver cannot load transform modules with native or ML dependencies (`onnxruntime-node`, `@huggingface/transformers`, `ibantools`) from `node_modules`; Node can. The child exits with omp: its stdin pipe closes when omp exits (SIGKILL included), and omp kills it on a normal exit. It does not keep omp's event loop alive. A child that dies later is replaced on the next turn.
99
102
  - Providers on a wire the proxy does not rewrite (Bedrock, Vertex, Gemini, agent bridges, providers without a base URL) are routed too, so the proxy refuses their requests: the setup fails closed. `/anon status` lists them as refused.
100
- - The session's active model is rebound to the re-routed one, so the next request already uses the new base URL.
103
+ - The session's active model is rebound to the re-routed one, so the next request already uses the new base URL. A rebind that fails is an activation failure: `/anon on` rolls the routes back, saves nothing and stays off. When anon is required, or a later turn's rebind fails while anon is on, the footer shows `anon error`.
104
+ - While anon is on or in error, a `before_provider_request` handler checks every model request: a request whose model does not target the proxy gets its payload replaced with an empty object, so no conversation text leaves, and an error notice names the model. omp has no hook that cancels a provider request, and a throwing handler is only logged, so this replacement is the block.
105
+ - 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.
101
106
  - `/anon off` restores the original base URLs and headers.
102
107
  - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, and the routed and refused providers.
103
108
  - The enabled flag is saved in `skaile-transform-proxy.json` in omp's agent dir (`~/.omp/agent`; `SKAILE_TRANSFORM_PROXY_OMP_STATE` overrides the path) and re-applied when a session starts.
package/dist/cli.d.mts CHANGED
@@ -12,6 +12,8 @@ export interface CliEnvironment {
12
12
  export interface CliRun {
13
13
  readonly code: number;
14
14
  readonly proxy?: TransformProxy;
15
+ /** `--exit-with-stdin`: the caller stops the proxy when its stdin closes (the parent process is gone). */
16
+ readonly exitWithStdin?: boolean;
15
17
  }
16
18
  /** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
17
19
  export declare function runCli(argv: readonly string[], io: CliEnvironment): Promise<CliRun>;
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { d as createTransformProxy, o as resolveProxyConfig, v as errorMessage } from "./config-Xdn_LYpZ.mjs";
2
+ import { d as createTransformProxy, o as resolveProxyConfig, v as errorMessage } from "./config-PKHxejC8.mjs";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { homedir } from "node:os";
@@ -7,7 +7,7 @@ import { parseArgs } from "node:util";
7
7
  // src/cli.ts
8
8
  /** Environment variable that supplies the client token when `--token` is absent. */
9
9
  const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
10
- const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>]";
10
+ const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]";
11
11
  /** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
12
12
  async function runCli(argv, io) {
13
13
  let values;
@@ -23,7 +23,8 @@ async function runCli(argv, io) {
23
23
  help: {
24
24
  type: "boolean",
25
25
  short: "h"
26
- }
26
+ },
27
+ "exit-with-stdin": { type: "boolean" }
27
28
  }
28
29
  }));
29
30
  } catch (err) {
@@ -67,7 +68,8 @@ async function runCli(argv, io) {
67
68
  io.stdout(`listening ${proxy.url}`);
68
69
  return {
69
70
  code: 0,
70
- proxy
71
+ proxy,
72
+ exitWithStdin: values["exit-with-stdin"] === true
71
73
  };
72
74
  } catch (err) {
73
75
  io.stderr(errorMessage(err));
@@ -103,7 +105,10 @@ if (isEntryPoint()) {
103
105
  if (run.proxy === void 0) process.exitCode = run.code;
104
106
  else {
105
107
  const proxy = run.proxy;
108
+ let stopping = false;
106
109
  const stop = () => {
110
+ if (stopping) return;
111
+ stopping = true;
107
112
  proxy.close().then(() => process.exit(0), (err) => {
108
113
  console.error(errorMessage(err));
109
114
  process.exit(1);
@@ -111,6 +116,11 @@ if (isEntryPoint()) {
111
116
  };
112
117
  process.once("SIGINT", stop);
113
118
  process.once("SIGTERM", stop);
119
+ if (run.exitWithStdin === true) {
120
+ process.stdin.once("end", stop);
121
+ process.stdin.once("close", stop);
122
+ process.stdin.resume();
123
+ }
114
124
  }
115
125
  }
116
126
  export { TOKEN_ENV, runCli };
@@ -315,24 +315,30 @@ const signatureKey$1 = (signature) => `anthropic:${signature}`;
315
315
  function refuse$1(block) {
316
316
  if (typeof block.type === "string" && REFUSED_BLOCKS[block.type] === true) throw new UnsupportedContentError(`Anthropic ${block.type} content cannot be transformed`);
317
317
  }
318
+ /** A slot the walker reads holds a value it cannot classify: refused, never forwarded untransformed. */
319
+ function malformed$2(slot) {
320
+ throw new UnsupportedContentError(`Anthropic ${slot} has an unexpected shape`);
321
+ }
318
322
  async function outbound$2(transform, text, leaf) {
319
323
  return text === "" ? text : transform.outbound(text, leaf);
320
324
  }
321
- /** A string, or an array of blocks whose `text` blocks are rewritten. */
322
- async function rewriteText(content, leaf, transform) {
325
+ /** A string, or an array of blocks whose `text` blocks are rewritten; anything else is refused. */
326
+ async function rewriteText(content, slot, leaf, transform) {
323
327
  if (typeof content === "string") return outbound$2(transform, content, leaf);
324
- if (!Array.isArray(content)) return content;
328
+ if (!Array.isArray(content)) return malformed$2(slot);
325
329
  const out = [];
326
330
  for (const block of content) {
327
- if (!isObj$2(block)) {
331
+ if (!isObj$2(block)) return malformed$2(`${slot} block`);
332
+ refuse$1(block);
333
+ if (block.type !== "text") {
328
334
  out.push(block);
329
335
  continue;
330
336
  }
331
- refuse$1(block);
332
- out.push(block.type === "text" && typeof block.text === "string" ? {
337
+ if (typeof block.text !== "string") return malformed$2(`${slot} text block`);
338
+ out.push({
333
339
  ...block,
334
340
  text: await outbound$2(transform, block.text, leaf)
335
- } : block);
341
+ });
336
342
  }
337
343
  return out;
338
344
  }
@@ -364,6 +370,7 @@ function restoreStrings$1(value, transform) {
364
370
  }
365
371
  /** A thinking block: swaps in the cached model text when its signature is known, else rewrites it. */
366
372
  async function rewriteThinking(block, ctx) {
373
+ if (typeof block.thinking !== "string") return malformed$2("thinking block");
367
374
  if (typeof block.signature === "string") {
368
375
  const cached = ctx.signatures.get(signatureKey$1(block.signature));
369
376
  if (cached !== void 0) return {
@@ -371,7 +378,6 @@ async function rewriteThinking(block, ctx) {
371
378
  thinking: cached
372
379
  };
373
380
  }
374
- if (typeof block.thinking !== "string") return block;
375
381
  return {
376
382
  ...block,
377
383
  thinking: await outbound$2(ctx.transform, block.thinking, { kind: "thinking" })
@@ -380,20 +386,18 @@ async function rewriteThinking(block, ctx) {
380
386
  async function rewriteContent$2(content, role, toolNames, ctx) {
381
387
  const textKind = role === "assistant" ? "assistant" : "user";
382
388
  if (typeof content === "string") return outbound$2(ctx.transform, content, { kind: textKind });
383
- if (!Array.isArray(content)) return content;
389
+ if (!Array.isArray(content)) return malformed$2("message content");
384
390
  const out = [];
385
391
  for (const block of content) {
386
- if (!isObj$2(block)) {
387
- out.push(block);
388
- continue;
389
- }
392
+ if (!isObj$2(block)) return malformed$2("message content block");
390
393
  refuse$1(block);
391
394
  switch (block.type) {
392
395
  case "text":
393
- out.push(typeof block.text === "string" ? {
396
+ if (typeof block.text !== "string") return malformed$2("text block");
397
+ out.push({
394
398
  ...block,
395
399
  text: await outbound$2(ctx.transform, block.text, { kind: textKind })
396
- } : block);
400
+ });
397
401
  break;
398
402
  case "thinking":
399
403
  out.push(await rewriteThinking(block, ctx));
@@ -410,10 +414,14 @@ async function rewriteContent$2(content, role, toolNames, ctx) {
410
414
  break;
411
415
  }
412
416
  case "tool_result": {
417
+ if (block.content === void 0) {
418
+ out.push(block);
419
+ break;
420
+ }
413
421
  const toolName = typeof block.tool_use_id === "string" ? toolNames.get(block.tool_use_id) : void 0;
414
422
  out.push({
415
423
  ...block,
416
- content: await rewriteText(block.content, {
424
+ content: await rewriteText(block.content, "tool_result content", {
417
425
  kind: "toolResult",
418
426
  toolName
419
427
  }, ctx.transform)
@@ -557,17 +565,21 @@ function createStreamTransformer$2(ctx) {
557
565
  }
558
566
  const anthropicWalker = {
559
567
  async request(body, ctx) {
560
- if (!isObj$2(body)) return body;
568
+ if (!isObj$2(body)) return malformed$2("request body");
561
569
  const out = { ...body };
562
- if (body.system !== void 0) out.system = await rewriteText(body.system, { kind: "system" }, ctx.transform);
570
+ if (body.system !== void 0) out.system = await rewriteText(body.system, "system", { kind: "system" }, ctx.transform);
563
571
  if (ctx.transform.instruction !== void 0) out.system = withInstruction(out.system, ctx.transform.instruction);
564
- if (Array.isArray(body.messages)) {
572
+ if (body.messages !== void 0) {
573
+ if (!Array.isArray(body.messages)) return malformed$2("messages");
565
574
  const toolNames = toolNamesById(body.messages);
566
575
  const messages = [];
567
- for (const message of body.messages) messages.push(isObj$2(message) ? {
568
- ...message,
569
- content: await rewriteContent$2(message.content, message.role, toolNames, ctx)
570
- } : message);
576
+ for (const message of body.messages) {
577
+ if (!isObj$2(message)) return malformed$2("message");
578
+ messages.push({
579
+ ...message,
580
+ content: await rewriteContent$2(message.content, message.role, toolNames, ctx)
581
+ });
582
+ }
571
583
  out.messages = messages;
572
584
  }
573
585
  return out;
@@ -615,21 +627,28 @@ const REASONING_FIELDS = ["reasoning_content", "reasoning"];
615
627
  async function outbound$1(transform, text, leaf) {
616
628
  return text === "" ? text : transform.outbound(text, leaf);
617
629
  }
618
- /** A string, or an array of parts whose `text` parts are rewritten. */
630
+ /** A slot the walker reads holds a value it cannot classify: refused, never forwarded untransformed. */
631
+ function malformed$1(slot) {
632
+ throw new UnsupportedContentError(`OpenAI chat ${slot} has an unexpected shape`);
633
+ }
634
+ /** A string, or an array of parts whose `text` parts are rewritten; `null`/absent carries no text; anything else is refused. */
619
635
  async function rewriteContent$1(content, leaf, transform) {
620
636
  if (typeof content === "string") return outbound$1(transform, content, leaf);
621
- if (!Array.isArray(content)) return content;
637
+ if (content === void 0 || content === null) return content;
638
+ if (!Array.isArray(content)) return malformed$1("message content");
622
639
  const out = [];
623
640
  for (const part of content) {
624
- if (!isObj$1(part)) {
641
+ if (!isObj$1(part)) return malformed$1("content part");
642
+ if (typeof part.type === "string" && REFUSED_PARTS$1[part.type] === true) throw new UnsupportedContentError(`OpenAI chat ${part.type} content cannot be transformed`);
643
+ if (part.type !== "text") {
625
644
  out.push(part);
626
645
  continue;
627
646
  }
628
- if (typeof part.type === "string" && REFUSED_PARTS$1[part.type] === true) throw new UnsupportedContentError(`OpenAI chat ${part.type} content cannot be transformed`);
629
- out.push(part.type === "text" && typeof part.text === "string" ? {
647
+ if (typeof part.text !== "string") return malformed$1("text part");
648
+ out.push({
630
649
  ...part,
631
650
  text: await outbound$1(transform, part.text, leaf)
632
- } : part);
651
+ });
633
652
  }
634
653
  return out;
635
654
  }
@@ -677,17 +696,20 @@ function restoreArguments$1(args, transform) {
677
696
  return JSON.stringify(restoreStrings(parsed, transform));
678
697
  }
679
698
  async function rewriteToolCalls(calls, transform, names) {
680
- if (!Array.isArray(calls)) return calls;
699
+ if (calls === void 0 || calls === null) return calls;
700
+ if (!Array.isArray(calls)) return malformed$1("tool_calls");
681
701
  const out = [];
682
702
  for (const call of calls) {
683
- if (!isObj$1(call) || !isObj$1(call.function)) {
703
+ if (!isObj$1(call)) return malformed$1("tool call");
704
+ if (call.type !== void 0 && call.type !== "function") {
684
705
  out.push(call);
685
706
  continue;
686
707
  }
687
708
  const fn = call.function;
709
+ if (!isObj$1(fn) || typeof fn.arguments !== "string") return malformed$1("function tool call");
688
710
  const toolName = typeof fn.name === "string" ? fn.name : void 0;
689
711
  if (typeof call.id === "string" && toolName !== void 0) names.set(call.id, toolName);
690
- out.push(typeof fn.arguments === "string" ? {
712
+ out.push({
691
713
  ...call,
692
714
  function: {
693
715
  ...fn,
@@ -696,7 +718,7 @@ async function rewriteToolCalls(calls, transform, names) {
696
718
  toolName
697
719
  }, transform)
698
720
  }
699
- } : call);
721
+ });
700
722
  }
701
723
  return out;
702
724
  }
@@ -714,6 +736,7 @@ async function rewriteMessage(message, transform, names) {
714
736
  for (const field of REASONING_FIELDS) {
715
737
  const value = message[field];
716
738
  if (typeof value === "string") out[field] = await outbound$1(transform, value, { kind: "thinking" });
739
+ else if (value !== void 0 && value !== null) return malformed$1(`assistant ${field}`);
717
740
  }
718
741
  out.content = await rewriteContent$1(message.content, { kind: "assistant" }, transform);
719
742
  out.tool_calls = await rewriteToolCalls(message.tool_calls, transform, names);
@@ -727,7 +750,15 @@ async function rewriteMessage(message, transform, names) {
727
750
  }, transform);
728
751
  break;
729
752
  }
730
- default: return message;
753
+ case "function": {
754
+ const toolName = typeof message.name === "string" ? message.name : void 0;
755
+ out.content = await rewriteContent$1(message.content, {
756
+ kind: "toolResult",
757
+ toolName
758
+ }, transform);
759
+ break;
760
+ }
761
+ default: return malformed$1("message role");
731
762
  }
732
763
  if (message.content === void 0) delete out.content;
733
764
  return out;
@@ -874,12 +905,16 @@ function createStreamTransformer$1(ctx) {
874
905
  }
875
906
  const openaiChatWalker = {
876
907
  async request(body, ctx) {
877
- if (!isObj$1(body)) return body;
908
+ if (!isObj$1(body)) return malformed$1("request body");
878
909
  const out = { ...body };
879
- if (Array.isArray(body.messages)) {
910
+ if (body.messages !== void 0) {
911
+ if (!Array.isArray(body.messages)) return malformed$1("messages");
880
912
  const names = /* @__PURE__ */ new Map();
881
913
  const messages = [];
882
- for (const message of body.messages) messages.push(isObj$1(message) ? await rewriteMessage(message, ctx.transform, names) : message);
914
+ for (const message of body.messages) {
915
+ if (!isObj$1(message)) return malformed$1("message");
916
+ messages.push(await rewriteMessage(message, ctx.transform, names));
917
+ }
883
918
  if (ctx.transform.instruction !== void 0) messages.unshift({
884
919
  role: "system",
885
920
  content: ctx.transform.instruction
@@ -937,21 +972,27 @@ function refuse(part) {
937
972
  async function outbound(transform, text, leaf) {
938
973
  return text === "" ? text : transform.outbound(text, leaf);
939
974
  }
940
- /** A string, or an array of parts whose `input_text`/`output_text` parts are rewritten. */
941
- async function rewriteContent(content, leaf, transform) {
975
+ /** A slot the walker reads holds a value it cannot classify: refused, never forwarded untransformed. */
976
+ function malformed(slot) {
977
+ throw new UnsupportedContentError(`OpenAI responses ${slot} has an unexpected shape`);
978
+ }
979
+ /** A string, or an array of parts whose `input_text`/`output_text` parts are rewritten; anything else is refused. */
980
+ async function rewriteContent(content, slot, leaf, transform) {
942
981
  if (typeof content === "string") return outbound(transform, content, leaf);
943
- if (!Array.isArray(content)) return content;
982
+ if (!Array.isArray(content)) return malformed(slot);
944
983
  const out = [];
945
984
  for (const part of content) {
946
- if (!isObj(part)) {
985
+ if (!isObj(part)) return malformed(`${slot} part`);
986
+ refuse(part);
987
+ if (part.type !== "input_text" && part.type !== "output_text") {
947
988
  out.push(part);
948
989
  continue;
949
990
  }
950
- refuse(part);
951
- out.push((part.type === "input_text" || part.type === "output_text") && typeof part.text === "string" ? {
991
+ if (typeof part.text !== "string") return malformed(`${slot} ${part.type} part`);
992
+ out.push({
952
993
  ...part,
953
994
  text: await outbound(transform, part.text, leaf)
954
- } : part);
995
+ });
955
996
  }
956
997
  return out;
957
998
  }
@@ -1002,17 +1043,14 @@ function cachedSummary(item, ctx) {
1002
1043
  return texts;
1003
1044
  }
1004
1045
  async function rewriteReasoning(item, ctx) {
1005
- if (!Array.isArray(item.summary)) return item;
1046
+ if (item.summary === void 0) return item;
1047
+ if (!Array.isArray(item.summary)) return malformed("reasoning summary");
1048
+ for (const part of item.summary) if (!isObj(part) || typeof part.text !== "string") return malformed("reasoning summary part");
1006
1049
  const cached = cachedSummary(item, ctx);
1007
1050
  const summary = [];
1008
- for (const [i, part] of item.summary.entries()) if (!isObj(part) || typeof part.text !== "string") summary.push(part);
1009
- else if (cached !== void 0) summary.push({
1051
+ for (const [i, part] of item.summary.entries()) summary.push({
1010
1052
  ...part,
1011
- text: cached[i]
1012
- });
1013
- else summary.push({
1014
- ...part,
1015
- text: await outbound(ctx.transform, part.text, { kind: "thinking" })
1053
+ text: cached !== void 0 ? cached[i] : await outbound(ctx.transform, part.text, { kind: "thinking" })
1016
1054
  });
1017
1055
  return {
1018
1056
  ...item,
@@ -1032,15 +1070,15 @@ function toolNamesByCallId(items) {
1032
1070
  async function rewriteItem(item, toolNames, ctx) {
1033
1071
  if (item.type === "message" || item.type === void 0 && item.role !== void 0) {
1034
1072
  const kind = roleKind(item.role);
1035
- if (kind === void 0) return item;
1073
+ if (kind === void 0) return malformed("message role");
1036
1074
  return {
1037
1075
  ...item,
1038
- content: await rewriteContent(item.content, { kind }, ctx.transform)
1076
+ content: await rewriteContent(item.content, "message content", { kind }, ctx.transform)
1039
1077
  };
1040
1078
  }
1041
1079
  switch (item.type) {
1042
1080
  case "function_call": {
1043
- if (typeof item.arguments !== "string") return item;
1081
+ if (typeof item.arguments !== "string") return malformed("function_call arguments");
1044
1082
  const toolName = typeof item.name === "string" ? item.name : void 0;
1045
1083
  return {
1046
1084
  ...item,
@@ -1054,7 +1092,7 @@ async function rewriteItem(item, toolNames, ctx) {
1054
1092
  const toolName = typeof item.call_id === "string" ? toolNames.get(item.call_id) : void 0;
1055
1093
  return {
1056
1094
  ...item,
1057
- output: await rewriteContent(item.output, {
1095
+ output: await rewriteContent(item.output, "function_call_output output", {
1058
1096
  kind: "toolResult",
1059
1097
  toolName
1060
1098
  }, ctx.transform)
@@ -1226,18 +1264,22 @@ function createStreamTransformer(ctx) {
1226
1264
  }
1227
1265
  const openaiResponsesWalker = {
1228
1266
  async request(body, ctx) {
1229
- if (!isObj(body)) return body;
1267
+ if (!isObj(body)) return malformed("request body");
1230
1268
  const out = { ...body };
1231
1269
  if (typeof body.instructions === "string") out.instructions = await outbound(ctx.transform, body.instructions, { kind: "system" });
1270
+ else if (body.instructions !== void 0 && body.instructions !== null) return malformed("instructions");
1232
1271
  const instruction = ctx.transform.instruction;
1233
1272
  if (instruction !== void 0) out.instructions = typeof out.instructions === "string" && out.instructions !== "" ? `${instruction}\n\n${out.instructions}` : instruction;
1234
1273
  if (typeof body.input === "string") out.input = await outbound(ctx.transform, body.input, { kind: "user" });
1235
1274
  else if (Array.isArray(body.input)) {
1236
1275
  const toolNames = toolNamesByCallId(body.input);
1237
1276
  const input = [];
1238
- for (const item of body.input) input.push(isObj(item) ? await rewriteItem(item, toolNames, ctx) : item);
1277
+ for (const item of body.input) {
1278
+ if (!isObj(item)) return malformed("input item");
1279
+ input.push(await rewriteItem(item, toolNames, ctx));
1280
+ }
1239
1281
  out.input = input;
1240
- }
1282
+ } else if (body.input !== void 0) return malformed("input");
1241
1283
  return out;
1242
1284
  },
1243
1285
  response(body, ctx) {
@@ -1465,7 +1507,7 @@ async function createTransformProxy(options) {
1465
1507
  /** Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. */
1466
1508
  async function relayUpstream(res, upstreamRes, rewrite) {
1467
1509
  if (upstreamRes === void 0) return;
1468
- const contentType = upstreamRes.headers.get("content-type") ?? "";
1510
+ const contentType = (upstreamRes.headers.get("content-type") ?? "").toLowerCase();
1469
1511
  const headers = relayHeaders(upstreamRes.headers);
1470
1512
  if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
1471
1513
  if (rewrite !== void 0 && upstreamRes.ok && contentType.includes("application/json")) {
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-Xdn_LYpZ.mjs";
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-PKHxejC8.mjs";
@@ -1,9 +1,11 @@
1
- import { a as TransformProxyOptions, i as TransformProxy } from "../server-C_eKyCTV.mjs";
1
+ import { Readable, Writable } from "node:stream";
2
2
  interface OmpModel {
3
3
  readonly provider: string;
4
4
  readonly id: string;
5
5
  readonly api: string;
6
6
  readonly baseUrl?: string;
7
+ /** The provider headers projected onto the model, the transform-proxy headers included. */
8
+ readonly headers?: Readonly<Record<string, string>>;
7
9
  }
8
10
  /** A runtime provider override: omp merges `headers` into the provider's existing headers. */
9
11
  interface OmpProviderOverride {
@@ -35,15 +37,22 @@ interface OmpContext {
35
37
  readonly sessionManager: {
36
38
  getSessionId(): string;
37
39
  };
38
- /** The session's active model: a snapshot that a provider override does not update. */
40
+ /**
41
+ * The session's active model: a snapshot that a provider override does not update. In
42
+ * `before_provider_request` it is the model of that request.
43
+ */
39
44
  readonly model: OmpModel | undefined;
45
+ /** False for headless sessions: subagents, print and RPC mode. */
46
+ readonly hasUI?: boolean;
47
+ /** `tui`, `rpc`, `json` or `print`; subagent sessions run as `print`. */
48
+ readonly mode?: string;
40
49
  }
41
50
  interface OmpAutocompleteItem {
42
51
  value: string;
43
52
  label: string;
44
53
  description?: string;
45
54
  }
46
- type OmpEventName = 'session_start' | 'before_agent_start' | 'turn_start';
55
+ type OmpEventName = 'session_start' | 'session_switch' | 'before_agent_start' | 'turn_start' | 'before_provider_request';
47
56
  /** Per-session actions of the extension API. */
48
57
  interface OmpSessionActions {
49
58
  /** Rebinds the session's active model (without persisting it as the default). */
@@ -55,15 +64,37 @@ interface OmpExtensionApi extends OmpSessionActions {
55
64
  getArgumentCompletions?: (argumentPrefix: string) => OmpAutocompleteItem[] | null;
56
65
  handler: (args: string, ctx: OmpContext) => Promise<void>;
57
66
  }): void;
58
- on(event: OmpEventName, handler: (event: unknown, ctx: OmpContext) => void | Promise<void>): void;
67
+ /** For `before_provider_request`, a non-undefined result replaces the request payload. */
68
+ on(event: OmpEventName, handler: (event: unknown, ctx: OmpContext) => unknown): void;
69
+ }
70
+ /** How the extension spawns its proxy child. */
71
+ interface ProxySpawnOptions {
72
+ readonly cwd: string;
73
+ readonly env: Readonly<Record<string, string | undefined>>;
74
+ readonly stdio: readonly ['pipe', 'pipe', 'pipe'];
75
+ }
76
+ /** The slice of a `node:child_process` `ChildProcess` the extension uses. */
77
+ interface ProxyChildProcess {
78
+ readonly pid?: number | undefined;
79
+ readonly stdin: Writable | null;
80
+ readonly stdout: Readable | null;
81
+ readonly stderr: Readable | null;
82
+ kill(signal?: NodeJS.Signals): boolean;
83
+ unref(): void;
84
+ on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
85
+ on(event: 'error', listener: (err: Error) => void): unknown;
59
86
  }
60
87
  interface AnonControllerDeps {
61
88
  readonly env: Readonly<Record<string, string | undefined>>;
62
89
  readonly home: string;
63
90
  /** Default: `$SKAILE_TRANSFORM_PROXY_OMP_STATE`, else `<omp agent dir>/skaile-transform-proxy.json`. */
64
91
  readonly statePath?: string;
65
- /** Default: `createTransformProxy`. */
66
- readonly startProxy?: (options: TransformProxyOptions) => Promise<TransformProxy>;
92
+ /** Spawns the proxy child. Default: `node:child_process` `spawn`. */
93
+ readonly spawnChild?: (command: string, args: readonly string[], options: ProxySpawnOptions) => ProxyChildProcess;
94
+ /** The CLI the child runs. Default: this package's `dist/cli.mjs`, next to the built extension's `dist/omp/`. */
95
+ readonly cliPath?: string;
96
+ /** How long the child may take to print `listening <url>`. Default: 60 s (transform modules may load ML models). */
97
+ readonly listenTimeoutMs?: number;
67
98
  }
68
99
  interface AnonController {
69
100
  readonly required: boolean;
@@ -71,14 +102,30 @@ interface AnonController {
71
102
  /** Handles `/anon <args>`. `session` rebinds the session's active model after a switch. */
72
103
  command(args: string, ctx: OmpContext, session: OmpSessionActions): Promise<void>;
73
104
  completions(prefix: string): OmpAutocompleteItem[] | null;
74
- /** Applies the persisted flag (or required mode) and refreshes the status bar. */
105
+ /**
106
+ * Applies the persisted flag (or required mode) and refreshes the status bar. A root session
107
+ * whose omp session id changed rotates the proxy session; subagents keep the parent's id.
108
+ */
75
109
  sessionStart(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
76
- /** While on: routes providers that appeared, or were reset, since the last pass. */
110
+ /** `/new`, `/resume`, `/fork`: the root session changed, so the proxy session rotates. */
111
+ sessionSwitch(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
112
+ /** While on (or failed): routes providers that appeared, or were reset, since the last pass. */
77
113
  reapply(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
114
+ /**
115
+ * `before_provider_request`: while anon is on, failed or required, a request whose model does
116
+ * not target the transform proxy gets a replacement payload without any conversation text.
117
+ * Returns `undefined` to keep the payload.
118
+ */
119
+ providerRequest(ctx: OmpContext): Record<string, never> | undefined;
78
120
  }
79
121
  /**
80
122
  * The process-wide `/anon` state machine. omp builds one extension instance per session
81
123
  * (subagents included) while they share one model registry, so one controller serves them all.
124
+ *
125
+ * `wanted`: model requests must go through the proxy (required mode, or `/anon on`).
126
+ * `enabled`: every provider is routed and the active model is rebound. `wanted` without
127
+ * `enabled` is the error state: the footer shows `anon error` and `providerRequest` refuses
128
+ * every request that would bypass the proxy.
82
129
  */
83
130
  export declare function createAnonController(deps: AnonControllerDeps): AnonController;
84
131
  /** `$SKAILE_TRANSFORM_PROXY_OMP_STATE`, else `skaile-transform-proxy.json` in omp's agent dir (`$PI_CODING_AGENT_DIR` or `~/.omp/agent`). */
@@ -86,4 +133,4 @@ export declare function defaultStatePath(env: Readonly<Record<string, string | u
86
133
  /** Registers the `/anon` command and the event handlers on one session's extension API. */
87
134
  export declare function registerAnonExtension(pi: OmpExtensionApi, controller: AnonController): void;
88
135
  export default function anonExtension(pi: OmpExtensionApi): void;
89
- export type { AnonController, AnonControllerDeps, OmpAutocompleteItem, OmpContext, OmpEventName, OmpExtensionApi, OmpModel, OmpModelRegistry, OmpNotifyType, OmpProviderOverride, OmpSessionActions, OmpUi };
136
+ export type { AnonController, AnonControllerDeps, OmpAutocompleteItem, OmpContext, OmpEventName, OmpExtensionApi, OmpModel, OmpModelRegistry, OmpNotifyType, OmpProviderOverride, OmpSessionActions, OmpUi, ProxyChildProcess, ProxySpawnOptions };
@@ -1,13 +1,17 @@
1
- import { c as SESSION_HEADER, d as createTransformProxy, i as loadProxyConfig, l as TOKEN_HEADER, n as configCandidates, r as findProxyConfig, u as UPSTREAM_HEADER, v as errorMessage, y as silentLogger } from "../config-Xdn_LYpZ.mjs";
2
- import { randomUUID } from "node:crypto";
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-PKHxejC8.mjs";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { homedir } from "node:os";
7
+ import { spawn } from "node:child_process";
6
8
  // src/omp/anon.ts
7
9
  /** A running proxy the extension routes to. */
8
10
  const URL_ENV = "SKAILE_TRANSFORM_PROXY_URL";
9
- /** The client token of that proxy. */
11
+ /** The client token of that proxy; also how the extension hands its own proxy child a random token. */
10
12
  const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
13
+ /** The Node binary that runs the proxy child; default: `node` from `PATH`. */
14
+ const NODE_ENV = "SKAILE_TRANSFORM_PROXY_NODE";
11
15
  /** `required` (set by a skaile session in required mode): routing is forced on and `/anon off` is refused. */
12
16
  const MODE_ENV = "SKAILE_TRANSFORM_PROXY_MODE";
13
17
  /** Overrides the path of the persisted enabled flag. */
@@ -43,13 +47,20 @@ const VERBS = [
43
47
  /**
44
48
  * The process-wide `/anon` state machine. omp builds one extension instance per session
45
49
  * (subagents included) while they share one model registry, so one controller serves them all.
50
+ *
51
+ * `wanted`: model requests must go through the proxy (required mode, or `/anon on`).
52
+ * `enabled`: every provider is routed and the active model is rebound. `wanted` without
53
+ * `enabled` is the error state: the footer shows `anon error` and `providerRequest` refuses
54
+ * every request that would bypass the proxy.
46
55
  */
47
56
  function createAnonController(deps) {
48
57
  const env = deps.env;
49
58
  const required = env[MODE_ENV] === "required";
50
59
  const statePath = deps.statePath ?? defaultStatePath(env, deps.home);
51
- const startProxy = deps.startProxy ?? createTransformProxy;
60
+ const spawnChild = deps.spawnChild ?? spawnNode;
61
+ const listenTimeoutMs = deps.listenTimeoutMs ?? 6e4;
52
62
  const registries = /* @__PURE__ */ new WeakMap();
63
+ let wanted = required;
53
64
  let enabled = false;
54
65
  let endpoint;
55
66
  let starting;
@@ -59,14 +70,14 @@ function createAnonController(deps) {
59
70
  if (state === void 0) {
60
71
  state = {
61
72
  originals: /* @__PURE__ */ new Map(),
62
- routed: /* @__PURE__ */ new Set()
73
+ routed: /* @__PURE__ */ new Map()
63
74
  };
64
75
  registries.set(registry, state);
65
76
  }
66
77
  return state;
67
78
  }
68
79
  async function resolveEndpoint(ctx) {
69
- if (endpoint !== void 0) return endpoint;
80
+ if (endpoint !== void 0 && endpoint.child?.exited !== true) return endpoint;
70
81
  const url = env[URL_ENV];
71
82
  if (url !== void 0 && url !== "") {
72
83
  const token = env[TOKEN_ENV];
@@ -77,13 +88,17 @@ function createAnonController(deps) {
77
88
  };
78
89
  return endpoint;
79
90
  }
80
- starting ??= startInProcess(ctx.cwd).finally(() => {
91
+ starting ??= startChild(ctx.cwd).finally(() => {
81
92
  starting = void 0;
82
93
  });
83
94
  endpoint = await starting;
84
95
  return endpoint;
85
96
  }
86
- async function startInProcess(cwd) {
97
+ /**
98
+ * Runs the proxy CLI under the system Node: omp is a compiled Bun executable whose resolver
99
+ * cannot load transform modules with native or ML dependencies from `node_modules`.
100
+ */
101
+ async function startChild(cwd) {
87
102
  const path = findProxyConfig({
88
103
  cwd,
89
104
  home: deps.home
@@ -94,27 +109,60 @@ function createAnonController(deps) {
94
109
  }
95
110
  const options = loadProxyConfig(path);
96
111
  if (options.defaultPipeline === void 0) throw new Error(`${path} sets no defaultPipeline: the omp extension sends no pipeline header`);
112
+ const token = randomBytes(32).toString("base64url");
113
+ const node = env["SKAILE_TRANSFORM_PROXY_NODE"] || "node";
114
+ const cli = deps.cliPath ?? fileURLToPath(new URL("../cli.mjs", import.meta.url));
115
+ const child = spawnChild(node, [
116
+ cli,
117
+ "--config",
118
+ path,
119
+ "--port",
120
+ "0",
121
+ "--host",
122
+ "127.0.0.1",
123
+ "--exit-with-stdin"
124
+ ], {
125
+ cwd,
126
+ env: {
127
+ ...env,
128
+ [TOKEN_ENV]: token
129
+ },
130
+ stdio: [
131
+ "pipe",
132
+ "pipe",
133
+ "pipe"
134
+ ]
135
+ });
136
+ const state = { exited: false };
137
+ child.on("exit", () => {
138
+ state.exited = true;
139
+ if (endpoint?.child === state) endpoint = void 0;
140
+ });
97
141
  return {
98
- url: (await startProxy({
99
- ...options,
100
- log: silentLogger
101
- })).url,
142
+ url: await waitForListening(child, {
143
+ node,
144
+ token,
145
+ timeoutMs: listenTimeoutMs
146
+ }),
147
+ token,
102
148
  pipeline: options.defaultPipeline,
103
- source: `in-process, config ${path}`
149
+ source: `child process ${child.pid}, config ${path}`,
150
+ child: state
104
151
  };
105
152
  }
106
153
  function currentSessionId(ctx) {
107
- if (sessionId === void 0) {
108
- let id;
109
- try {
110
- id = ctx.sessionManager.getSessionId();
111
- } catch {
112
- id = void 0;
113
- }
114
- sessionId = typeof id === "string" && id !== "" ? id : randomUUID();
115
- }
154
+ sessionId ??= readSessionId(ctx) ?? randomUUID();
116
155
  return sessionId;
117
156
  }
157
+ /**
158
+ * Adopts the session id of a root session. The first session of the process is the root;
159
+ * later `session_start`s are subagents (headless, `print` mode) unless the session has a UI
160
+ * or runs in another mode.
161
+ */
162
+ function enterSession(ctx, root) {
163
+ if (sessionId === void 0) currentSessionId(ctx);
164
+ else if (root) sessionId = readSessionId(ctx) ?? sessionId;
165
+ }
118
166
  function route(ctx, target) {
119
167
  const registry = ctx.modelRegistry;
120
168
  const state = stateOf(registry);
@@ -126,7 +174,7 @@ function createAnonController(deps) {
126
174
  state.originals.set(provider, original);
127
175
  }
128
176
  if (original.preRouted) continue;
129
- if (state.routed.has(provider) && registry.getProviderBaseUrl(provider) === target.url) continue;
177
+ if (state.routed.get(provider) === session && registry.getProviderBaseUrl(provider) === target.url) continue;
130
178
  const headers = { [SESSION_HEADER]: session };
131
179
  if (original.baseUrl !== void 0) headers[UPSTREAM_HEADER] = original.baseUrl;
132
180
  if (target.token !== void 0) headers[TOKEN_HEADER] = target.token;
@@ -134,7 +182,7 @@ function createAnonController(deps) {
134
182
  baseUrl: target.url,
135
183
  headers
136
184
  }, SOURCE_ID);
137
- state.routed.add(provider);
185
+ state.routed.set(provider, session);
138
186
  }
139
187
  }
140
188
  /** Clears the proxy overrides; re-applies the recorded original where another override was layered underneath. */
@@ -143,7 +191,7 @@ function createAnonController(deps) {
143
191
  const state = stateOf(registry);
144
192
  registry.clearSourceRegistrations(SOURCE_ID);
145
193
  const lost = [];
146
- for (const provider of state.routed) {
194
+ for (const provider of state.routed.keys()) {
147
195
  const original = state.originals.get(provider);
148
196
  const restored = registry.getProviderBaseUrl(provider);
149
197
  if (original === void 0 || restored === original.baseUrl) continue;
@@ -159,26 +207,52 @@ function createAnonController(deps) {
159
207
  state.routed.clear();
160
208
  return lost;
161
209
  }
162
- async function turnOn(ctx, session) {
210
+ /**
211
+ * Routes every provider and rebinds the active model. Returns the failure, or `undefined`.
212
+ * A failed rebind leaves the session on a model that bypasses the proxy: outside required
213
+ * mode the routes are rolled back, so the registry matches the state shown.
214
+ */
215
+ async function activate(ctx, session) {
163
216
  let target;
164
217
  try {
165
218
  target = await resolveEndpoint(ctx);
166
219
  } catch (err) {
167
- ctx.ui.notify(`anon: ${errorMessage(err)}`, "error");
168
- return false;
220
+ enabled = false;
221
+ return errorMessage(err);
169
222
  }
170
223
  route(ctx, target);
171
- enabled = true;
172
- await rebind(ctx, session);
173
- return true;
224
+ if (await rebind(ctx, session)) {
225
+ enabled = true;
226
+ return;
227
+ }
228
+ if (!required) unroute(ctx);
229
+ enabled = false;
230
+ const active = ctx.model;
231
+ return `could not rebind the active model ${active?.provider}/${active?.id} to the transform proxy`;
174
232
  }
175
233
  /** The session holds its active model as a snapshot: swap in the registry's re-projected one. */
176
234
  async function rebind(ctx, session) {
177
235
  const active = ctx.model;
178
- if (active === void 0) return;
236
+ if (active === void 0) return true;
179
237
  const live = ctx.modelRegistry.find(active.provider, active.id);
180
- if (live === void 0 || live.baseUrl === active.baseUrl) return;
181
- if (!await session.setModel(live)) ctx.ui.notify(`anon: could not rebind the active model ${active.provider}/${active.id}; select it again`, "warning");
238
+ if (live === void 0) return false;
239
+ if (live.baseUrl === active.baseUrl && live.headers?.["x-skaile-transform-session"] === active.headers?.["x-skaile-transform-session"]) return true;
240
+ try {
241
+ return await session.setModel(live);
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+ /** Why a request of `model` would bypass the proxy, or `undefined` when it targets it. */
247
+ function bypassReason(ctx, model) {
248
+ if (model === void 0) return "the request names no model";
249
+ if (stateOf(ctx.modelRegistry).originals.get(model.provider)?.preRouted === true) return void 0;
250
+ const proxyOrigin = endpoint === void 0 ? void 0 : originOf(endpoint.url);
251
+ if (proxyOrigin === void 0) return "the transform proxy is not available";
252
+ if (model.baseUrl === void 0 || originOf(model.baseUrl) !== proxyOrigin) return `${model.provider}/${model.id} does not target the transform proxy`;
253
+ }
254
+ function refusalHint() {
255
+ return required ? `anon: required mode: model requests are refused until the transform proxy is routed (${MODE_ENV}=required)` : "anon: model requests are refused until /anon on succeeds; /anon off sends them directly";
182
256
  }
183
257
  function persist(ctx, value) {
184
258
  try {
@@ -188,20 +262,23 @@ function createAnonController(deps) {
188
262
  ctx.ui.notify(`anon: could not save ${statePath}: ${errorMessage(err)}`, "warning");
189
263
  }
190
264
  }
265
+ function stateLabel() {
266
+ return enabled ? "on" : wanted ? "error" : "off";
267
+ }
191
268
  function showStatus(ctx) {
192
- ctx.ui.setStatus(STATUS_KEY, enabled ? "anon on" : "anon off");
269
+ ctx.ui.setStatus(STATUS_KEY, `anon ${stateLabel()}`);
193
270
  }
194
271
  function statusText(ctx) {
195
272
  const registry = ctx.modelRegistry;
196
273
  const state = stateOf(registry);
197
274
  const apis = providerApis(registry);
198
- const lines = [`anon ${enabled ? "on" : "off"}${required ? " (required)" : ""}`];
275
+ const lines = [`anon ${stateLabel()}${required ? " (required)" : ""}`];
199
276
  if (endpoint !== void 0) {
200
277
  lines.push(`proxy: ${endpoint.url} (${endpoint.source})`);
201
278
  lines.push(`pipeline: ${endpoint.pipeline ?? "the proxy default"}`);
202
279
  }
203
280
  if (sessionId !== void 0) lines.push(`session: ${sessionId}`);
204
- const routed = [...state.routed].sort();
281
+ const routed = [...state.routed.keys()].sort();
205
282
  const refused = routed.flatMap((provider) => {
206
283
  const reason = refusalReason(apis.get(provider), state.originals.get(provider));
207
284
  return reason === void 0 ? [] : [`${provider} (${reason})`];
@@ -212,26 +289,52 @@ function createAnonController(deps) {
212
289
  if (preRouted.length > 0) lines.push(`routed by their own config (${preRouted.length}): ${preRouted.sort().join(", ")}`);
213
290
  return lines.join("\n");
214
291
  }
292
+ /**
293
+ * Session entry: rotate the proxy session for a new root, then (re-)apply routing when wanted.
294
+ * Rotation only switches the session header: the proxy keeps the old session's state in memory
295
+ * until it exits, and no request carries that id again.
296
+ */
297
+ async function start(ctx, session, root) {
298
+ enterSession(ctx, root);
299
+ if (!wanted && readEnabled(statePath)) wanted = true;
300
+ if (wanted) {
301
+ const failure = await activate(ctx, session);
302
+ if (failure !== void 0) {
303
+ ctx.ui.notify(`anon: ${failure}`, "error");
304
+ ctx.ui.notify(refusalHint(), "error");
305
+ }
306
+ }
307
+ showStatus(ctx);
308
+ }
215
309
  return {
216
310
  required,
217
311
  statePath,
218
312
  async command(args, ctx, session) {
219
313
  switch (args.trim().toLowerCase()) {
220
- case "on":
221
- if (await turnOn(ctx, session)) {
314
+ case "on": {
315
+ const before = wanted;
316
+ wanted = true;
317
+ const failure = await activate(ctx, session);
318
+ if (failure === void 0) {
222
319
  persist(ctx, true);
223
320
  const count = stateOf(ctx.modelRegistry).routed.size;
224
321
  ctx.ui.notify(`anon on: ${count} provider(s) routed through ${endpoint?.url}`, "info");
322
+ break;
225
323
  }
324
+ wanted = before || required;
325
+ ctx.ui.notify(`anon: ${failure}`, "error");
326
+ if (wanted) ctx.ui.notify(refusalHint(), "error");
226
327
  break;
328
+ }
227
329
  case "off": {
228
330
  if (required) {
229
331
  ctx.ui.notify(`anon: off refused: this session requires the transform proxy (${MODE_ENV}=required)`, "warning");
230
332
  break;
231
333
  }
232
334
  const lost = unroute(ctx);
335
+ wanted = false;
233
336
  enabled = false;
234
- await rebind(ctx, session);
337
+ if (!await rebind(ctx, session)) ctx.ui.notify(`anon: could not rebind the active model ${ctx.model?.provider}/${ctx.model?.id}; select it again`, "warning");
235
338
  persist(ctx, false);
236
339
  ctx.ui.notify("anon off: original provider base URLs restored", "info");
237
340
  if (lost.length > 0) ctx.ui.notify(`anon: ${lost.join(", ")} lost their runtime registration (registered by another extension); restart omp to restore them`, "warning");
@@ -250,20 +353,33 @@ function createAnonController(deps) {
250
353
  const items = VERBS.filter((item) => item.value.startsWith(needle));
251
354
  return items.length > 0 ? items : null;
252
355
  },
253
- async sessionStart(ctx, session) {
254
- currentSessionId(ctx);
255
- if (enabled && endpoint !== void 0) {
256
- route(ctx, endpoint);
257
- await rebind(ctx, session);
258
- } else if (required || readEnabled(statePath)) {
259
- if (!await turnOn(ctx, session) && required) ctx.ui.notify("anon: required mode, but no provider is routed through the transform proxy", "error");
260
- }
261
- showStatus(ctx);
356
+ sessionStart(ctx, session) {
357
+ return start(ctx, session, ctx.hasUI === true || ctx.mode !== void 0 && ctx.mode !== "print");
358
+ },
359
+ sessionSwitch(ctx, session) {
360
+ return start(ctx, session, true);
262
361
  },
263
362
  async reapply(ctx, session) {
264
- if (!enabled || endpoint === void 0) return;
265
- route(ctx, endpoint);
266
- await rebind(ctx, session);
363
+ if (!wanted) return;
364
+ const was = enabled;
365
+ const failure = await activate(ctx, session);
366
+ if (failure !== void 0 && was) {
367
+ ctx.ui.notify(`anon: ${failure}`, "error");
368
+ ctx.ui.notify(refusalHint(), "error");
369
+ }
370
+ if (enabled !== was) showStatus(ctx);
371
+ },
372
+ providerRequest(ctx) {
373
+ if (!wanted) return void 0;
374
+ let reason;
375
+ try {
376
+ reason = bypassReason(ctx, ctx.model);
377
+ } catch (err) {
378
+ reason = errorMessage(err);
379
+ }
380
+ if (reason === void 0) return void 0;
381
+ ctx.ui.notify(`anon: model request refused: ${reason}`, "error");
382
+ return {};
267
383
  }
268
384
  };
269
385
  }
@@ -282,6 +398,101 @@ function readEnabled(path) {
282
398
  return false;
283
399
  }
284
400
  }
401
+ function spawnNode(command, args, options) {
402
+ return spawn(command, [...args], {
403
+ cwd: options.cwd,
404
+ env: options.env,
405
+ stdio: [...options.stdio]
406
+ });
407
+ }
408
+ /** The stderr lines a startup failure quotes. */
409
+ const STDERR_TAIL = 10;
410
+ /**
411
+ * Resolves with the URL of the child's `listening <url>` line. A child that exits first, fails
412
+ * to spawn or stays silent past `timeoutMs` (it is then killed) rejects, quoting its last stderr
413
+ * lines with the token redacted. omp exiting kills the child; once listening, the child and its
414
+ * pipes no longer hold omp's event loop, and stdin stays open so the child sees omp go away.
415
+ */
416
+ function waitForListening(child, how) {
417
+ const killOnExit = () => {
418
+ child.kill("SIGTERM");
419
+ };
420
+ process.on("exit", killOnExit);
421
+ const forgetExitHook = () => {
422
+ process.off("exit", killOnExit);
423
+ };
424
+ child.on("exit", forgetExitHook);
425
+ for (const stream of [
426
+ child.stdin,
427
+ child.stdout,
428
+ child.stderr
429
+ ]) stream?.on("error", () => {});
430
+ const listening = Promise.withResolvers();
431
+ const label = `the transform proxy child process (${how.node}, pid ${child.pid})`;
432
+ const tail = [];
433
+ let stderrPartial = "";
434
+ let stdout = "";
435
+ let settled = false;
436
+ function stderrTail() {
437
+ const lines = [...tail, stderrPartial].filter((line) => line.trim() !== "").slice(-10);
438
+ return lines.length === 0 ? "(none)" : lines.map((line) => line.split(how.token).join("[token]")).join("\n");
439
+ }
440
+ function fail(message) {
441
+ if (settled) return;
442
+ settled = true;
443
+ clearTimeout(timer);
444
+ listening.reject(new Error(message));
445
+ }
446
+ const timer = setTimeout(() => {
447
+ fail(`${label} did not print "listening" within ${duration(how.timeoutMs)} and was killed; last stderr lines:\n${stderrTail()}`);
448
+ child.kill("SIGKILL");
449
+ }, how.timeoutMs);
450
+ child.stderr?.on("data", (chunk) => {
451
+ if (settled) return;
452
+ const lines = (stderrPartial + String(chunk)).split("\n");
453
+ stderrPartial = lines.pop() ?? "";
454
+ tail.push(...lines.map((line) => line.replace(/\r$/, "")));
455
+ tail.splice(0, Math.max(0, tail.length - STDERR_TAIL));
456
+ });
457
+ child.stdout?.on("data", (chunk) => {
458
+ if (settled) return;
459
+ const lines = (stdout + String(chunk)).split("\n");
460
+ stdout = lines.pop() ?? "";
461
+ for (const line of lines) {
462
+ const match = /^listening (\S+)$/.exec(line.trim());
463
+ if (match === null) continue;
464
+ settled = true;
465
+ clearTimeout(timer);
466
+ stdout = "";
467
+ child.unref();
468
+ for (const stream of [
469
+ child.stdin,
470
+ child.stdout,
471
+ child.stderr
472
+ ]) stream?.unref?.();
473
+ listening.resolve(match[1]);
474
+ return;
475
+ }
476
+ });
477
+ child.on("exit", (code, signal) => {
478
+ if (settled) return;
479
+ const report = () => fail(`${label} exited with ${code === null ? `signal ${signal}` : `code ${code}`} before it was listening; last stderr lines:\n${stderrTail()}`);
480
+ const stderr = child.stderr;
481
+ if (stderr === null || stderr.readableEnded || stderr.destroyed) report();
482
+ else {
483
+ stderr.once("end", report);
484
+ stderr.once("close", report);
485
+ }
486
+ });
487
+ child.on("error", (err) => {
488
+ forgetExitHook();
489
+ fail(`could not start the transform proxy with "${how.node}": ${err.message}; it runs under Node >= 22: put node on PATH or set ${NODE_ENV}`);
490
+ });
491
+ return listening.promise;
492
+ }
493
+ function duration(ms) {
494
+ return ms % 1e3 === 0 ? `${ms / 1e3} s` : `${ms} ms`;
495
+ }
285
496
  /** Every provider of the registry with the wire APIs of its models. */
286
497
  function providerApis(registry) {
287
498
  const out = /* @__PURE__ */ new Map();
@@ -311,8 +522,24 @@ function refusalReason(apis, original) {
311
522
  const unserved = [...apis ?? []].filter((api) => !Object.hasOwn(SERVED_APIS, api));
312
523
  return unserved.length === 0 ? void 0 : unserved.join(", ");
313
524
  }
525
+ function readSessionId(ctx) {
526
+ try {
527
+ const id = ctx.sessionManager.getSessionId();
528
+ return typeof id === "string" && id !== "" ? id : void 0;
529
+ } catch {
530
+ return;
531
+ }
532
+ }
533
+ /** The origin of an http(s) URL; `undefined` for anything else, which never matches the proxy. */
534
+ function originOf(url) {
535
+ try {
536
+ return new URL(url).origin;
537
+ } catch {
538
+ return;
539
+ }
540
+ }
314
541
  // src/omp/index.ts
315
- /** omp re-imports the extension per session; the controller (proxy, recorded originals) is process-wide. */
542
+ /** omp re-imports the extension per session; the controller (proxy child, recorded originals) is process-wide. */
316
543
  const CONTROLLER_KEY = Symbol.for("@skaile/transform-proxy/omp");
317
544
  /** Registers the `/anon` command and the event handlers on one session's extension API. */
318
545
  function registerAnonExtension(pi, controller) {
@@ -322,8 +549,10 @@ function registerAnonExtension(pi, controller) {
322
549
  handler: (args, ctx) => guarded(ctx, () => controller.command(args, ctx, pi))
323
550
  });
324
551
  on(pi, "session_start", (ctx) => controller.sessionStart(ctx, pi));
552
+ on(pi, "session_switch", (ctx) => controller.sessionSwitch(ctx, pi));
325
553
  on(pi, "before_agent_start", (ctx) => controller.reapply(ctx, pi));
326
554
  on(pi, "turn_start", (ctx) => controller.reapply(ctx, pi));
555
+ pi.on("before_provider_request", (_event, ctx) => controller.providerRequest(ctx));
327
556
  }
328
557
  function anonExtension(pi) {
329
558
  const scope = globalThis;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/transform-proxy",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",