mtok-bridge 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/LICENSE +21 -0
- package/README.md +43 -0
- package/mtok-bridge.mjs +75 -0
- package/package.json +36 -0
- package/src/bridge.mjs +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roy Ashbrook
|
|
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
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# mtok-bridge
|
|
2
|
+
|
|
3
|
+
Serve any model as an OpenAI-compatible API, gated by a key. No payment, no market, no account,
|
|
4
|
+
nothing reported anywhere. Runs anywhere node runs.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
npx mtok-bridge --upstream https://api.openai.com/v1 --upstream-key sk-... --model gpt-4o-mini
|
|
8
|
+
npx mtok-bridge --upstream http://localhost:11434/v1 --model llama3.2 # a local ollama server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It prints an endpoint and an api key. Hand them to whoever should use it:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
curl http://localhost:8790/v1/chat/completions \
|
|
15
|
+
-H "authorization: Bearer <the-printed-key>" \
|
|
16
|
+
-H "content-type: application/json" \
|
|
17
|
+
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## flags
|
|
21
|
+
|
|
22
|
+
- `--upstream <url>` (required): any OpenAI-compatible chat/completions root (a provider, or your
|
|
23
|
+
own model server: ollama, LM Studio, vLLM, etc).
|
|
24
|
+
- `--upstream-key <key>`: the upstream's bearer token, if it needs one.
|
|
25
|
+
- `--model <id>`: a model you serve (repeatable, or a comma list). Omit to pass the upstream's
|
|
26
|
+
default through.
|
|
27
|
+
- `--port <n>`: default 8790.
|
|
28
|
+
- `--api-key <key>`: the key clients send. Omit and one is generated + printed for you.
|
|
29
|
+
- `--keyless`: serve with NO key (anyone who can reach the endpoint can use it). Opt-in.
|
|
30
|
+
|
|
31
|
+
## want to get paid for it?
|
|
32
|
+
|
|
33
|
+
The bridge is the transport half of [mtok.market](https://mtok.market)'s seller relay. When you
|
|
34
|
+
want to get PAID for a model (on-chain, per call, in USDC on Base) and be discovered on the market
|
|
35
|
+
board instead of handing out keys, the market relay wraps this exact bridge with settlement. Same
|
|
36
|
+
tool, one layer on top.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
Read-only public mirror. The source of truth is the private mtok.market
|
|
42
|
+
monorepo; this repo is synced automatically. Do not open pull requests here.
|
|
43
|
+
Home: https://mtok.market
|
package/mtok-bridge.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mtok-bridge CLI: serve a model as an OpenAI-compatible API, keyed, no payment, no market.
|
|
3
|
+
// Runs anywhere node runs. Point it at any OpenAI-compatible upstream (a provider, or your own
|
|
4
|
+
// local model server), hand out the endpoint + key. When you want to get PAID + discovered for
|
|
5
|
+
// the same model, the market relay (npx mtok-relay) wraps this exact core with on-chain settlement.
|
|
6
|
+
//
|
|
7
|
+
// npx mtok-bridge --upstream https://api.openai.com/v1 --upstream-key sk-... --model gpt-4o-mini
|
|
8
|
+
// npx mtok-bridge --upstream http://localhost:11434/v1 --model llama3.2 # a local ollama server
|
|
9
|
+
//
|
|
10
|
+
// Flags: --upstream <url> (required), --upstream-key <key>, --model <id> (repeatable or comma-list),
|
|
11
|
+
// --port <n> (default 8790), --api-key <key> (default: generate + print one), --keyless.
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { serveChat, httpUpstream } from './src/bridge.mjs';
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const o = { models: [], port: 8790 };
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === '--keyless') { o.keyless = true; continue; }
|
|
21
|
+
if (!a.startsWith('--')) { console.error(`ABORT: unexpected argument "${a}"`); process.exit(1); }
|
|
22
|
+
const name = a.slice(2);
|
|
23
|
+
const val = argv[++i];
|
|
24
|
+
if (name === 'model') o.models.push(...String(val || '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
25
|
+
else if (name === 'upstream') o.upstream = val;
|
|
26
|
+
else if (name === 'upstream-key') o.upstreamKey = val;
|
|
27
|
+
else if (name === 'port') o.port = Number(val) || 8790;
|
|
28
|
+
else if (name === 'api-key') o.apiKey = val;
|
|
29
|
+
else { console.error(`ABORT: unknown flag --${name}`); process.exit(1); }
|
|
30
|
+
}
|
|
31
|
+
return o;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const o = parseArgs(process.argv.slice(2));
|
|
35
|
+
if (!o.upstream) { console.error('need --upstream <openai-compatible url> (e.g. https://api.openai.com/v1 or http://localhost:11434/v1)'); process.exit(1); }
|
|
36
|
+
const apiKey = o.keyless ? null : (o.apiKey || 'mtok_' + crypto.randomBytes(24).toString('hex'));
|
|
37
|
+
const upstream = httpUpstream({ baseUrl: o.upstream, key: o.upstreamKey });
|
|
38
|
+
|
|
39
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
40
|
+
let data = ''; let over = false;
|
|
41
|
+
req.on('data', (c) => { data += c; if (data.length > 2_000_000) { over = true; req.destroy(); } });
|
|
42
|
+
req.on('end', () => resolve(over ? null : data));
|
|
43
|
+
req.on('error', () => resolve(null));
|
|
44
|
+
});
|
|
45
|
+
const send = (res, status, json) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(json)); };
|
|
46
|
+
|
|
47
|
+
const server = http.createServer(async (req, res) => {
|
|
48
|
+
const path = (req.url || '').split('?')[0];
|
|
49
|
+
if (req.method === 'GET' && path === '/v1/models') {
|
|
50
|
+
return send(res, 200, { object: 'list', data: (o.models.length ? o.models : ['(upstream default)']).map((id) => ({ id, object: 'model' })) });
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'POST' && path === '/v1/chat/completions') {
|
|
53
|
+
const raw = await readBody(req);
|
|
54
|
+
if (raw === null) return send(res, 413, { error: { message: 'body too large or unreadable', type: 'invalid_request_error' } });
|
|
55
|
+
let body; try { body = JSON.parse(raw || '{}'); } catch { return send(res, 400, { error: { message: 'invalid JSON', type: 'invalid_request_error' } }); }
|
|
56
|
+
const r = await serveChat({ body, authHeader: req.headers['authorization'], apiKey, models: o.models, upstream });
|
|
57
|
+
return send(res, r.status, r.json);
|
|
58
|
+
}
|
|
59
|
+
return send(res, 404, { error: { message: 'not found; POST /v1/chat/completions', type: 'invalid_request_error' } });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
server.listen(o.port, () => {
|
|
63
|
+
const base = `http://localhost:${o.port}/v1`;
|
|
64
|
+
console.log('mtok-bridge is serving.');
|
|
65
|
+
console.log(` endpoint: ${base}`);
|
|
66
|
+
console.log(` models: ${o.models.length ? o.models.join(', ') : '(whatever the upstream serves)'}`);
|
|
67
|
+
console.log(apiKey ? ` api key: ${apiKey}` : ' api key: NONE (--keyless: anyone who can reach this endpoint can use it)');
|
|
68
|
+
console.log('\n hand these to whoever should use it. example:');
|
|
69
|
+
console.log(` curl ${base}/chat/completions \\`);
|
|
70
|
+
if (apiKey) console.log(` -H "authorization: Bearer ${apiKey}" \\`);
|
|
71
|
+
console.log(` -H "content-type: application/json" \\`);
|
|
72
|
+
console.log(` -d '{"model":"${o.models[0] ?? 'MODEL'}","messages":[{"role":"user","content":"hi"}]}'`);
|
|
73
|
+
console.log('\n want to get PAID for this model instead of handing out keys? the market relay');
|
|
74
|
+
console.log(' (npx mtok-relay) wraps this same bridge with on-chain settlement. see mtok.market.');
|
|
75
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mtok-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Serve any model as an OpenAI-compatible API with a key. No payment, no market, runs anywhere node runs. The transport core behind mtok.market's seller relay.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mtok-bridge": "mtok-bridge.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/bridge.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"mtok-bridge.mjs",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"llm",
|
|
25
|
+
"openai-compatible",
|
|
26
|
+
"proxy",
|
|
27
|
+
"bridge",
|
|
28
|
+
"mtok"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://mtok.market",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/mtok-market/bridge.git"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/bridge.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// mtok-bridge core: the TRANSPORT guts of a model seller, with NO payment and NO market.
|
|
2
|
+
// Serve any model as an OpenAI-compatible API, gated by an api key. Runtime-agnostic and
|
|
3
|
+
// dependency-free: the request logic is pure functions; a host (the node CLI here, a CF Worker
|
|
4
|
+
// later, the market relay on top) adapts its req/res to them. The market layer (on-chain verify,
|
|
5
|
+
// fee, redemption, discovery) is a SEPARATE wrapper that calls the same serveChat guts; it is not
|
|
6
|
+
// in here. (#566)
|
|
7
|
+
|
|
8
|
+
// Bearer-key auth. No key configured (null/'') = OPEN on purpose (keyless mode is an explicit
|
|
9
|
+
// opt-in the CLI announces loudly). A configured key must match exactly.
|
|
10
|
+
export function checkAuth(authHeader, apiKey) {
|
|
11
|
+
if (!apiKey) return true;
|
|
12
|
+
const m = /^Bearer\s+(.+)$/i.exec(String(authHeader || ''));
|
|
13
|
+
return !!m && m[1] === apiKey;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Resolve the requested model against the served set. Empty set = pass the request through
|
|
17
|
+
// (the upstream decides). One-or-more served models: default to the first when none is asked,
|
|
18
|
+
// and reject a model we do not serve (null = not allowed).
|
|
19
|
+
export function pickModel(requested, models) {
|
|
20
|
+
const set = Array.isArray(models) ? models.filter(Boolean) : [];
|
|
21
|
+
if (!set.length) return requested || null;
|
|
22
|
+
if (!requested) return set[0];
|
|
23
|
+
return set.includes(requested) ? requested : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Core chat handler. `upstream(payload)` returns an OpenAI-shaped completion (or throws). Returns
|
|
27
|
+
// { status, json } for the host to serialize. This is the whole free bridge; the market layer
|
|
28
|
+
// wraps it with a verify step in front.
|
|
29
|
+
export async function serveChat({ body, authHeader, apiKey, models, upstream }) {
|
|
30
|
+
if (!checkAuth(authHeader, apiKey)) {
|
|
31
|
+
return { status: 401, json: { error: { message: 'missing or invalid api key', type: 'auth_error' } } };
|
|
32
|
+
}
|
|
33
|
+
if (!body || !Array.isArray(body.messages) || !body.messages.length) {
|
|
34
|
+
return { status: 400, json: { error: { message: 'messages[] is required', type: 'invalid_request_error' } } };
|
|
35
|
+
}
|
|
36
|
+
const model = pickModel(body.model, models);
|
|
37
|
+
if (!model) {
|
|
38
|
+
const served = (Array.isArray(models) ? models : []).join(', ') || '(any)';
|
|
39
|
+
return { status: 400, json: { error: { message: `model "${body.model}" is not served here (served: ${served})`, type: 'invalid_request_error' } } };
|
|
40
|
+
}
|
|
41
|
+
let completion;
|
|
42
|
+
try {
|
|
43
|
+
completion = await upstream({ ...body, model });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { status: 502, json: { error: { message: `upstream error: ${e?.message ?? e}`, type: 'upstream_error' } } };
|
|
46
|
+
}
|
|
47
|
+
return { status: 200, json: completion };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build an upstream function that forwards to any OpenAI-compatible chat/completions endpoint.
|
|
51
|
+
// `baseUrl` is the API root (e.g. https://api.openai.com/v1 or a local model server); `key` is
|
|
52
|
+
// its bearer token (optional for a keyless local server). This is what makes the bridge portable:
|
|
53
|
+
// point it at a provider, or at ollama / LM Studio / vLLM on localhost.
|
|
54
|
+
export function httpUpstream({ baseUrl, key }) {
|
|
55
|
+
const url = String(baseUrl || '').replace(/\/$/, '') + '/chat/completions';
|
|
56
|
+
return async (payload) => {
|
|
57
|
+
const res = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'content-type': 'application/json',
|
|
61
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(payload),
|
|
64
|
+
});
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
let json;
|
|
67
|
+
try { json = JSON.parse(text); } catch { throw new Error(`non-JSON upstream response (${res.status})`); }
|
|
68
|
+
if (!res.ok) throw new Error(json?.error?.message || `upstream ${res.status}`);
|
|
69
|
+
return json;
|
|
70
|
+
};
|
|
71
|
+
}
|