@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 +11 -0
- package/README.md +11 -4
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/{config-DQPsbYLi.mjs → config-PKHxejC8.mjs} +110 -63
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/omp/index.d.mts +32 -6
- package/dist/omp/index.mjs +152 -46
- package/dist/{server-D5nBnO7T.d.mts → server-C_eKyCTV.d.mts} +1 -1
- package/package.json +1 -1
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 (
|
|
27
|
+
| anything else (embeddings included) | 404 `unsupported_route`. |
|
|
26
28
|
|
|
27
|
-
|
|
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`
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
332
|
-
out.push(
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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 `${
|
|
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
|
-
}
|
|
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 (
|
|
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)
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
624
|
-
out.push(
|
|
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
|
-
}
|
|
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 (
|
|
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)
|
|
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(
|
|
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
|
-
}
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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
|
|
936
|
-
|
|
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
|
|
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
|
-
|
|
946
|
-
out.push(
|
|
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
|
-
}
|
|
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 (
|
|
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())
|
|
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
|
|
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
|
|
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)
|
|
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-
|
|
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-
|
|
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";
|
package/dist/omp/index.d.mts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { a as TransformProxyOptions, i as TransformProxy } from "../server-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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`). */
|
package/dist/omp/index.mjs
CHANGED
|
@@ -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-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
return
|
|
195
|
+
enabled = false;
|
|
196
|
+
return errorMessage(err);
|
|
169
197
|
}
|
|
170
198
|
route(ctx, target);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
181
|
-
if (
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 (!
|
|
265
|
-
|
|
266
|
-
await
|
|
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
|
-
/**
|
|
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.
|
|
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",
|