@skaile/transform-proxy 0.2.2 → 0.2.4
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 +9 -0
- package/README.md +17 -5
- package/dist/cli.d.mts +2 -0
- package/dist/cli.mjs +13 -3
- package/dist/omp/index.d.mts +26 -5
- package/dist/omp/index.mjs +162 -38
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4
|
|
4
|
+
|
|
5
|
+
- omp extension: the status is one glyph instead of a word: `A` (on), `Ⱥ` (crossed A, off), `A!` (error; was `anon on`, `anon off`, `anon error`). It fits omp's `status` status-line segment; the README shows the `statusLine` config that puts it into the main line.
|
|
6
|
+
|
|
7
|
+
## 0.2.3
|
|
8
|
+
|
|
9
|
+
- omp extension: without `SKAILE_TRANSFORM_PROXY_URL`, `/anon on` starts the proxy as a child process under the system Node (`SKAILE_TRANSFORM_PROXY_NODE`, else `node` from `PATH`; Node ≥ 22) instead of inside omp. omp is a compiled Bun executable whose resolver cannot load transform modules with native or ML dependencies from `node_modules`: under omp 18.14.2, `/anon on` failed with `transform "anon": cannot load module @skaile/forge-anon/proxy: Cannot find package 'ibantools'`. The child runs this package's CLI with the found config, `--port 0 --host 127.0.0.1 --exit-with-stdin` and a random token, which every routed provider sends as `x-skaile-transform-token`. A child that exits before `listening <url>` or stays silent for 60 s fails `/anon on` with its last stderr lines (token redacted). The child exits with omp, does not keep omp's event loop alive, and is replaced on the next turn when it dies. A rotated-out proxy session is no longer dropped: the child keeps its state in memory until it exits, and no request carries that id again.
|
|
10
|
+
- CLI: `--exit-with-stdin` stops the proxy when its stdin closes, so a parent that spawns it with a stdin pipe takes it down on exit, SIGKILL included.
|
|
11
|
+
|
|
3
12
|
## 0.2.2
|
|
4
13
|
|
|
5
14
|
- 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.
|
package/README.md
CHANGED
|
@@ -60,12 +60,13 @@ Keys are exactly the option fields. An unknown top-level key is an error. Module
|
|
|
60
60
|
## CLI
|
|
61
61
|
|
|
62
62
|
```sh
|
|
63
|
-
skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>]
|
|
63
|
+
skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
- The token comes from `--token` or `SKAILE_TRANSFORM_PROXY_TOKEN`.
|
|
67
67
|
- Without `--config`, the proxy reads `./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`.
|
|
68
68
|
- On start it prints `listening <url>`. It stops on SIGINT or SIGTERM.
|
|
69
|
+
- `--exit-with-stdin` also stops it when its stdin closes. A parent process that spawns the proxy with a stdin pipe and never writes to it takes the proxy down when it exits, even on SIGKILL.
|
|
69
70
|
|
|
70
71
|
## Library
|
|
71
72
|
|
|
@@ -92,15 +93,26 @@ The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform
|
|
|
92
93
|
omp plugin install @skaile/transform-proxy
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
It adds the command `/anon on|off|status` and
|
|
96
|
+
It adds the command `/anon on|off|status` and a one-glyph status: `A` (on), `Ⱥ` (crossed A, off) or `A!` (error). Unless `SKAILE_TRANSFORM_PROXY_URL` names a running proxy, it needs Node ≥ 22 on `PATH` (or its path in `SKAILE_TRANSFORM_PROXY_NODE`).
|
|
97
|
+
|
|
98
|
+
omp shows extension statuses on an extra line below the status line. To show the glyph in the status line itself, add the `status` segment and turn the extra line off in `~/.omp/agent/config.yml` (`showHookStatus: false` moves every extension status, not just this one, into the segment):
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
statusLine:
|
|
102
|
+
preset: custom
|
|
103
|
+
leftSegments: [vim, model, status, mode, path, git, pr]
|
|
104
|
+
rightSegments: [session_name, token_total, cost, context_pct]
|
|
105
|
+
showHookStatus: false
|
|
106
|
+
```
|
|
96
107
|
|
|
97
108
|
- `/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.
|
|
98
109
|
- With `SKAILE_TRANSFORM_PROXY_URL` (and `SKAILE_TRANSFORM_PROXY_TOKEN`) set, it uses that running proxy, and the proxy applies its `defaultPipeline`.
|
|
99
|
-
- Otherwise it starts one proxy
|
|
110
|
+
- Otherwise it starts one proxy per omp process as a child process: the system Node (`SKAILE_TRANSFORM_PROXY_NODE`, else `node` from `PATH`) runs this package's CLI with the config lookup of the CLI (`./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`), `--port 0 --host 127.0.0.1 --exit-with-stdin` and a random token in `SKAILE_TRANSFORM_PROXY_TOKEN`, which every routed provider sends. That config must set `defaultPipeline`. Without a config, `/anon on` refuses and says where to put one. A child that exits before printing `listening <url>`, or stays silent for 60 s, fails `/anon on` with its last stderr lines.
|
|
111
|
+
- omp is a compiled Bun executable, and its resolver cannot load transform modules with native or ML dependencies (`onnxruntime-node`, `@huggingface/transformers`, `ibantools`) from `node_modules`; Node can. The child exits with omp: its stdin pipe closes when omp exits (SIGKILL included), and omp kills it on a normal exit. It does not keep omp's event loop alive. A child that dies later is replaced on the next turn.
|
|
100
112
|
- 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.
|
|
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
|
|
113
|
+
- 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 status shows `A!`.
|
|
102
114
|
- 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
|
|
115
|
+
- The proxy session follows the root omp session: on `/new`, `/resume`, `/fork` (`session_switch`) and on a root `session_start` with a new session id, every provider is re-routed with the new `x-skaile-transform-session`. The proxy keeps the old session's state in memory until it exits; no request carries that id again. A `session_start` of a headless `print` session (how omp runs subagents) keeps the parent's id, so subagents share its pseudonyms.
|
|
104
116
|
- `/anon off` restores the original base URLs and headers.
|
|
105
117
|
- `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, and the routed and refused providers.
|
|
106
118
|
- The enabled flag is saved in `skaile-transform-proxy.json` in omp's agent dir (`~/.omp/agent`; `SKAILE_TRANSFORM_PROXY_OMP_STATE` overrides the path) and re-applied when a session starts.
|
package/dist/cli.d.mts
CHANGED
|
@@ -12,6 +12,8 @@ export interface CliEnvironment {
|
|
|
12
12
|
export interface CliRun {
|
|
13
13
|
readonly code: number;
|
|
14
14
|
readonly proxy?: TransformProxy;
|
|
15
|
+
/** `--exit-with-stdin`: the caller stops the proxy when its stdin closes (the parent process is gone). */
|
|
16
|
+
readonly exitWithStdin?: boolean;
|
|
15
17
|
}
|
|
16
18
|
/** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
|
|
17
19
|
export declare function runCli(argv: readonly string[], io: CliEnvironment): Promise<CliRun>;
|
package/dist/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
// src/cli.ts
|
|
8
8
|
/** Environment variable that supplies the client token when `--token` is absent. */
|
|
9
9
|
const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
|
|
10
|
-
const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>]";
|
|
10
|
+
const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]";
|
|
11
11
|
/** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
|
|
12
12
|
async function runCli(argv, io) {
|
|
13
13
|
let values;
|
|
@@ -23,7 +23,8 @@ async function runCli(argv, io) {
|
|
|
23
23
|
help: {
|
|
24
24
|
type: "boolean",
|
|
25
25
|
short: "h"
|
|
26
|
-
}
|
|
26
|
+
},
|
|
27
|
+
"exit-with-stdin": { type: "boolean" }
|
|
27
28
|
}
|
|
28
29
|
}));
|
|
29
30
|
} catch (err) {
|
|
@@ -67,7 +68,8 @@ async function runCli(argv, io) {
|
|
|
67
68
|
io.stdout(`listening ${proxy.url}`);
|
|
68
69
|
return {
|
|
69
70
|
code: 0,
|
|
70
|
-
proxy
|
|
71
|
+
proxy,
|
|
72
|
+
exitWithStdin: values["exit-with-stdin"] === true
|
|
71
73
|
};
|
|
72
74
|
} catch (err) {
|
|
73
75
|
io.stderr(errorMessage(err));
|
|
@@ -103,7 +105,10 @@ if (isEntryPoint()) {
|
|
|
103
105
|
if (run.proxy === void 0) process.exitCode = run.code;
|
|
104
106
|
else {
|
|
105
107
|
const proxy = run.proxy;
|
|
108
|
+
let stopping = false;
|
|
106
109
|
const stop = () => {
|
|
110
|
+
if (stopping) return;
|
|
111
|
+
stopping = true;
|
|
107
112
|
proxy.close().then(() => process.exit(0), (err) => {
|
|
108
113
|
console.error(errorMessage(err));
|
|
109
114
|
process.exit(1);
|
|
@@ -111,6 +116,11 @@ if (isEntryPoint()) {
|
|
|
111
116
|
};
|
|
112
117
|
process.once("SIGINT", stop);
|
|
113
118
|
process.once("SIGTERM", stop);
|
|
119
|
+
if (run.exitWithStdin === true) {
|
|
120
|
+
process.stdin.once("end", stop);
|
|
121
|
+
process.stdin.once("close", stop);
|
|
122
|
+
process.stdin.resume();
|
|
123
|
+
}
|
|
114
124
|
}
|
|
115
125
|
}
|
|
116
126
|
export { TOKEN_ENV, runCli };
|
package/dist/omp/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Readable, Writable } from "node:stream";
|
|
2
2
|
interface OmpModel {
|
|
3
3
|
readonly provider: string;
|
|
4
4
|
readonly id: string;
|
|
@@ -67,13 +67,34 @@ interface OmpExtensionApi extends OmpSessionActions {
|
|
|
67
67
|
/** For `before_provider_request`, a non-undefined result replaces the request payload. */
|
|
68
68
|
on(event: OmpEventName, handler: (event: unknown, ctx: OmpContext) => unknown): void;
|
|
69
69
|
}
|
|
70
|
+
/** How the extension spawns its proxy child. */
|
|
71
|
+
interface ProxySpawnOptions {
|
|
72
|
+
readonly cwd: string;
|
|
73
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
74
|
+
readonly stdio: readonly ['pipe', 'pipe', 'pipe'];
|
|
75
|
+
}
|
|
76
|
+
/** The slice of a `node:child_process` `ChildProcess` the extension uses. */
|
|
77
|
+
interface ProxyChildProcess {
|
|
78
|
+
readonly pid?: number | undefined;
|
|
79
|
+
readonly stdin: Writable | null;
|
|
80
|
+
readonly stdout: Readable | null;
|
|
81
|
+
readonly stderr: Readable | null;
|
|
82
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
83
|
+
unref(): void;
|
|
84
|
+
on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
85
|
+
on(event: 'error', listener: (err: Error) => void): unknown;
|
|
86
|
+
}
|
|
70
87
|
interface AnonControllerDeps {
|
|
71
88
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
72
89
|
readonly home: string;
|
|
73
90
|
/** Default: `$SKAILE_TRANSFORM_PROXY_OMP_STATE`, else `<omp agent dir>/skaile-transform-proxy.json`. */
|
|
74
91
|
readonly statePath?: string;
|
|
75
|
-
/** Default: `
|
|
76
|
-
readonly
|
|
92
|
+
/** Spawns the proxy child. Default: `node:child_process` `spawn`. */
|
|
93
|
+
readonly spawnChild?: (command: string, args: readonly string[], options: ProxySpawnOptions) => ProxyChildProcess;
|
|
94
|
+
/** The CLI the child runs. Default: this package's `dist/cli.mjs`, next to the built extension's `dist/omp/`. */
|
|
95
|
+
readonly cliPath?: string;
|
|
96
|
+
/** How long the child may take to print `listening <url>`. Default: 60 s (transform modules may load ML models). */
|
|
97
|
+
readonly listenTimeoutMs?: number;
|
|
77
98
|
}
|
|
78
99
|
interface AnonController {
|
|
79
100
|
readonly required: boolean;
|
|
@@ -103,7 +124,7 @@ interface AnonController {
|
|
|
103
124
|
*
|
|
104
125
|
* `wanted`: model requests must go through the proxy (required mode, or `/anon on`).
|
|
105
126
|
* `enabled`: every provider is routed and the active model is rebound. `wanted` without
|
|
106
|
-
* `enabled` is the error state: the
|
|
127
|
+
* `enabled` is the error state: the status shows `A!` and `providerRequest` refuses
|
|
107
128
|
* every request that would bypass the proxy.
|
|
108
129
|
*/
|
|
109
130
|
export declare function createAnonController(deps: AnonControllerDeps): AnonController;
|
|
@@ -112,4 +133,4 @@ export declare function defaultStatePath(env: Readonly<Record<string, string | u
|
|
|
112
133
|
/** Registers the `/anon` command and the event handlers on one session's extension API. */
|
|
113
134
|
export declare function registerAnonExtension(pi: OmpExtensionApi, controller: AnonController): void;
|
|
114
135
|
export default function anonExtension(pi: OmpExtensionApi): void;
|
|
115
|
-
export type { AnonController, AnonControllerDeps, OmpAutocompleteItem, OmpContext, OmpEventName, OmpExtensionApi, OmpModel, OmpModelRegistry, OmpNotifyType, OmpProviderOverride, OmpSessionActions, OmpUi };
|
|
136
|
+
export type { AnonController, AnonControllerDeps, OmpAutocompleteItem, OmpContext, OmpEventName, OmpExtensionApi, OmpModel, OmpModelRegistry, OmpNotifyType, OmpProviderOverride, OmpSessionActions, OmpUi, ProxyChildProcess, ProxySpawnOptions };
|
package/dist/omp/index.mjs
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { c as SESSION_HEADER,
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { c as SESSION_HEADER, i as loadProxyConfig, l as TOKEN_HEADER, n as configCandidates, r as findProxyConfig, u as UPSTREAM_HEADER, v as errorMessage } from "../config-PKHxejC8.mjs";
|
|
2
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
6
8
|
// src/omp/anon.ts
|
|
7
9
|
/** A running proxy the extension routes to. */
|
|
8
10
|
const URL_ENV = "SKAILE_TRANSFORM_PROXY_URL";
|
|
9
|
-
/** The client token of that proxy. */
|
|
11
|
+
/** The client token of that proxy; also how the extension hands its own proxy child a random token. */
|
|
10
12
|
const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
|
|
13
|
+
/** The Node binary that runs the proxy child; default: `node` from `PATH`. */
|
|
14
|
+
const NODE_ENV = "SKAILE_TRANSFORM_PROXY_NODE";
|
|
11
15
|
/** `required` (set by a skaile session in required mode): routing is forced on and `/anon off` is refused. */
|
|
12
16
|
const MODE_ENV = "SKAILE_TRANSFORM_PROXY_MODE";
|
|
13
17
|
/** Overrides the path of the persisted enabled flag. */
|
|
@@ -46,14 +50,15 @@ const VERBS = [
|
|
|
46
50
|
*
|
|
47
51
|
* `wanted`: model requests must go through the proxy (required mode, or `/anon on`).
|
|
48
52
|
* `enabled`: every provider is routed and the active model is rebound. `wanted` without
|
|
49
|
-
* `enabled` is the error state: the
|
|
53
|
+
* `enabled` is the error state: the status shows `A!` and `providerRequest` refuses
|
|
50
54
|
* every request that would bypass the proxy.
|
|
51
55
|
*/
|
|
52
56
|
function createAnonController(deps) {
|
|
53
57
|
const env = deps.env;
|
|
54
58
|
const required = env[MODE_ENV] === "required";
|
|
55
59
|
const statePath = deps.statePath ?? defaultStatePath(env, deps.home);
|
|
56
|
-
const
|
|
60
|
+
const spawnChild = deps.spawnChild ?? spawnNode;
|
|
61
|
+
const listenTimeoutMs = deps.listenTimeoutMs ?? 6e4;
|
|
57
62
|
const registries = /* @__PURE__ */ new WeakMap();
|
|
58
63
|
let wanted = required;
|
|
59
64
|
let enabled = false;
|
|
@@ -72,7 +77,7 @@ function createAnonController(deps) {
|
|
|
72
77
|
return state;
|
|
73
78
|
}
|
|
74
79
|
async function resolveEndpoint(ctx) {
|
|
75
|
-
if (endpoint !== void 0) return endpoint;
|
|
80
|
+
if (endpoint !== void 0 && endpoint.child?.exited !== true) return endpoint;
|
|
76
81
|
const url = env[URL_ENV];
|
|
77
82
|
if (url !== void 0 && url !== "") {
|
|
78
83
|
const token = env[TOKEN_ENV];
|
|
@@ -83,13 +88,17 @@ function createAnonController(deps) {
|
|
|
83
88
|
};
|
|
84
89
|
return endpoint;
|
|
85
90
|
}
|
|
86
|
-
starting ??=
|
|
91
|
+
starting ??= startChild(ctx.cwd).finally(() => {
|
|
87
92
|
starting = void 0;
|
|
88
93
|
});
|
|
89
94
|
endpoint = await starting;
|
|
90
95
|
return endpoint;
|
|
91
96
|
}
|
|
92
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Runs the proxy CLI under the system Node: omp is a compiled Bun executable whose resolver
|
|
99
|
+
* cannot load transform modules with native or ML dependencies from `node_modules`.
|
|
100
|
+
*/
|
|
101
|
+
async function startChild(cwd) {
|
|
93
102
|
const path = findProxyConfig({
|
|
94
103
|
cwd,
|
|
95
104
|
home: deps.home
|
|
@@ -100,15 +109,45 @@ function createAnonController(deps) {
|
|
|
100
109
|
}
|
|
101
110
|
const options = loadProxyConfig(path);
|
|
102
111
|
if (options.defaultPipeline === void 0) throw new Error(`${path} sets no defaultPipeline: the omp extension sends no pipeline header`);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
const token = randomBytes(32).toString("base64url");
|
|
113
|
+
const node = env["SKAILE_TRANSFORM_PROXY_NODE"] || "node";
|
|
114
|
+
const cli = deps.cliPath ?? fileURLToPath(new URL("../cli.mjs", import.meta.url));
|
|
115
|
+
const child = spawnChild(node, [
|
|
116
|
+
cli,
|
|
117
|
+
"--config",
|
|
118
|
+
path,
|
|
119
|
+
"--port",
|
|
120
|
+
"0",
|
|
121
|
+
"--host",
|
|
122
|
+
"127.0.0.1",
|
|
123
|
+
"--exit-with-stdin"
|
|
124
|
+
], {
|
|
125
|
+
cwd,
|
|
126
|
+
env: {
|
|
127
|
+
...env,
|
|
128
|
+
[TOKEN_ENV]: token
|
|
129
|
+
},
|
|
130
|
+
stdio: [
|
|
131
|
+
"pipe",
|
|
132
|
+
"pipe",
|
|
133
|
+
"pipe"
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
const state = { exited: false };
|
|
137
|
+
child.on("exit", () => {
|
|
138
|
+
state.exited = true;
|
|
139
|
+
if (endpoint?.child === state) endpoint = void 0;
|
|
106
140
|
});
|
|
107
141
|
return {
|
|
108
|
-
url:
|
|
142
|
+
url: await waitForListening(child, {
|
|
143
|
+
node,
|
|
144
|
+
token,
|
|
145
|
+
timeoutMs: listenTimeoutMs
|
|
146
|
+
}),
|
|
147
|
+
token,
|
|
109
148
|
pipeline: options.defaultPipeline,
|
|
110
|
-
source: `
|
|
111
|
-
|
|
149
|
+
source: `child process ${child.pid}, config ${path}`,
|
|
150
|
+
child: state
|
|
112
151
|
};
|
|
113
152
|
}
|
|
114
153
|
function currentSessionId(ctx) {
|
|
@@ -116,27 +155,13 @@ function createAnonController(deps) {
|
|
|
116
155
|
return sessionId;
|
|
117
156
|
}
|
|
118
157
|
/**
|
|
119
|
-
* Adopts the session id of a root session
|
|
120
|
-
*
|
|
121
|
-
*
|
|
158
|
+
* Adopts the session id of a root session. The first session of the process is the root;
|
|
159
|
+
* later `session_start`s are subagents (headless, `print` mode) unless the session has a UI
|
|
160
|
+
* or runs in another mode.
|
|
122
161
|
*/
|
|
123
162
|
function enterSession(ctx, root) {
|
|
124
|
-
|
|
125
|
-
if (
|
|
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
|
-
}
|
|
163
|
+
if (sessionId === void 0) currentSessionId(ctx);
|
|
164
|
+
else if (root) sessionId = readSessionId(ctx) ?? sessionId;
|
|
140
165
|
}
|
|
141
166
|
function route(ctx, target) {
|
|
142
167
|
const registry = ctx.modelRegistry;
|
|
@@ -240,8 +265,9 @@ function createAnonController(deps) {
|
|
|
240
265
|
function stateLabel() {
|
|
241
266
|
return enabled ? "on" : wanted ? "error" : "off";
|
|
242
267
|
}
|
|
268
|
+
/** One glyph per state, so the status fits into omp's main status line: `A` on, `Ⱥ` (crossed A) off, `A!` error. */
|
|
243
269
|
function showStatus(ctx) {
|
|
244
|
-
ctx.ui.setStatus(STATUS_KEY,
|
|
270
|
+
ctx.ui.setStatus(STATUS_KEY, enabled ? "A" : wanted ? "A!" : "Ⱥ");
|
|
245
271
|
}
|
|
246
272
|
function statusText(ctx) {
|
|
247
273
|
const registry = ctx.modelRegistry;
|
|
@@ -264,9 +290,13 @@ function createAnonController(deps) {
|
|
|
264
290
|
if (preRouted.length > 0) lines.push(`routed by their own config (${preRouted.length}): ${preRouted.sort().join(", ")}`);
|
|
265
291
|
return lines.join("\n");
|
|
266
292
|
}
|
|
267
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* Session entry: rotate the proxy session for a new root, then (re-)apply routing when wanted.
|
|
295
|
+
* Rotation only switches the session header: the proxy keeps the old session's state in memory
|
|
296
|
+
* until it exits, and no request carries that id again.
|
|
297
|
+
*/
|
|
268
298
|
async function start(ctx, session, root) {
|
|
269
|
-
|
|
299
|
+
enterSession(ctx, root);
|
|
270
300
|
if (!wanted && readEnabled(statePath)) wanted = true;
|
|
271
301
|
if (wanted) {
|
|
272
302
|
const failure = await activate(ctx, session);
|
|
@@ -275,7 +305,6 @@ function createAnonController(deps) {
|
|
|
275
305
|
ctx.ui.notify(refusalHint(), "error");
|
|
276
306
|
}
|
|
277
307
|
}
|
|
278
|
-
if (previous !== void 0) await dropProxySession(ctx, previous);
|
|
279
308
|
showStatus(ctx);
|
|
280
309
|
}
|
|
281
310
|
return {
|
|
@@ -370,6 +399,101 @@ function readEnabled(path) {
|
|
|
370
399
|
return false;
|
|
371
400
|
}
|
|
372
401
|
}
|
|
402
|
+
function spawnNode(command, args, options) {
|
|
403
|
+
return spawn(command, [...args], {
|
|
404
|
+
cwd: options.cwd,
|
|
405
|
+
env: options.env,
|
|
406
|
+
stdio: [...options.stdio]
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/** The stderr lines a startup failure quotes. */
|
|
410
|
+
const STDERR_TAIL = 10;
|
|
411
|
+
/**
|
|
412
|
+
* Resolves with the URL of the child's `listening <url>` line. A child that exits first, fails
|
|
413
|
+
* to spawn or stays silent past `timeoutMs` (it is then killed) rejects, quoting its last stderr
|
|
414
|
+
* lines with the token redacted. omp exiting kills the child; once listening, the child and its
|
|
415
|
+
* pipes no longer hold omp's event loop, and stdin stays open so the child sees omp go away.
|
|
416
|
+
*/
|
|
417
|
+
function waitForListening(child, how) {
|
|
418
|
+
const killOnExit = () => {
|
|
419
|
+
child.kill("SIGTERM");
|
|
420
|
+
};
|
|
421
|
+
process.on("exit", killOnExit);
|
|
422
|
+
const forgetExitHook = () => {
|
|
423
|
+
process.off("exit", killOnExit);
|
|
424
|
+
};
|
|
425
|
+
child.on("exit", forgetExitHook);
|
|
426
|
+
for (const stream of [
|
|
427
|
+
child.stdin,
|
|
428
|
+
child.stdout,
|
|
429
|
+
child.stderr
|
|
430
|
+
]) stream?.on("error", () => {});
|
|
431
|
+
const listening = Promise.withResolvers();
|
|
432
|
+
const label = `the transform proxy child process (${how.node}, pid ${child.pid})`;
|
|
433
|
+
const tail = [];
|
|
434
|
+
let stderrPartial = "";
|
|
435
|
+
let stdout = "";
|
|
436
|
+
let settled = false;
|
|
437
|
+
function stderrTail() {
|
|
438
|
+
const lines = [...tail, stderrPartial].filter((line) => line.trim() !== "").slice(-10);
|
|
439
|
+
return lines.length === 0 ? "(none)" : lines.map((line) => line.split(how.token).join("[token]")).join("\n");
|
|
440
|
+
}
|
|
441
|
+
function fail(message) {
|
|
442
|
+
if (settled) return;
|
|
443
|
+
settled = true;
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
listening.reject(new Error(message));
|
|
446
|
+
}
|
|
447
|
+
const timer = setTimeout(() => {
|
|
448
|
+
fail(`${label} did not print "listening" within ${duration(how.timeoutMs)} and was killed; last stderr lines:\n${stderrTail()}`);
|
|
449
|
+
child.kill("SIGKILL");
|
|
450
|
+
}, how.timeoutMs);
|
|
451
|
+
child.stderr?.on("data", (chunk) => {
|
|
452
|
+
if (settled) return;
|
|
453
|
+
const lines = (stderrPartial + String(chunk)).split("\n");
|
|
454
|
+
stderrPartial = lines.pop() ?? "";
|
|
455
|
+
tail.push(...lines.map((line) => line.replace(/\r$/, "")));
|
|
456
|
+
tail.splice(0, Math.max(0, tail.length - STDERR_TAIL));
|
|
457
|
+
});
|
|
458
|
+
child.stdout?.on("data", (chunk) => {
|
|
459
|
+
if (settled) return;
|
|
460
|
+
const lines = (stdout + String(chunk)).split("\n");
|
|
461
|
+
stdout = lines.pop() ?? "";
|
|
462
|
+
for (const line of lines) {
|
|
463
|
+
const match = /^listening (\S+)$/.exec(line.trim());
|
|
464
|
+
if (match === null) continue;
|
|
465
|
+
settled = true;
|
|
466
|
+
clearTimeout(timer);
|
|
467
|
+
stdout = "";
|
|
468
|
+
child.unref();
|
|
469
|
+
for (const stream of [
|
|
470
|
+
child.stdin,
|
|
471
|
+
child.stdout,
|
|
472
|
+
child.stderr
|
|
473
|
+
]) stream?.unref?.();
|
|
474
|
+
listening.resolve(match[1]);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
child.on("exit", (code, signal) => {
|
|
479
|
+
if (settled) return;
|
|
480
|
+
const report = () => fail(`${label} exited with ${code === null ? `signal ${signal}` : `code ${code}`} before it was listening; last stderr lines:\n${stderrTail()}`);
|
|
481
|
+
const stderr = child.stderr;
|
|
482
|
+
if (stderr === null || stderr.readableEnded || stderr.destroyed) report();
|
|
483
|
+
else {
|
|
484
|
+
stderr.once("end", report);
|
|
485
|
+
stderr.once("close", report);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
child.on("error", (err) => {
|
|
489
|
+
forgetExitHook();
|
|
490
|
+
fail(`could not start the transform proxy with "${how.node}": ${err.message}; it runs under Node >= 22: put node on PATH or set ${NODE_ENV}`);
|
|
491
|
+
});
|
|
492
|
+
return listening.promise;
|
|
493
|
+
}
|
|
494
|
+
function duration(ms) {
|
|
495
|
+
return ms % 1e3 === 0 ? `${ms / 1e3} s` : `${ms} ms`;
|
|
496
|
+
}
|
|
373
497
|
/** Every provider of the registry with the wire APIs of its models. */
|
|
374
498
|
function providerApis(registry) {
|
|
375
499
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -416,7 +540,7 @@ function originOf(url) {
|
|
|
416
540
|
}
|
|
417
541
|
}
|
|
418
542
|
// src/omp/index.ts
|
|
419
|
-
/** omp re-imports the extension per session; the controller (proxy, recorded originals) is process-wide. */
|
|
543
|
+
/** omp re-imports the extension per session; the controller (proxy child, recorded originals) is process-wide. */
|
|
420
544
|
const CONTROLLER_KEY = Symbol.for("@skaile/transform-proxy/omp");
|
|
421
545
|
/** Registers the `/anon` command and the event handlers on one session's extension API. */
|
|
422
546
|
function registerAnonExtension(pi, controller) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaile/transform-proxy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|