@skaile/transform-proxy 0.0.0-stage → 0.1.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 +5 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/dist/cli.d.mts +17 -0
- package/dist/cli.mjs +116 -0
- package/dist/config-BKD-ILIz.mjs +1060 -0
- package/dist/index.d.mts +86 -0
- package/dist/index.mjs +1 -0
- package/dist/server-D5nBnO7T.d.mts +96 -0
- package/package.json +47 -4
package/CHANGELOG.md
ADDED
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,101 @@
|
|
|
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
|
+
## Writing a transform module
|
|
85
|
+
|
|
86
|
+
A module default-exports a `TransformModule`:
|
|
87
|
+
|
|
88
|
+
- `open(config, { baseDir, log })` runs once per proxy. It validates the config and builds shared state. Throwing rejects startup.
|
|
89
|
+
- It returns a `TransformFactory`, which runs once per `(session, pipeline)`: `(ctx: { sessionId, facts, log }) => Transform`.
|
|
90
|
+
- A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()` and `dispose?()`.
|
|
91
|
+
|
|
92
|
+
`inboundStream().push(c1) + … + flush()` must equal `inbound(c1 + … + cn)` for every chunking.
|
|
93
|
+
|
|
94
|
+
Connectors publish facts through `proxy.session(id).publisher(source)`. Facts are in-process only.
|
|
95
|
+
|
|
96
|
+
## Security notes
|
|
97
|
+
|
|
98
|
+
- The default host is `127.0.0.1`. Set a token before binding elsewhere.
|
|
99
|
+
- Use `allowUpstreams` to limit where requests can go.
|
|
100
|
+
- Clear text never leaves the proxy in a transformed field. Auth headers are relayed, not logged.
|
|
101
|
+
- 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, g as errorMessage, o as resolveProxyConfig } from "./config-BKD-ILIz.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 };
|