@skaile/transform-proxy 0.0.0-stage → 0.2.0
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 +10 -0
- package/LICENSE +21 -0
- package/README.md +122 -2
- package/dist/cli.d.mts +17 -0
- package/dist/cli.mjs +116 -0
- package/dist/config-DQPsbYLi.mjs +1717 -0
- package/dist/index.d.mts +88 -0
- package/dist/index.mjs +1 -0
- package/dist/omp/index.d.mts +89 -0
- package/dist/omp/index.mjs +345 -0
- package/dist/server-D5nBnO7T.d.mts +96 -0
- package/package.json +68 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- omp extension (`@skaile/transform-proxy/omp`, manifest `omp.extensions`): `/anon on|off|status` routes every provider of omp's model registry through the proxy (an external one from `SKAILE_TRANSFORM_PROXY_URL`, or one started in-process from the CLI config lookup), shows `anon on` / `anon off` in the footer, persists the flag in omp's agent dir and refuses `off` under `SKAILE_TRANSFORM_PROXY_MODE=required`.
|
|
7
|
+
|
|
8
|
+
## 0.1.0
|
|
9
|
+
|
|
10
|
+
- First release: transform contract, chain composition, per-session state and facts, the HTTP proxy with the Anthropic Messages walker, and the `skaile-transform-proxy` CLI.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthias Ortegro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @skaile/transform-proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A same-protocol passthrough HTTP proxy. A model client points its base URL at the proxy; the proxy runs every text leaf of the request through a chain of transforms (outbound), forwards the request with the client's own auth, and restores the reply (inbound), in JSON and in streamed SSE.
|
|
4
|
+
|
|
5
|
+
Anthropic in is Anthropic out. Only text fields are rewritten; everything else is forwarded byte for byte.
|
|
6
|
+
|
|
7
|
+
## Headers
|
|
8
|
+
|
|
9
|
+
| Header | Purpose |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `x-skaile-transform-upstream` | Upstream base URL, for example `https://api.anthropic.com`. Required. |
|
|
12
|
+
| `x-skaile-transform-session` | Session id. Defaults to `default`. Transform state is kept per session. |
|
|
13
|
+
| `x-skaile-transform-pipeline` | Pipeline name. Defaults to `defaultPipeline`; without either, the request is refused. |
|
|
14
|
+
| `x-skaile-transform-token` | Client token. Required when a token is configured. |
|
|
15
|
+
|
|
16
|
+
The proxy strips all four headers before forwarding. Every other header, including `Authorization` and `x-api-key`, is relayed unchanged.
|
|
17
|
+
|
|
18
|
+
## Routes
|
|
19
|
+
|
|
20
|
+
| Path suffix | Handling |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `POST …/messages` | Anthropic request and response: request rewritten, JSON reply and SSE stream restored. |
|
|
23
|
+
| `POST …/messages/count_tokens` | Request rewritten; the response passes through untouched. |
|
|
24
|
+
| `GET …/models` | Passthrough. |
|
|
25
|
+
| anything else (chat completions, responses, embeddings) | 404 `unsupported_route`. |
|
|
26
|
+
|
|
27
|
+
OpenAI chat and responses walkers are not implemented yet. Embeddings are refused on purpose: a vector of pseudonymized input cannot be restored.
|
|
28
|
+
|
|
29
|
+
## Refusals
|
|
30
|
+
|
|
31
|
+
- Attachments (image, document, file parts) are refused with 400 `unsupported_content`. Nothing is sent upstream.
|
|
32
|
+
- Unknown routes, pipelines and upstreams are refused before any body is read or transform is built.
|
|
33
|
+
- A transform that throws refuses the request with 502 `transform_failed`. Nothing is sent upstream.
|
|
34
|
+
- Bedrock (SigV4 covers the body) and Vertex are not routed. Refuse them in the caller.
|
|
35
|
+
- A non-loopback `host` requires a token.
|
|
36
|
+
- `OPTIONS` is refused: 401 without a token, 405 with one.
|
|
37
|
+
|
|
38
|
+
Error bodies have the form `{ "error": { "type": "...", "message": "..." } }`.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
# .skaile/transform-proxy.yaml
|
|
44
|
+
transforms:
|
|
45
|
+
anon:
|
|
46
|
+
module: "@skaile/forge-anon/proxy"
|
|
47
|
+
config: { config: anon.config.yaml }
|
|
48
|
+
pipelines:
|
|
49
|
+
anon: [anon]
|
|
50
|
+
defaultPipeline: anon
|
|
51
|
+
allowUpstreams: # optional; default: any https origin, plus loopback http
|
|
52
|
+
- https://api.anthropic.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Keys are exactly the option fields. An unknown top-level key is an error. Module specifiers resolve against the config file's directory.
|
|
56
|
+
|
|
57
|
+
## CLI
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- The token comes from `--token` or `SKAILE_TRANSFORM_PROXY_TOKEN`.
|
|
64
|
+
- Without `--config`, the proxy reads `./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`.
|
|
65
|
+
- On start it prints `listening <url>`. It stops on SIGINT or SIGTERM.
|
|
66
|
+
|
|
67
|
+
## Library
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createTransformProxy } from '@skaile/transform-proxy'
|
|
71
|
+
|
|
72
|
+
const proxy = await createTransformProxy({
|
|
73
|
+
baseDir: process.cwd(),
|
|
74
|
+
transforms: { anon: { module: '@skaile/forge-anon/proxy', config: { config: 'anon.config.yaml' } } },
|
|
75
|
+
pipelines: { anon: ['anon'] },
|
|
76
|
+
defaultPipeline: 'anon',
|
|
77
|
+
})
|
|
78
|
+
proxy.session('s1').publisher('connector.patients').publish([{ kind: 'NAME_PATIENT', value: 'Alice' }])
|
|
79
|
+
// ... clients use proxy.url with the headers above ...
|
|
80
|
+
await proxy.dropSession('s1')
|
|
81
|
+
await proxy.close()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## omp extension
|
|
85
|
+
|
|
86
|
+
The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform-proxy/omp`, declared in the package manifest under `omp.extensions`):
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
omp plugin install @skaile/transform-proxy
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It adds the command `/anon on|off|status` and the footer status `anon on` / `anon off`.
|
|
93
|
+
|
|
94
|
+
- `/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
|
+
- With `SKAILE_TRANSFORM_PROXY_URL` (and `SKAILE_TRANSFORM_PROXY_TOKEN`) set, it uses that running proxy, and the proxy applies its `defaultPipeline`.
|
|
96
|
+
- 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
|
+
- 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.
|
|
99
|
+
- `/anon off` restores the original base URLs and headers.
|
|
100
|
+
- `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, and the routed and refused providers.
|
|
101
|
+
- 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.
|
|
102
|
+
- `SKAILE_TRANSFORM_PROXY_MODE=required` (set by a skaile session in required mode) turns routing on at session start and refuses `/anon off`.
|
|
103
|
+
|
|
104
|
+
The overrides are registered under the model-registry source `skaile-transform-proxy`, so `/anon off` clears them in one catalog reload. A provider that another extension registered at runtime is taken over while anon is on; `/anon off` restores its base URL and headers, and a provider that existed only at runtime is reported as lost (restart omp to restore it).
|
|
105
|
+
|
|
106
|
+
## Writing a transform module
|
|
107
|
+
|
|
108
|
+
A module default-exports a `TransformModule`:
|
|
109
|
+
|
|
110
|
+
- `open(config, { baseDir, log })` runs once per proxy. It validates the config and builds shared state. Throwing rejects startup.
|
|
111
|
+
- It returns a `TransformFactory`, which runs once per `(session, pipeline)`: `(ctx: { sessionId, facts, log }) => Transform`.
|
|
112
|
+
- A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()` and `dispose?()`.
|
|
113
|
+
|
|
114
|
+
`inboundStream().push(c1) + … + flush()` must equal `inbound(c1 + … + cn)` for every chunking.
|
|
115
|
+
|
|
116
|
+
Connectors publish facts through `proxy.session(id).publisher(source)`. Facts are in-process only.
|
|
117
|
+
|
|
118
|
+
## Security notes
|
|
119
|
+
|
|
120
|
+
- The default host is `127.0.0.1`. Set a token before binding elsewhere.
|
|
121
|
+
- Use `allowUpstreams` to limit where requests can go.
|
|
122
|
+
- Clear text never leaves the proxy in a transformed field. Auth headers are relayed, not logged.
|
|
123
|
+
- Mappings, facts and the signature cache are kept in memory and are lost on restart.
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { i as TransformProxy } from "./server-D5nBnO7T.mjs";
|
|
2
|
+
/** Environment variable that supplies the client token when `--token` is absent. */
|
|
3
|
+
export declare const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
|
|
4
|
+
export interface CliEnvironment {
|
|
5
|
+
readonly cwd: string;
|
|
6
|
+
readonly home: string;
|
|
7
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
8
|
+
readonly stdout: (line: string) => void;
|
|
9
|
+
readonly stderr: (line: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/** `proxy` is set only when the server started; the caller owns its shutdown. */
|
|
12
|
+
export interface CliRun {
|
|
13
|
+
readonly code: number;
|
|
14
|
+
readonly proxy?: TransformProxy;
|
|
15
|
+
}
|
|
16
|
+
/** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
|
|
17
|
+
export declare function runCli(argv: readonly string[], io: CliEnvironment): Promise<CliRun>;
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { d as createTransformProxy, o as resolveProxyConfig, v as errorMessage } from "./config-DQPsbYLi.mjs";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
// src/cli.ts
|
|
8
|
+
/** Environment variable that supplies the client token when `--token` is absent. */
|
|
9
|
+
const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
|
|
10
|
+
const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>]";
|
|
11
|
+
/** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
|
|
12
|
+
async function runCli(argv, io) {
|
|
13
|
+
let values;
|
|
14
|
+
try {
|
|
15
|
+
({values} = parseArgs({
|
|
16
|
+
args: [...argv],
|
|
17
|
+
allowPositionals: false,
|
|
18
|
+
options: {
|
|
19
|
+
config: { type: "string" },
|
|
20
|
+
port: { type: "string" },
|
|
21
|
+
host: { type: "string" },
|
|
22
|
+
token: { type: "string" },
|
|
23
|
+
help: {
|
|
24
|
+
type: "boolean",
|
|
25
|
+
short: "h"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
io.stderr(errorMessage(err));
|
|
31
|
+
io.stderr(USAGE);
|
|
32
|
+
return { code: 2 };
|
|
33
|
+
}
|
|
34
|
+
if (values.help === true) {
|
|
35
|
+
io.stdout(USAGE);
|
|
36
|
+
return { code: 0 };
|
|
37
|
+
}
|
|
38
|
+
const port = parsePort(values.port);
|
|
39
|
+
if (port === void 0 && values.port !== void 0) {
|
|
40
|
+
io.stderr(`--port must be an integer from 0 to 65535, got "${values.port}"`);
|
|
41
|
+
return { code: 2 };
|
|
42
|
+
}
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = resolveProxyConfig({
|
|
46
|
+
explicit: values.config,
|
|
47
|
+
cwd: io.cwd,
|
|
48
|
+
home: io.home
|
|
49
|
+
});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
io.stderr(errorMessage(err));
|
|
52
|
+
return { code: 1 };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const proxy = await createTransformProxy({
|
|
56
|
+
...config.options,
|
|
57
|
+
host: values.host,
|
|
58
|
+
port,
|
|
59
|
+
token: values.token ?? io.env["SKAILE_TRANSFORM_PROXY_TOKEN"],
|
|
60
|
+
log: {
|
|
61
|
+
debug: () => {},
|
|
62
|
+
info: () => {},
|
|
63
|
+
warn: (msg, ...args) => io.stderr(format("warn", msg, args)),
|
|
64
|
+
error: (msg, ...args) => io.stderr(format("error", msg, args))
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
io.stdout(`listening ${proxy.url}`);
|
|
68
|
+
return {
|
|
69
|
+
code: 0,
|
|
70
|
+
proxy
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
io.stderr(errorMessage(err));
|
|
74
|
+
return { code: 1 };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function parsePort(value) {
|
|
78
|
+
if (value === void 0 || !/^\d+$/.test(value)) return void 0;
|
|
79
|
+
const port = Number(value);
|
|
80
|
+
return port <= 65535 ? port : void 0;
|
|
81
|
+
}
|
|
82
|
+
function format(level, msg, args) {
|
|
83
|
+
return [`[transform-proxy ${level}] ${msg}`, ...args.map(String)].join(" ");
|
|
84
|
+
}
|
|
85
|
+
/** True when this module is the executed entry point (the bin, or `bun src/cli.ts`). */
|
|
86
|
+
function isEntryPoint() {
|
|
87
|
+
const entry = process.argv[1];
|
|
88
|
+
if (entry === void 0) return false;
|
|
89
|
+
try {
|
|
90
|
+
return realpathSync(entry) === fileURLToPath(import.meta.url);
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (isEntryPoint()) {
|
|
96
|
+
const run = await runCli(process.argv.slice(2), {
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
home: homedir(),
|
|
99
|
+
env: process.env,
|
|
100
|
+
stdout: (line) => console.log(line),
|
|
101
|
+
stderr: (line) => console.error(line)
|
|
102
|
+
});
|
|
103
|
+
if (run.proxy === void 0) process.exitCode = run.code;
|
|
104
|
+
else {
|
|
105
|
+
const proxy = run.proxy;
|
|
106
|
+
const stop = () => {
|
|
107
|
+
proxy.close().then(() => process.exit(0), (err) => {
|
|
108
|
+
console.error(errorMessage(err));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
process.once("SIGINT", stop);
|
|
113
|
+
process.once("SIGTERM", stop);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export { TOKEN_ENV, runCli };
|