@skaile/transform-proxy 0.2.1 → 0.2.2

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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).
8
+ - 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.
9
+
3
10
  ## 0.2.1
4
11
 
5
12
  - 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.
@@ -91,13 +92,15 @@ The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform
91
92
  omp plugin install @skaile/transform-proxy
92
93
  ```
93
94
 
94
- It adds the command `/anon on|off|status` and the footer status `anon on` / `anon off`.
95
+ It adds the command `/anon on|off|status` and the footer status `anon on`, `anon off` or `anon error`.
95
96
 
96
97
  - `/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
98
  - With `SKAILE_TRANSFORM_PROXY_URL` (and `SKAILE_TRANSFORM_PROXY_TOKEN`) set, it uses that running proxy, and the proxy applies its `defaultPipeline`.
98
99
  - 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.
99
100
  - 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.
101
+ - 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`.
102
+ - 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.
103
+ - 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`, and the in-process proxy drops the old session. A `session_start` of a headless `print` session (how omp runs subagents) keeps the parent's id, so subagents share its pseudonyms.
101
104
  - `/anon off` restores the original base URLs and headers.
102
105
  - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, and the routed and refused providers.
103
106
  - 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.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";
@@ -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";
@@ -4,6 +4,8 @@ interface OmpModel {
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,7 +64,8 @@ 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;
59
69
  }
60
70
  interface AnonControllerDeps {
61
71
  readonly env: Readonly<Record<string, string | undefined>>;
@@ -71,14 +81,30 @@ interface AnonController {
71
81
  /** Handles `/anon <args>`. `session` rebinds the session's active model after a switch. */
72
82
  command(args: string, ctx: OmpContext, session: OmpSessionActions): Promise<void>;
73
83
  completions(prefix: string): OmpAutocompleteItem[] | null;
74
- /** Applies the persisted flag (or required mode) and refreshes the status bar. */
84
+ /**
85
+ * Applies the persisted flag (or required mode) and refreshes the status bar. A root session
86
+ * whose omp session id changed rotates the proxy session; subagents keep the parent's id.
87
+ */
75
88
  sessionStart(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
76
- /** While on: routes providers that appeared, or were reset, since the last pass. */
89
+ /** `/new`, `/resume`, `/fork`: the root session changed, so the proxy session rotates. */
90
+ sessionSwitch(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
91
+ /** While on (or failed): routes providers that appeared, or were reset, since the last pass. */
77
92
  reapply(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
93
+ /**
94
+ * `before_provider_request`: while anon is on, failed or required, a request whose model does
95
+ * not target the transform proxy gets a replacement payload without any conversation text.
96
+ * Returns `undefined` to keep the payload.
97
+ */
98
+ providerRequest(ctx: OmpContext): Record<string, never> | undefined;
78
99
  }
79
100
  /**
80
101
  * The process-wide `/anon` state machine. omp builds one extension instance per session
81
102
  * (subagents included) while they share one model registry, so one controller serves them all.
103
+ *
104
+ * `wanted`: model requests must go through the proxy (required mode, or `/anon on`).
105
+ * `enabled`: every provider is routed and the active model is rebound. `wanted` without
106
+ * `enabled` is the error state: the footer shows `anon error` and `providerRequest` refuses
107
+ * every request that would bypass the proxy.
82
108
  */
83
109
  export declare function createAnonController(deps: AnonControllerDeps): AnonController;
84
110
  /** `$SKAILE_TRANSFORM_PROXY_OMP_STATE`, else `skaile-transform-proxy.json` in omp's agent dir (`$PI_CODING_AGENT_DIR` or `~/.omp/agent`). */
@@ -1,4 +1,4 @@
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";
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-PKHxejC8.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
@@ -43,6 +43,11 @@ const VERBS = [
43
43
  /**
44
44
  * The process-wide `/anon` state machine. omp builds one extension instance per session
45
45
  * (subagents included) while they share one model registry, so one controller serves them all.
46
+ *
47
+ * `wanted`: model requests must go through the proxy (required mode, or `/anon on`).
48
+ * `enabled`: every provider is routed and the active model is rebound. `wanted` without
49
+ * `enabled` is the error state: the footer shows `anon error` and `providerRequest` refuses
50
+ * every request that would bypass the proxy.
46
51
  */
47
52
  function createAnonController(deps) {
48
53
  const env = deps.env;
@@ -50,6 +55,7 @@ function createAnonController(deps) {
50
55
  const statePath = deps.statePath ?? defaultStatePath(env, deps.home);
51
56
  const startProxy = deps.startProxy ?? createTransformProxy;
52
57
  const registries = /* @__PURE__ */ new WeakMap();
58
+ let wanted = required;
53
59
  let enabled = false;
54
60
  let endpoint;
55
61
  let starting;
@@ -59,7 +65,7 @@ function createAnonController(deps) {
59
65
  if (state === void 0) {
60
66
  state = {
61
67
  originals: /* @__PURE__ */ new Map(),
62
- routed: /* @__PURE__ */ new Set()
68
+ routed: /* @__PURE__ */ new Map()
63
69
  };
64
70
  registries.set(registry, state);
65
71
  }
@@ -94,27 +100,44 @@ function createAnonController(deps) {
94
100
  }
95
101
  const options = loadProxyConfig(path);
96
102
  if (options.defaultPipeline === void 0) throw new Error(`${path} sets no defaultPipeline: the omp extension sends no pipeline header`);
103
+ const proxy = await startProxy({
104
+ ...options,
105
+ log: silentLogger
106
+ });
97
107
  return {
98
- url: (await startProxy({
99
- ...options,
100
- log: silentLogger
101
- })).url,
108
+ url: proxy.url,
102
109
  pipeline: options.defaultPipeline,
103
- source: `in-process, config ${path}`
110
+ source: `in-process, config ${path}`,
111
+ proxy
104
112
  };
105
113
  }
106
114
  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
- }
115
+ sessionId ??= readSessionId(ctx) ?? randomUUID();
116
116
  return sessionId;
117
117
  }
118
+ /**
119
+ * Adopts the session id of a root session and returns the id it replaced, if any. The first
120
+ * session of the process is the root; later `session_start`s are subagents (headless, `print`
121
+ * mode) unless the session has a UI or runs in another mode.
122
+ */
123
+ function enterSession(ctx, root) {
124
+ const previous = sessionId;
125
+ if (previous === void 0) {
126
+ currentSessionId(ctx);
127
+ return;
128
+ }
129
+ if (root) sessionId = readSessionId(ctx) ?? previous;
130
+ return sessionId === previous ? void 0 : previous;
131
+ }
132
+ async function dropProxySession(ctx, id) {
133
+ const proxy = endpoint?.proxy;
134
+ if (proxy === void 0) return;
135
+ try {
136
+ await proxy.dropSession(id);
137
+ } catch (err) {
138
+ ctx.ui.notify(`anon: could not drop proxy session ${id}: ${errorMessage(err)}`, "warning");
139
+ }
140
+ }
118
141
  function route(ctx, target) {
119
142
  const registry = ctx.modelRegistry;
120
143
  const state = stateOf(registry);
@@ -126,7 +149,7 @@ function createAnonController(deps) {
126
149
  state.originals.set(provider, original);
127
150
  }
128
151
  if (original.preRouted) continue;
129
- if (state.routed.has(provider) && registry.getProviderBaseUrl(provider) === target.url) continue;
152
+ if (state.routed.get(provider) === session && registry.getProviderBaseUrl(provider) === target.url) continue;
130
153
  const headers = { [SESSION_HEADER]: session };
131
154
  if (original.baseUrl !== void 0) headers[UPSTREAM_HEADER] = original.baseUrl;
132
155
  if (target.token !== void 0) headers[TOKEN_HEADER] = target.token;
@@ -134,7 +157,7 @@ function createAnonController(deps) {
134
157
  baseUrl: target.url,
135
158
  headers
136
159
  }, SOURCE_ID);
137
- state.routed.add(provider);
160
+ state.routed.set(provider, session);
138
161
  }
139
162
  }
140
163
  /** Clears the proxy overrides; re-applies the recorded original where another override was layered underneath. */
@@ -143,7 +166,7 @@ function createAnonController(deps) {
143
166
  const state = stateOf(registry);
144
167
  registry.clearSourceRegistrations(SOURCE_ID);
145
168
  const lost = [];
146
- for (const provider of state.routed) {
169
+ for (const provider of state.routed.keys()) {
147
170
  const original = state.originals.get(provider);
148
171
  const restored = registry.getProviderBaseUrl(provider);
149
172
  if (original === void 0 || restored === original.baseUrl) continue;
@@ -159,26 +182,52 @@ function createAnonController(deps) {
159
182
  state.routed.clear();
160
183
  return lost;
161
184
  }
162
- async function turnOn(ctx, session) {
185
+ /**
186
+ * Routes every provider and rebinds the active model. Returns the failure, or `undefined`.
187
+ * A failed rebind leaves the session on a model that bypasses the proxy: outside required
188
+ * mode the routes are rolled back, so the registry matches the state shown.
189
+ */
190
+ async function activate(ctx, session) {
163
191
  let target;
164
192
  try {
165
193
  target = await resolveEndpoint(ctx);
166
194
  } catch (err) {
167
- ctx.ui.notify(`anon: ${errorMessage(err)}`, "error");
168
- return false;
195
+ enabled = false;
196
+ return errorMessage(err);
169
197
  }
170
198
  route(ctx, target);
171
- enabled = true;
172
- await rebind(ctx, session);
173
- return true;
199
+ if (await rebind(ctx, session)) {
200
+ enabled = true;
201
+ return;
202
+ }
203
+ if (!required) unroute(ctx);
204
+ enabled = false;
205
+ const active = ctx.model;
206
+ return `could not rebind the active model ${active?.provider}/${active?.id} to the transform proxy`;
174
207
  }
175
208
  /** The session holds its active model as a snapshot: swap in the registry's re-projected one. */
176
209
  async function rebind(ctx, session) {
177
210
  const active = ctx.model;
178
- if (active === void 0) return;
211
+ if (active === void 0) return true;
179
212
  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");
213
+ if (live === void 0) return false;
214
+ if (live.baseUrl === active.baseUrl && live.headers?.["x-skaile-transform-session"] === active.headers?.["x-skaile-transform-session"]) return true;
215
+ try {
216
+ return await session.setModel(live);
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+ /** Why a request of `model` would bypass the proxy, or `undefined` when it targets it. */
222
+ function bypassReason(ctx, model) {
223
+ if (model === void 0) return "the request names no model";
224
+ if (stateOf(ctx.modelRegistry).originals.get(model.provider)?.preRouted === true) return void 0;
225
+ const proxyOrigin = endpoint === void 0 ? void 0 : originOf(endpoint.url);
226
+ if (proxyOrigin === void 0) return "the transform proxy is not available";
227
+ if (model.baseUrl === void 0 || originOf(model.baseUrl) !== proxyOrigin) return `${model.provider}/${model.id} does not target the transform proxy`;
228
+ }
229
+ function refusalHint() {
230
+ 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
231
  }
183
232
  function persist(ctx, value) {
184
233
  try {
@@ -188,20 +237,23 @@ function createAnonController(deps) {
188
237
  ctx.ui.notify(`anon: could not save ${statePath}: ${errorMessage(err)}`, "warning");
189
238
  }
190
239
  }
240
+ function stateLabel() {
241
+ return enabled ? "on" : wanted ? "error" : "off";
242
+ }
191
243
  function showStatus(ctx) {
192
- ctx.ui.setStatus(STATUS_KEY, enabled ? "anon on" : "anon off");
244
+ ctx.ui.setStatus(STATUS_KEY, `anon ${stateLabel()}`);
193
245
  }
194
246
  function statusText(ctx) {
195
247
  const registry = ctx.modelRegistry;
196
248
  const state = stateOf(registry);
197
249
  const apis = providerApis(registry);
198
- const lines = [`anon ${enabled ? "on" : "off"}${required ? " (required)" : ""}`];
250
+ const lines = [`anon ${stateLabel()}${required ? " (required)" : ""}`];
199
251
  if (endpoint !== void 0) {
200
252
  lines.push(`proxy: ${endpoint.url} (${endpoint.source})`);
201
253
  lines.push(`pipeline: ${endpoint.pipeline ?? "the proxy default"}`);
202
254
  }
203
255
  if (sessionId !== void 0) lines.push(`session: ${sessionId}`);
204
- const routed = [...state.routed].sort();
256
+ const routed = [...state.routed.keys()].sort();
205
257
  const refused = routed.flatMap((provider) => {
206
258
  const reason = refusalReason(apis.get(provider), state.originals.get(provider));
207
259
  return reason === void 0 ? [] : [`${provider} (${reason})`];
@@ -212,26 +264,49 @@ function createAnonController(deps) {
212
264
  if (preRouted.length > 0) lines.push(`routed by their own config (${preRouted.length}): ${preRouted.sort().join(", ")}`);
213
265
  return lines.join("\n");
214
266
  }
267
+ /** Session entry: rotate the proxy session for a new root, then (re-)apply routing when wanted. */
268
+ async function start(ctx, session, root) {
269
+ const previous = enterSession(ctx, root);
270
+ if (!wanted && readEnabled(statePath)) wanted = true;
271
+ if (wanted) {
272
+ const failure = await activate(ctx, session);
273
+ if (failure !== void 0) {
274
+ ctx.ui.notify(`anon: ${failure}`, "error");
275
+ ctx.ui.notify(refusalHint(), "error");
276
+ }
277
+ }
278
+ if (previous !== void 0) await dropProxySession(ctx, previous);
279
+ showStatus(ctx);
280
+ }
215
281
  return {
216
282
  required,
217
283
  statePath,
218
284
  async command(args, ctx, session) {
219
285
  switch (args.trim().toLowerCase()) {
220
- case "on":
221
- if (await turnOn(ctx, session)) {
286
+ case "on": {
287
+ const before = wanted;
288
+ wanted = true;
289
+ const failure = await activate(ctx, session);
290
+ if (failure === void 0) {
222
291
  persist(ctx, true);
223
292
  const count = stateOf(ctx.modelRegistry).routed.size;
224
293
  ctx.ui.notify(`anon on: ${count} provider(s) routed through ${endpoint?.url}`, "info");
294
+ break;
225
295
  }
296
+ wanted = before || required;
297
+ ctx.ui.notify(`anon: ${failure}`, "error");
298
+ if (wanted) ctx.ui.notify(refusalHint(), "error");
226
299
  break;
300
+ }
227
301
  case "off": {
228
302
  if (required) {
229
303
  ctx.ui.notify(`anon: off refused: this session requires the transform proxy (${MODE_ENV}=required)`, "warning");
230
304
  break;
231
305
  }
232
306
  const lost = unroute(ctx);
307
+ wanted = false;
233
308
  enabled = false;
234
- await rebind(ctx, session);
309
+ 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
310
  persist(ctx, false);
236
311
  ctx.ui.notify("anon off: original provider base URLs restored", "info");
237
312
  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 +325,33 @@ function createAnonController(deps) {
250
325
  const items = VERBS.filter((item) => item.value.startsWith(needle));
251
326
  return items.length > 0 ? items : null;
252
327
  },
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);
328
+ sessionStart(ctx, session) {
329
+ return start(ctx, session, ctx.hasUI === true || ctx.mode !== void 0 && ctx.mode !== "print");
330
+ },
331
+ sessionSwitch(ctx, session) {
332
+ return start(ctx, session, true);
262
333
  },
263
334
  async reapply(ctx, session) {
264
- if (!enabled || endpoint === void 0) return;
265
- route(ctx, endpoint);
266
- await rebind(ctx, session);
335
+ if (!wanted) return;
336
+ const was = enabled;
337
+ const failure = await activate(ctx, session);
338
+ if (failure !== void 0 && was) {
339
+ ctx.ui.notify(`anon: ${failure}`, "error");
340
+ ctx.ui.notify(refusalHint(), "error");
341
+ }
342
+ if (enabled !== was) showStatus(ctx);
343
+ },
344
+ providerRequest(ctx) {
345
+ if (!wanted) return void 0;
346
+ let reason;
347
+ try {
348
+ reason = bypassReason(ctx, ctx.model);
349
+ } catch (err) {
350
+ reason = errorMessage(err);
351
+ }
352
+ if (reason === void 0) return void 0;
353
+ ctx.ui.notify(`anon: model request refused: ${reason}`, "error");
354
+ return {};
267
355
  }
268
356
  };
269
357
  }
@@ -311,6 +399,22 @@ function refusalReason(apis, original) {
311
399
  const unserved = [...apis ?? []].filter((api) => !Object.hasOwn(SERVED_APIS, api));
312
400
  return unserved.length === 0 ? void 0 : unserved.join(", ");
313
401
  }
402
+ function readSessionId(ctx) {
403
+ try {
404
+ const id = ctx.sessionManager.getSessionId();
405
+ return typeof id === "string" && id !== "" ? id : void 0;
406
+ } catch {
407
+ return;
408
+ }
409
+ }
410
+ /** The origin of an http(s) URL; `undefined` for anything else, which never matches the proxy. */
411
+ function originOf(url) {
412
+ try {
413
+ return new URL(url).origin;
414
+ } catch {
415
+ return;
416
+ }
417
+ }
314
418
  // src/omp/index.ts
315
419
  /** omp re-imports the extension per session; the controller (proxy, recorded originals) is process-wide. */
316
420
  const CONTROLLER_KEY = Symbol.for("@skaile/transform-proxy/omp");
@@ -322,8 +426,10 @@ function registerAnonExtension(pi, controller) {
322
426
  handler: (args, ctx) => guarded(ctx, () => controller.command(args, ctx, pi))
323
427
  });
324
428
  on(pi, "session_start", (ctx) => controller.sessionStart(ctx, pi));
429
+ on(pi, "session_switch", (ctx) => controller.sessionSwitch(ctx, pi));
325
430
  on(pi, "before_agent_start", (ctx) => controller.reapply(ctx, pi));
326
431
  on(pi, "turn_start", (ctx) => controller.reapply(ctx, pi));
432
+ pi.on("before_provider_request", (_event, ctx) => controller.providerRequest(ctx));
327
433
  }
328
434
  function anonExtension(pi) {
329
435
  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.2",
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",