@skaile/transform-proxy 0.2.0 → 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,16 @@
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
+
10
+ ## 0.2.1
11
+
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.
13
+
3
14
  ## 0.2.0
4
15
 
5
16
  - OpenAI chat completions (`/chat/completions`) and OpenAI responses (`/responses`) walkers: text leaves, tool-call arguments and reasoning summaries are transformed outbound and restored in JSON and SSE replies; `image_url`, `input_audio`, `file`, `input_image` and `input_file` parts are refused with 400 `unsupported_content`. Responses reasoning summaries are cached by item id so a replayed reasoning item is sent back byte for byte.
package/README.md CHANGED
@@ -21,14 +21,17 @@ The proxy strips all four headers before forwarding. Every other header, includi
21
21
  |---|---|
22
22
  | `POST …/messages` | Anthropic request and response: request rewritten, JSON reply and SSE stream restored. |
23
23
  | `POST …/messages/count_tokens` | Request rewritten; the response passes through untouched. |
24
+ | `POST …/chat/completions` | OpenAI chat request and response: request rewritten, JSON reply and SSE stream restored. |
25
+ | `POST …/responses` | OpenAI responses request and response: request rewritten, JSON reply and SSE stream restored. |
24
26
  | `GET …/models` | Passthrough. |
25
- | anything else (chat completions, responses, embeddings) | 404 `unsupported_route`. |
27
+ | anything else (embeddings included) | 404 `unsupported_route`. |
26
28
 
27
- OpenAI chat and responses walkers are not implemented yet. Embeddings are refused on purpose: a vector of pseudonymized input cannot be restored.
29
+ Embeddings are refused on purpose: a vector of pseudonymized input cannot be restored.
28
30
 
29
31
  ## Refusals
30
32
 
31
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.
32
35
  - Unknown routes, pipelines and upstreams are refused before any body is read or transform is built.
33
36
  - A transform that throws refuses the request with 502 `transform_failed`. Nothing is sent upstream.
34
37
  - Bedrock (SigV4 covers the body) and Vertex are not routed. Refuse them in the caller.
@@ -89,13 +92,15 @@ The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform
89
92
  omp plugin install @skaile/transform-proxy
90
93
  ```
91
94
 
92
- 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`.
93
96
 
94
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.
95
98
  - With `SKAILE_TRANSFORM_PROXY_URL` (and `SKAILE_TRANSFORM_PROXY_TOKEN`) set, it uses that running proxy, and the proxy applies its `defaultPipeline`.
96
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.
97
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.
98
- - 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.
99
104
  - `/anon off` restores the original base URLs and headers.
100
105
  - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, and the routed and refused providers.
101
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.
@@ -111,6 +116,8 @@ A module default-exports a `TransformModule`:
111
116
  - It returns a `TransformFactory`, which runs once per `(session, pipeline)`: `(ctx: { sessionId, facts, log }) => Transform`.
112
117
  - A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()` and `dispose?()`.
113
118
 
119
+ The Anthropic walker appends the pipeline `instruction` after the host's system prompt: a string `system` gets it after a blank line, a block array gets it as one more text block at the end, and an absent `system` becomes the instruction. The host's leading blocks stay first, because Claude Code gateways (TeamClaude) refuse Sonnet requests with 429 `rate_limit_error` when the system prompt does not start with the Claude Code blocks. The OpenAI walkers still put the instruction first: a leading `system` message in chat completions, a prefix of `instructions` in responses.
120
+
114
121
  `inboundStream().push(c1) + … + flush()` must equal `inbound(c1 + … + cn)` for every chunking.
115
122
 
116
123
  Connectors publish facts through `proxy.session(id).publisher(source)`. Facts are in-process only.
package/dist/cli.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as TransformProxy } from "./server-D5nBnO7T.mjs";
1
+ import { i as TransformProxy } from "./server-C_eKyCTV.mjs";
2
2
  /** Environment variable that supplies the client token when `--token` is absent. */
3
3
  export declare const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
4
4
  export interface CliEnvironment {
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-DQPsbYLi.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)
@@ -434,13 +442,18 @@ function toolNamesById(messages) {
434
442
  }
435
443
  return names;
436
444
  }
445
+ /**
446
+ * Appends the instruction after the host's system prompt. Gateways that serve only Claude Code
447
+ * traffic check that the system prompt starts with the Claude Code blocks (TeamClaude answers
448
+ * 429 for Sonnet otherwise), so the host's leading blocks must stay first.
449
+ */
437
450
  function withInstruction(system, instruction) {
438
451
  if (system === void 0) return instruction;
439
- if (typeof system === "string") return `${instruction}\n\n${system}`;
440
- if (Array.isArray(system)) return [{
452
+ if (typeof system === "string") return `${system}\n\n${instruction}`;
453
+ if (Array.isArray(system)) return [...system, {
441
454
  type: "text",
442
455
  text: instruction
443
- }, ...system];
456
+ }];
444
457
  return system;
445
458
  }
446
459
  function newBlockState(kind, transform) {
@@ -552,17 +565,21 @@ function createStreamTransformer$2(ctx) {
552
565
  }
553
566
  const anthropicWalker = {
554
567
  async request(body, ctx) {
555
- if (!isObj$2(body)) return body;
568
+ if (!isObj$2(body)) return malformed$2("request body");
556
569
  const out = { ...body };
557
- 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);
558
571
  if (ctx.transform.instruction !== void 0) out.system = withInstruction(out.system, ctx.transform.instruction);
559
- if (Array.isArray(body.messages)) {
572
+ if (body.messages !== void 0) {
573
+ if (!Array.isArray(body.messages)) return malformed$2("messages");
560
574
  const toolNames = toolNamesById(body.messages);
561
575
  const messages = [];
562
- for (const message of body.messages) messages.push(isObj$2(message) ? {
563
- ...message,
564
- content: await rewriteContent$2(message.content, message.role, toolNames, ctx)
565
- } : 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
+ }
566
583
  out.messages = messages;
567
584
  }
568
585
  return out;
@@ -610,21 +627,28 @@ const REASONING_FIELDS = ["reasoning_content", "reasoning"];
610
627
  async function outbound$1(transform, text, leaf) {
611
628
  return text === "" ? text : transform.outbound(text, leaf);
612
629
  }
613
- /** 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. */
614
635
  async function rewriteContent$1(content, leaf, transform) {
615
636
  if (typeof content === "string") return outbound$1(transform, content, leaf);
616
- 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");
617
639
  const out = [];
618
640
  for (const part of content) {
619
- 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") {
620
644
  out.push(part);
621
645
  continue;
622
646
  }
623
- if (typeof part.type === "string" && REFUSED_PARTS$1[part.type] === true) throw new UnsupportedContentError(`OpenAI chat ${part.type} content cannot be transformed`);
624
- out.push(part.type === "text" && typeof part.text === "string" ? {
647
+ if (typeof part.text !== "string") return malformed$1("text part");
648
+ out.push({
625
649
  ...part,
626
650
  text: await outbound$1(transform, part.text, leaf)
627
- } : part);
651
+ });
628
652
  }
629
653
  return out;
630
654
  }
@@ -672,17 +696,20 @@ function restoreArguments$1(args, transform) {
672
696
  return JSON.stringify(restoreStrings(parsed, transform));
673
697
  }
674
698
  async function rewriteToolCalls(calls, transform, names) {
675
- 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");
676
701
  const out = [];
677
702
  for (const call of calls) {
678
- 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") {
679
705
  out.push(call);
680
706
  continue;
681
707
  }
682
708
  const fn = call.function;
709
+ if (!isObj$1(fn) || typeof fn.arguments !== "string") return malformed$1("function tool call");
683
710
  const toolName = typeof fn.name === "string" ? fn.name : void 0;
684
711
  if (typeof call.id === "string" && toolName !== void 0) names.set(call.id, toolName);
685
- out.push(typeof fn.arguments === "string" ? {
712
+ out.push({
686
713
  ...call,
687
714
  function: {
688
715
  ...fn,
@@ -691,7 +718,7 @@ async function rewriteToolCalls(calls, transform, names) {
691
718
  toolName
692
719
  }, transform)
693
720
  }
694
- } : call);
721
+ });
695
722
  }
696
723
  return out;
697
724
  }
@@ -709,6 +736,7 @@ async function rewriteMessage(message, transform, names) {
709
736
  for (const field of REASONING_FIELDS) {
710
737
  const value = message[field];
711
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}`);
712
740
  }
713
741
  out.content = await rewriteContent$1(message.content, { kind: "assistant" }, transform);
714
742
  out.tool_calls = await rewriteToolCalls(message.tool_calls, transform, names);
@@ -722,7 +750,15 @@ async function rewriteMessage(message, transform, names) {
722
750
  }, transform);
723
751
  break;
724
752
  }
725
- 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");
726
762
  }
727
763
  if (message.content === void 0) delete out.content;
728
764
  return out;
@@ -869,12 +905,16 @@ function createStreamTransformer$1(ctx) {
869
905
  }
870
906
  const openaiChatWalker = {
871
907
  async request(body, ctx) {
872
- if (!isObj$1(body)) return body;
908
+ if (!isObj$1(body)) return malformed$1("request body");
873
909
  const out = { ...body };
874
- if (Array.isArray(body.messages)) {
910
+ if (body.messages !== void 0) {
911
+ if (!Array.isArray(body.messages)) return malformed$1("messages");
875
912
  const names = /* @__PURE__ */ new Map();
876
913
  const messages = [];
877
- 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
+ }
878
918
  if (ctx.transform.instruction !== void 0) messages.unshift({
879
919
  role: "system",
880
920
  content: ctx.transform.instruction
@@ -932,21 +972,27 @@ function refuse(part) {
932
972
  async function outbound(transform, text, leaf) {
933
973
  return text === "" ? text : transform.outbound(text, leaf);
934
974
  }
935
- /** A string, or an array of parts whose `input_text`/`output_text` parts are rewritten. */
936
- 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) {
937
981
  if (typeof content === "string") return outbound(transform, content, leaf);
938
- if (!Array.isArray(content)) return content;
982
+ if (!Array.isArray(content)) return malformed(slot);
939
983
  const out = [];
940
984
  for (const part of content) {
941
- if (!isObj(part)) {
985
+ if (!isObj(part)) return malformed(`${slot} part`);
986
+ refuse(part);
987
+ if (part.type !== "input_text" && part.type !== "output_text") {
942
988
  out.push(part);
943
989
  continue;
944
990
  }
945
- refuse(part);
946
- 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({
947
993
  ...part,
948
994
  text: await outbound(transform, part.text, leaf)
949
- } : part);
995
+ });
950
996
  }
951
997
  return out;
952
998
  }
@@ -997,17 +1043,14 @@ function cachedSummary(item, ctx) {
997
1043
  return texts;
998
1044
  }
999
1045
  async function rewriteReasoning(item, ctx) {
1000
- 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");
1001
1049
  const cached = cachedSummary(item, ctx);
1002
1050
  const summary = [];
1003
- for (const [i, part] of item.summary.entries()) if (!isObj(part) || typeof part.text !== "string") summary.push(part);
1004
- else if (cached !== void 0) summary.push({
1051
+ for (const [i, part] of item.summary.entries()) summary.push({
1005
1052
  ...part,
1006
- text: cached[i]
1007
- });
1008
- else summary.push({
1009
- ...part,
1010
- text: await outbound(ctx.transform, part.text, { kind: "thinking" })
1053
+ text: cached !== void 0 ? cached[i] : await outbound(ctx.transform, part.text, { kind: "thinking" })
1011
1054
  });
1012
1055
  return {
1013
1056
  ...item,
@@ -1027,15 +1070,15 @@ function toolNamesByCallId(items) {
1027
1070
  async function rewriteItem(item, toolNames, ctx) {
1028
1071
  if (item.type === "message" || item.type === void 0 && item.role !== void 0) {
1029
1072
  const kind = roleKind(item.role);
1030
- if (kind === void 0) return item;
1073
+ if (kind === void 0) return malformed("message role");
1031
1074
  return {
1032
1075
  ...item,
1033
- content: await rewriteContent(item.content, { kind }, ctx.transform)
1076
+ content: await rewriteContent(item.content, "message content", { kind }, ctx.transform)
1034
1077
  };
1035
1078
  }
1036
1079
  switch (item.type) {
1037
1080
  case "function_call": {
1038
- if (typeof item.arguments !== "string") return item;
1081
+ if (typeof item.arguments !== "string") return malformed("function_call arguments");
1039
1082
  const toolName = typeof item.name === "string" ? item.name : void 0;
1040
1083
  return {
1041
1084
  ...item,
@@ -1049,7 +1092,7 @@ async function rewriteItem(item, toolNames, ctx) {
1049
1092
  const toolName = typeof item.call_id === "string" ? toolNames.get(item.call_id) : void 0;
1050
1093
  return {
1051
1094
  ...item,
1052
- output: await rewriteContent(item.output, {
1095
+ output: await rewriteContent(item.output, "function_call_output output", {
1053
1096
  kind: "toolResult",
1054
1097
  toolName
1055
1098
  }, ctx.transform)
@@ -1221,18 +1264,22 @@ function createStreamTransformer(ctx) {
1221
1264
  }
1222
1265
  const openaiResponsesWalker = {
1223
1266
  async request(body, ctx) {
1224
- if (!isObj(body)) return body;
1267
+ if (!isObj(body)) return malformed("request body");
1225
1268
  const out = { ...body };
1226
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");
1227
1271
  const instruction = ctx.transform.instruction;
1228
1272
  if (instruction !== void 0) out.instructions = typeof out.instructions === "string" && out.instructions !== "" ? `${instruction}\n\n${out.instructions}` : instruction;
1229
1273
  if (typeof body.input === "string") out.input = await outbound(ctx.transform, body.input, { kind: "user" });
1230
1274
  else if (Array.isArray(body.input)) {
1231
1275
  const toolNames = toolNamesByCallId(body.input);
1232
1276
  const input = [];
1233
- 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
+ }
1234
1281
  out.input = input;
1235
- }
1282
+ } else if (body.input !== void 0) return malformed("input");
1236
1283
  return out;
1237
1284
  },
1238
1285
  response(body, ctx) {
@@ -1460,7 +1507,7 @@ async function createTransformProxy(options) {
1460
1507
  /** Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. */
1461
1508
  async function relayUpstream(res, upstreamRes, rewrite) {
1462
1509
  if (upstreamRes === void 0) return;
1463
- const contentType = upstreamRes.headers.get("content-type") ?? "";
1510
+ const contentType = (upstreamRes.headers.get("content-type") ?? "").toLowerCase();
1464
1511
  const headers = relayHeaders(upstreamRes.headers);
1465
1512
  if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
1466
1513
  if (rewrite !== void 0 && upstreamRes.ok && contentType.includes("application/json")) {
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as TransformModule, a as TransformProxyOptions, c as TransformSpec, d as SessionFactPublisher, f as SessionFacts, g as TransformFactory, h as Transform, i as TransformProxy, l as Logger, m as TextLeafKind, n as SESSION_HEADER, o as UPSTREAM_HEADER, p as TextLeaf, r as TOKEN_HEADER, s as createTransformProxy, t as PIPELINE_HEADER, u as SessionFact, v as TransformSessionContext } from "./server-D5nBnO7T.mjs";
1
+ import { _ as TransformModule, a as TransformProxyOptions, c as TransformSpec, d as SessionFactPublisher, f as SessionFacts, g as TransformFactory, h as Transform, i as TransformProxy, l as Logger, m as TextLeafKind, n as SESSION_HEADER, o as UPSTREAM_HEADER, p as TextLeaf, r as TOKEN_HEADER, s as createTransformProxy, t as PIPELINE_HEADER, u as SessionFact, v as TransformSessionContext } from "./server-C_eKyCTV.mjs";
2
2
  /** One stage's streamed inbound transformer, as returned by `Transform.inboundStream()`. */
3
3
  interface InboundStream {
4
4
  push(chunk: string): string;
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-DQPsbYLi.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-D5nBnO7T.mjs";
1
+ import { a as TransformProxyOptions, i as TransformProxy } from "../server-C_eKyCTV.mjs";
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,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-DQPsbYLi.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;
@@ -12,7 +12,7 @@ interface TextLeaf {
12
12
  }
13
13
  /** One session's instance of one pipeline stage. */
14
14
  interface Transform {
15
- /** Prepended to the system prompt; omit when the model needs no instruction. */
15
+ /** Added to the system prompt by each wire walker (Anthropic: appended last; OpenAI: placed first); omit when the model needs no instruction. */
16
16
  readonly instruction?: string;
17
17
  /** Host -> model, one text leaf. A throw refuses the request (502 `transform_failed`); nothing is sent. */
18
18
  outbound(text: string, leaf: TextLeaf): Promise<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/transform-proxy",
3
- "version": "0.2.0",
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",