lynkr 9.14.2 → 9.14.3
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/README.md +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +10 -4
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/tools/web-search-exec.js +182 -0
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ lynkr wrap claude
|
|
|
45
45
|
- ✅ Sticky sessions: one routing decision per conversation via content fingerprinting, with automatic escalation when the task outgrows the model
|
|
46
46
|
- ✅ TOON/RTK compression (84% token reduction on large JSON tool outputs)
|
|
47
47
|
- ✅ Semantic caching (sub-300ms cache hits, 0 tokens billed)
|
|
48
|
-
- ✅ **
|
|
48
|
+
- ✅ **Your subscription is used only where it counts** — simple requests route to your own local/API models, so subscription capacity goes to the work that needs it
|
|
49
49
|
- ✅ Works with OAuth (Claude, Copilot, Cursor) or API keys (Aider, Codex)
|
|
50
50
|
|
|
51
51
|
[Full wrap guide →](docs/wrap-guide.md)
|
|
@@ -221,6 +221,7 @@ Claude Code / Cursor / Codex / Cline / Continue
|
|
|
221
221
|
| **Z.ai** | Cloud | GLM-4.7, GLM-4.5-Air | $ |
|
|
222
222
|
| **Moonshot AI** | Cloud | Kimi K2.6, Kimi K3 | $ |
|
|
223
223
|
| **Baidu Qianfan** | Cloud | ERNIE 4.5 Turbo, ERNIE X1.1 | $ (unverified — not yet probed against a live key) |
|
|
224
|
+
| **Fireworks AI** | Cloud | Kimi K2, GLM-5, DeepSeek V3 (serverless) | $ (unverified — not yet probed against a live key) |
|
|
224
225
|
|
|
225
226
|
**4 local providers** for 100% offline, free usage. **14+ cloud providers** for scale.
|
|
226
227
|
|
|
@@ -617,6 +618,9 @@ With tier routing + token optimization: **additional 50-87% savings** on cloud p
|
|
|
617
618
|
- [NPM Package](https://www.npmjs.com/package/lynkr) — Official releases
|
|
618
619
|
- [DeepWiki](https://deepwiki.com/Fast-Editor/Lynkr) — AI-powered docs
|
|
619
620
|
|
|
621
|
+
This project follows our [Code of Conduct](CODE_OF_CONDUCT.md) — it applies in
|
|
622
|
+
all project spaces, and it tells you exactly how to report a problem.
|
|
623
|
+
|
|
620
624
|
---
|
|
621
625
|
|
|
622
626
|
## License
|
package/bin/cli.js
CHANGED
|
@@ -6,12 +6,15 @@ const pkg = require('../package.json');
|
|
|
6
6
|
// Subcommands. Dispatched before server boot so `lynkr usage` / `lynkr trajectory`
|
|
7
7
|
// don't start the proxy. Add new subcommands here, not in scattered binaries.
|
|
8
8
|
const SUBCOMMANDS = {
|
|
9
|
-
usage:
|
|
10
|
-
stats:
|
|
11
|
-
trajectory:
|
|
12
|
-
wrap:
|
|
13
|
-
init:
|
|
14
|
-
reset:
|
|
9
|
+
usage: path.join(__dirname, "lynkr-usage.js"),
|
|
10
|
+
stats: path.join(__dirname, "lynkr-usage.js"),
|
|
11
|
+
trajectory: path.join(__dirname, "lynkr-trajectory.js"),
|
|
12
|
+
wrap: path.join(__dirname, "wrap.js"),
|
|
13
|
+
init: path.join(__dirname, "lynkr-init.js"),
|
|
14
|
+
reset: path.join(__dirname, "lynkr-reset.js"),
|
|
15
|
+
"desktop-token": path.join(__dirname, "lynkr-desktop-token.js"),
|
|
16
|
+
restart: path.join(__dirname, "lynkr-restart.js"),
|
|
17
|
+
run: path.join(__dirname, "run.js"),
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
const sub = process.argv[2];
|
|
@@ -51,6 +54,11 @@ Usage:
|
|
|
51
54
|
lynkr start [options] Alias for the above
|
|
52
55
|
lynkr init [options] Interactive setup wizard (writes .env, pulls classifier model)
|
|
53
56
|
lynkr wrap <target> [options] Wrap CLI tools through Lynkr proxy
|
|
57
|
+
lynkr desktop-token <token> Apply a Claude Pro/Max OAuth token to Claude Desktop's
|
|
58
|
+
gateway profile (macOS only)
|
|
59
|
+
lynkr desktop-token --restore Restore Claude Desktop to talking to Anthropic directly
|
|
60
|
+
(undoes the above; macOS only)
|
|
61
|
+
lynkr restart Restart the local Lynkr server (pick up code/config changes)
|
|
54
62
|
lynkr usage [options] Show AI spend report and tier-routing savings
|
|
55
63
|
lynkr stats [options] Shareable savings-receipt card (also: lynkr usage --card)
|
|
56
64
|
lynkr trajectory [options] Export agent trajectories as JSONL training data
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `lynkr desktop-token <token>` / `lynkr desktop-token --restore`
|
|
4
|
+
*
|
|
5
|
+
* Apply a freshly-minted Claude Pro/Max OAuth token to Claude Desktop's
|
|
6
|
+
* Lynkr gateway profile — or restore Desktop back to talking to Anthropic
|
|
7
|
+
* directly.
|
|
8
|
+
*
|
|
9
|
+
* This does NOT mint or refresh the token — that stays a manual, one-time
|
|
10
|
+
* `claude setup-token` (interactive browser approval) every time it expires.
|
|
11
|
+
* It does NOT restart Lynkr, either: the token lives entirely in Desktop's
|
|
12
|
+
* own config file (read at Desktop's launch, not Lynkr's), and Lynkr just
|
|
13
|
+
* forwards whatever bearer shows up on each request — it has no token state
|
|
14
|
+
* of its own to refresh. Restarting only matters when Lynkr's own code
|
|
15
|
+
* changed; use `lynkr restart` for that, separately.
|
|
16
|
+
*
|
|
17
|
+
* `lynkr desktop-token <token>`:
|
|
18
|
+
* 1. Installs the token into the Desktop "3p" gateway profile
|
|
19
|
+
* (delegates to scripts/claude-desktop.js --install)
|
|
20
|
+
* 2. Prints --status so you can see the profile actually took
|
|
21
|
+
*
|
|
22
|
+
* `lynkr desktop-token --restore`:
|
|
23
|
+
* Delegates to scripts/claude-desktop.js --restore — puts Desktop's
|
|
24
|
+
* deployment mode and applied-profile bookkeeping back to what they were
|
|
25
|
+
* before Lynkr's gateway profile was installed, and removes the profile
|
|
26
|
+
* + backup files. Safe to run even if nothing was ever installed (a
|
|
27
|
+
* no-op restore, not an error).
|
|
28
|
+
*
|
|
29
|
+
* Neither variant touches .env TIER_* values, and neither quits or
|
|
30
|
+
* relaunches Claude Desktop itself (that would close your open chat
|
|
31
|
+
* windows) — both just print the command to do so when you're ready.
|
|
32
|
+
*
|
|
33
|
+
* Usage:
|
|
34
|
+
* lynkr desktop-token sk-ant-oat01-...
|
|
35
|
+
* lynkr desktop-token --restore
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const path = require("path");
|
|
39
|
+
const fs = require("fs");
|
|
40
|
+
const { execFileSync } = require("child_process");
|
|
41
|
+
|
|
42
|
+
const ROOT = path.join(__dirname, "..");
|
|
43
|
+
const ENV_PATH = path.join(ROOT, ".env");
|
|
44
|
+
const CLAUDE_DESKTOP_JS = path.join(ROOT, "scripts", "claude-desktop.js");
|
|
45
|
+
|
|
46
|
+
function fail(msg) {
|
|
47
|
+
console.error(msg);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readPort() {
|
|
52
|
+
try {
|
|
53
|
+
const env = fs.readFileSync(ENV_PATH, "utf8");
|
|
54
|
+
const m = env.match(/^PORT=(\d+)/m);
|
|
55
|
+
if (m) return Number(m[1]);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`);
|
|
58
|
+
}
|
|
59
|
+
return 8081;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function main() {
|
|
63
|
+
const token = process.argv[2];
|
|
64
|
+
if (!token || token === "-h" || token === "--help") {
|
|
65
|
+
fail("Usage: lynkr desktop-token <sk-ant-oat-token> | --restore");
|
|
66
|
+
}
|
|
67
|
+
if (token === "--restore") {
|
|
68
|
+
if (process.platform !== "darwin") {
|
|
69
|
+
fail("Claude Desktop profile management is only supported on macOS.");
|
|
70
|
+
}
|
|
71
|
+
console.log("Restoring Claude Desktop to the usual Claude profile...");
|
|
72
|
+
execFileSync("node", [CLAUDE_DESKTOP_JS, "--restore"], { stdio: "inherit" });
|
|
73
|
+
console.log(
|
|
74
|
+
"\nWhen you're ready (this would close your open chats if done automatically):" +
|
|
75
|
+
"\n killall Claude && open -a Claude"
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!token.startsWith("sk-ant-oat")) {
|
|
80
|
+
fail(
|
|
81
|
+
`That doesn't look like a Claude Code OAuth access token (expected it to start ` +
|
|
82
|
+
`with "sk-ant-oat"). Got: ${token.slice(0, 12)}...\n` +
|
|
83
|
+
`Mint one with: claude setup-token`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (process.platform !== "darwin") {
|
|
87
|
+
fail("Claude Desktop profile management is only supported on macOS.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const url = `http://127.0.0.1:${readPort()}`;
|
|
91
|
+
|
|
92
|
+
console.log("Step 1/2 — installing token into Claude Desktop's Lynkr gateway profile...");
|
|
93
|
+
execFileSync("node", [CLAUDE_DESKTOP_JS, "--install", "--url", url, "--key", token], {
|
|
94
|
+
stdio: "inherit",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log("\nStep 2/2 — status:");
|
|
98
|
+
execFileSync("node", [CLAUDE_DESKTOP_JS, "--status"], { stdio: "inherit" });
|
|
99
|
+
|
|
100
|
+
console.log(
|
|
101
|
+
"\nNext (manual, on purpose — this would close your open chats if done for you):" +
|
|
102
|
+
"\n killall Claude && open -a Claude" +
|
|
103
|
+
"\n\nWhen Anthropic calls start 401ing: claude setup-token, then: lynkr desktop-token <token>" +
|
|
104
|
+
"\nIf you've also changed Lynkr's own code, that's a separate step: lynkr restart"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main();
|
package/bin/lynkr-init.js
CHANGED
|
@@ -179,12 +179,21 @@ const PROVIDERS = {
|
|
|
179
179
|
extras: [],
|
|
180
180
|
defaultModel: 'ernie-4.5-turbo-128k',
|
|
181
181
|
},
|
|
182
|
+
fireworks: {
|
|
183
|
+
label: 'Fireworks AI (serverless open models)',
|
|
184
|
+
local: false,
|
|
185
|
+
creds: [
|
|
186
|
+
{ key: 'FIREWORKS_API_KEY', label: 'Fireworks API key (fw-...)', secret: true },
|
|
187
|
+
],
|
|
188
|
+
extras: [],
|
|
189
|
+
defaultModel: 'accounts/fireworks/models/kimi-k2-instruct-0905',
|
|
190
|
+
},
|
|
182
191
|
};
|
|
183
192
|
|
|
184
193
|
const PROVIDER_ORDER = [
|
|
185
194
|
'ollama', 'llamacpp', 'lmstudio',
|
|
186
195
|
'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai',
|
|
187
|
-
'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu',
|
|
196
|
+
'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu', 'fireworks',
|
|
188
197
|
];
|
|
189
198
|
const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
190
199
|
|
|
@@ -342,6 +351,9 @@ const BASELINE_ENV = {
|
|
|
342
351
|
BAIDU_API_KEY: '',
|
|
343
352
|
BAIDU_ENDPOINT: 'https://qianfan.baidubce.com/v2/chat/completions',
|
|
344
353
|
BAIDU_MODEL: 'ernie-4.5-turbo-128k',
|
|
354
|
+
FIREWORKS_API_KEY: '',
|
|
355
|
+
FIREWORKS_ENDPOINT: 'https://api.fireworks.ai/inference/v1/chat/completions',
|
|
356
|
+
FIREWORKS_MODEL: 'accounts/fireworks/models/kimi-k2-instruct-0905',
|
|
345
357
|
LLAMACPP_ENDPOINT: 'http://localhost:8080',
|
|
346
358
|
LLAMACPP_MODEL: 'default',
|
|
347
359
|
LLAMACPP_TIMEOUT_MS: '120000',
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `lynkr restart`
|
|
4
|
+
*
|
|
5
|
+
* Stop whatever's listening on Lynkr's configured port and start a fresh
|
|
6
|
+
* `npm start`, detached so it survives this command exiting. Use this after
|
|
7
|
+
* changing Lynkr's own code (routing, config, etc.) — it has nothing to do
|
|
8
|
+
* with Claude Desktop's token or profile, which Desktop reads from disk at
|
|
9
|
+
* its own launch time, independent of whether Lynkr's process restarts.
|
|
10
|
+
* See `lynkr desktop-token` for that.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* lynkr restart
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const http = require("http");
|
|
19
|
+
const { execFileSync, spawn } = require("child_process");
|
|
20
|
+
|
|
21
|
+
const ROOT = path.join(__dirname, "..");
|
|
22
|
+
const ENV_PATH = path.join(ROOT, ".env");
|
|
23
|
+
const LOG_PATH = path.join(ROOT, "data", "logs", "lynkr-desktop.log");
|
|
24
|
+
|
|
25
|
+
function fail(msg) {
|
|
26
|
+
console.error(msg);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readPort() {
|
|
31
|
+
try {
|
|
32
|
+
const env = fs.readFileSync(ENV_PATH, "utf8");
|
|
33
|
+
const m = env.match(/^PORT=(\d+)/m);
|
|
34
|
+
if (m) return Number(m[1]);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`);
|
|
37
|
+
}
|
|
38
|
+
return 8081;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findListeningPid(port) {
|
|
42
|
+
try {
|
|
43
|
+
const out = execFileSync("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-t"], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
46
|
+
}).trim();
|
|
47
|
+
return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
|
|
48
|
+
} catch {
|
|
49
|
+
return []; // lsof exits non-zero when nothing matches
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function healthCheck(port) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const req = http.get({ host: "127.0.0.1", port, path: "/health", timeout: 1500 }, (res) => {
|
|
56
|
+
let body = "";
|
|
57
|
+
res.on("data", (d) => (body += d));
|
|
58
|
+
res.on("end", () => {
|
|
59
|
+
try {
|
|
60
|
+
resolve(res.statusCode === 200 && JSON.parse(body).status === "ok");
|
|
61
|
+
} catch {
|
|
62
|
+
resolve(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
req.on("error", () => resolve(false));
|
|
67
|
+
req.on("timeout", () => {
|
|
68
|
+
req.destroy();
|
|
69
|
+
resolve(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function waitForHealth(port, timeoutMs) {
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
if (await healthCheck(port)) return true;
|
|
78
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
if (process.argv[2] === "-h" || process.argv[2] === "--help") {
|
|
85
|
+
console.log("Usage: lynkr restart");
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const port = readPort();
|
|
90
|
+
const pids = findListeningPid(port);
|
|
91
|
+
if (pids.length) {
|
|
92
|
+
console.log(`Stopping existing Lynkr process (pid ${pids.join(", ")}) on port ${port}...`);
|
|
93
|
+
for (const pid of pids) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(Number(pid), "SIGTERM");
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(` couldn't signal pid ${pid}: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const freedByDeadline = Date.now() + 5000;
|
|
101
|
+
while (Date.now() < freedByDeadline && findListeningPid(port).length) {
|
|
102
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`Nothing currently listening on port ${port}.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
109
|
+
const logFd = fs.openSync(LOG_PATH, "a");
|
|
110
|
+
console.log(`Starting Lynkr (npm start), logging to ${LOG_PATH}...`);
|
|
111
|
+
const child = spawn("npm", ["start"], {
|
|
112
|
+
cwd: ROOT,
|
|
113
|
+
stdio: ["ignore", logFd, logFd],
|
|
114
|
+
detached: true,
|
|
115
|
+
});
|
|
116
|
+
child.unref();
|
|
117
|
+
|
|
118
|
+
const up = await waitForHealth(port, 15000);
|
|
119
|
+
if (!up) {
|
|
120
|
+
fail(`Lynkr didn't answer /health on port ${port} within 15s.\nCheck ${LOG_PATH} for errors.`);
|
|
121
|
+
}
|
|
122
|
+
console.log(`Lynkr is up on http://127.0.0.1:${port}.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((err) => fail(err.stack || String(err)));
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lynkr opencode — write the Lynkr provider into opencode's config with
|
|
4
|
+
* HONEST per-tier context windows.
|
|
5
|
+
*
|
|
6
|
+
* The problem this solves: opencode's compaction triggers off ONE static
|
|
7
|
+
* `limit.context` per model entry, but Lynkr's tier routing serves models
|
|
8
|
+
* with different real windows (e.g. 128k / 200k / 1M). One blended number is
|
|
9
|
+
* wrong for most requests: too big and opencode never compacts in time (the
|
|
10
|
+
* conversation outgrows the served model and Lynkr's server-side compressor
|
|
11
|
+
* has to amputate it mechanically); too small and big-window models are
|
|
12
|
+
* wasted.
|
|
13
|
+
*
|
|
14
|
+
* The fix: one opencode model entry per tier, each carrying its tier's REAL
|
|
15
|
+
* configured-model window (read live from this Lynkr install's TIER_* config
|
|
16
|
+
* and model registry), plus a "lynkr-auto" entry floored at the MINIMUM
|
|
17
|
+
* across tiers (safe for whatever routing picks). The per-tier ids
|
|
18
|
+
* (lynkr-simple/medium/complex/reasoning) pin their tier server-side — see
|
|
19
|
+
* src/routing/model-slots.js VIRTUAL_TIER_IDS — so the window each entry
|
|
20
|
+
* advertises is the window that actually serves it. Picking a tier in
|
|
21
|
+
* opencode's model picker doubles as tier pinning (documentation/
|
|
22
|
+
* tier-pinning.md), and compaction budgets stay correct per model.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* lynkr opencode # merge into ~/.config/opencode/opencode.json
|
|
26
|
+
* lynkr opencode --path ./opencode.json # project-scoped config instead
|
|
27
|
+
* lynkr opencode --base-url http://host:8081
|
|
28
|
+
* lynkr opencode --dry-run # print the block, write nothing
|
|
29
|
+
*
|
|
30
|
+
* Merging is non-destructive: only the `provider.lynkr` block is replaced;
|
|
31
|
+
* every other key in an existing opencode.json is preserved verbatim.
|
|
32
|
+
*
|
|
33
|
+
* @module bin/opencode-setup
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require("fs");
|
|
37
|
+
const os = require("os");
|
|
38
|
+
const path = require("path");
|
|
39
|
+
|
|
40
|
+
const TIERS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read each configured tier's model and its real context window.
|
|
44
|
+
* @returns {{ tiers: Object<string, {provider: string, model: string, contextWindow: number|null}>, minWindow: number|null }}
|
|
45
|
+
*/
|
|
46
|
+
function readTierWindows() {
|
|
47
|
+
const config = require("../src/config");
|
|
48
|
+
const { contextWindowFor } = require("../src/routing/model-registry");
|
|
49
|
+
const tiers = {};
|
|
50
|
+
let minWindow = null;
|
|
51
|
+
for (const tier of TIERS) {
|
|
52
|
+
const raw = config.modelTiers?.[tier];
|
|
53
|
+
if (!raw) continue;
|
|
54
|
+
const [provider, ...rest] = raw.split(":");
|
|
55
|
+
const model = rest.join(":") || null;
|
|
56
|
+
const contextWindow = model ? contextWindowFor(model) : null;
|
|
57
|
+
tiers[tier] = { provider, model, contextWindow };
|
|
58
|
+
if (contextWindow && (!minWindow || contextWindow < minWindow)) {
|
|
59
|
+
minWindow = contextWindow;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { tiers, minWindow };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Conservative floor when no tier window is resolvable (fresh install,
|
|
66
|
+
// unpriced local models): matches the orchestrator's TOKEN_BUDGET_FALLBACK
|
|
67
|
+
// rationale — never advertise bigger than what's known safe.
|
|
68
|
+
const FALLBACK_WINDOW = 128000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the `provider.lynkr` block for opencode.json. Pure — exported for
|
|
72
|
+
* tests.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ tiers: Object, minWindow: number|null }} tierWindows
|
|
75
|
+
* @param {{ baseURL: string, apiKey: string }} options
|
|
76
|
+
* @returns {object} provider block
|
|
77
|
+
*/
|
|
78
|
+
function buildLynkrProvider(tierWindows, { baseURL, apiKey }) {
|
|
79
|
+
const models = {
|
|
80
|
+
"lynkr-auto": {
|
|
81
|
+
name: "Lynkr Auto (tier routing)",
|
|
82
|
+
limit: { context: tierWindows.minWindow || FALLBACK_WINDOW },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
for (const tier of TIERS) {
|
|
86
|
+
const entry = tierWindows.tiers[tier];
|
|
87
|
+
if (!entry?.model) continue;
|
|
88
|
+
models[`lynkr-${tier.toLowerCase()}`] = {
|
|
89
|
+
name: `Lynkr ${tier} (${entry.model})`,
|
|
90
|
+
limit: { context: entry.contextWindow || tierWindows.minWindow || FALLBACK_WINDOW },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
npm: "@ai-sdk/anthropic",
|
|
95
|
+
name: "Lynkr",
|
|
96
|
+
options: {
|
|
97
|
+
baseURL: `${baseURL.replace(/\/+$/, "")}/v1`,
|
|
98
|
+
apiKey,
|
|
99
|
+
},
|
|
100
|
+
models,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Merge the Lynkr provider into an existing opencode config object without
|
|
106
|
+
* touching anything else. Pure — exported for tests.
|
|
107
|
+
*/
|
|
108
|
+
function mergeOpencodeConfig(existing, lynkrProvider) {
|
|
109
|
+
const merged = { ...(existing || {}) };
|
|
110
|
+
merged.provider = { ...(merged.provider || {}), lynkr: lynkrProvider };
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultConfigPath() {
|
|
115
|
+
return path.join(os.homedir(), ".config", "opencode", "opencode.json");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Perform the setup. Exported so `lynkr run opencode` (bin/run.js) can
|
|
120
|
+
* configure-then-launch. Returns { dryRun } so the caller knows whether to
|
|
121
|
+
* proceed to launching.
|
|
122
|
+
* @param {string[]} args - CLI-style flag list
|
|
123
|
+
*/
|
|
124
|
+
function runSetup(args) {
|
|
125
|
+
const argValue = (flag) => {
|
|
126
|
+
const i = args.indexOf(flag);
|
|
127
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
|
128
|
+
};
|
|
129
|
+
const configPath = argValue("--path") || defaultConfigPath();
|
|
130
|
+
const baseURL = argValue("--base-url")
|
|
131
|
+
|| `http://localhost:${process.env.PORT || 8081}`;
|
|
132
|
+
const apiKey = process.env.LYNKR_API_KEY || "lynkr-local";
|
|
133
|
+
const dryRun = args.includes("--dry-run");
|
|
134
|
+
|
|
135
|
+
const tierWindows = readTierWindows();
|
|
136
|
+
const provider = buildLynkrProvider(tierWindows, { baseURL, apiKey });
|
|
137
|
+
|
|
138
|
+
console.log("Lynkr → opencode provider block (per-tier context windows):\n");
|
|
139
|
+
for (const [id, m] of Object.entries(provider.models)) {
|
|
140
|
+
console.log(` ${id.padEnd(16)} limit.context=${String(m.limit.context).padStart(8)} ${m.name}`);
|
|
141
|
+
}
|
|
142
|
+
const unresolved = TIERS.filter(
|
|
143
|
+
(t) => tierWindows.tiers[t]?.model && !tierWindows.tiers[t]?.contextWindow
|
|
144
|
+
);
|
|
145
|
+
if (unresolved.length > 0) {
|
|
146
|
+
console.warn(`\n⚠ No registry context window for tier model(s): ${unresolved
|
|
147
|
+
.map((t) => `${t}=${tierWindows.tiers[t].model}`)
|
|
148
|
+
.join(", ")} — floored conservatively. Add MODEL_PRICE_OVERRIDES entries (with a context field) to fix.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (dryRun) {
|
|
152
|
+
console.log(`\n--dry-run: would merge into ${configPath}`);
|
|
153
|
+
console.log(JSON.stringify({ provider: { lynkr: provider } }, null, 2));
|
|
154
|
+
return { dryRun: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let existing = {};
|
|
158
|
+
if (fs.existsSync(configPath)) {
|
|
159
|
+
try {
|
|
160
|
+
existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`\nRefusing to overwrite ${configPath}: it exists but isn't valid JSON (${err.message}).`);
|
|
163
|
+
console.error("Fix or move it, then re-run.");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const merged = mergeOpencodeConfig(existing, provider);
|
|
169
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
170
|
+
fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
171
|
+
console.log(`\n✓ Merged provider.lynkr into ${configPath} (other keys preserved).`);
|
|
172
|
+
return { dryRun: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { buildLynkrProvider, mergeOpencodeConfig, readTierWindows, runSetup, FALLBACK_WINDOW };
|
package/bin/run.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lynkr run <tool> — ensure the tool's Lynkr provider config is current,
|
|
4
|
+
* then launch the tool.
|
|
5
|
+
*
|
|
6
|
+
* Differs from `lynkr wrap` on purpose: wrap intercepts a tool's traffic via
|
|
7
|
+
* environment overrides at launch; `run` writes the tool's own native
|
|
8
|
+
* provider config (so the tool works with Lynkr even when started normally
|
|
9
|
+
* later) and then execs it. Config refresh on every run keeps the per-tier
|
|
10
|
+
* context windows in sync with TIER_* changes.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* lynkr run opencode # refresh config, launch opencode
|
|
14
|
+
* lynkr run opencode --dry-run # preview config, don't write or launch
|
|
15
|
+
* lynkr run opencode --path ./opencode.json --base-url http://host:8081
|
|
16
|
+
* lynkr run opencode -- --help # everything after -- goes to opencode
|
|
17
|
+
*
|
|
18
|
+
* @module bin/run
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { spawn } = require("child_process");
|
|
22
|
+
|
|
23
|
+
// Flags consumed by the setup step (with whether they take a value).
|
|
24
|
+
const SETUP_FLAGS = new Map([
|
|
25
|
+
["--dry-run", false],
|
|
26
|
+
["--path", true],
|
|
27
|
+
["--base-url", true],
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function splitArgs(args) {
|
|
31
|
+
const setupArgs = [];
|
|
32
|
+
const toolArgs = [];
|
|
33
|
+
let passthrough = false;
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
const arg = args[i];
|
|
36
|
+
if (passthrough) {
|
|
37
|
+
toolArgs.push(arg);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg === "--") {
|
|
41
|
+
passthrough = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (SETUP_FLAGS.has(arg)) {
|
|
45
|
+
setupArgs.push(arg);
|
|
46
|
+
if (SETUP_FLAGS.get(arg) && i + 1 < args.length) setupArgs.push(args[++i]);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
toolArgs.push(arg);
|
|
50
|
+
}
|
|
51
|
+
return { setupArgs, toolArgs };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runOpencode(args) {
|
|
55
|
+
const { runSetup } = require("./opencode-setup");
|
|
56
|
+
const { setupArgs, toolArgs } = splitArgs(args);
|
|
57
|
+
const { dryRun } = runSetup(setupArgs) || {};
|
|
58
|
+
if (dryRun) return;
|
|
59
|
+
|
|
60
|
+
const child = spawn("opencode", toolArgs, { stdio: "inherit" });
|
|
61
|
+
child.on("error", (err) => {
|
|
62
|
+
if (err.code === "ENOENT") {
|
|
63
|
+
console.error("\nConfig written, but the `opencode` binary was not found on PATH.");
|
|
64
|
+
console.error("Install it (https://opencode.ai), then run `opencode` — the Lynkr provider is already configured.");
|
|
65
|
+
process.exit(127);
|
|
66
|
+
}
|
|
67
|
+
console.error(`Failed to launch opencode: ${err.message}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
|
70
|
+
child.on("exit", (code, signal) => {
|
|
71
|
+
process.exit(signal ? 1 : (code ?? 0));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TOOLS = { opencode: runOpencode };
|
|
76
|
+
|
|
77
|
+
function main() {
|
|
78
|
+
const [tool, ...rest] = process.argv.slice(2);
|
|
79
|
+
if (!tool || !Object.prototype.hasOwnProperty.call(TOOLS, tool)) {
|
|
80
|
+
console.error(`Usage: lynkr run <tool> [options] [-- tool-args]`);
|
|
81
|
+
console.error(`Supported tools: ${Object.keys(TOOLS).join(", ")}`);
|
|
82
|
+
console.error(tool ? `Unknown tool '${tool}'.` : "");
|
|
83
|
+
process.exit(tool ? 1 : 0);
|
|
84
|
+
}
|
|
85
|
+
TOOLS[tool](rest);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { splitArgs, TOOLS };
|
|
89
|
+
|
|
90
|
+
if (require.main === module || process.env._LYNKR_SUBCMD === "run") {
|
|
91
|
+
main();
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"heads": ["reasoning", "codegen", "debugging", "tool_use"],
|
|
4
|
+
"notes": "Capability profiles are decoupled from the predictor (see src/routing/capabilities.js). Editing this file re-routes traffic with zero retraining — the HyDRA shortfall-matching port. Tier profiles are seeded to mirror the legacy scalar bands (SIMPLE 0-19, MEDIUM 20-50, COMPLEX 51-75, REASONING 76-100). This file is read once at boot (same as config/model-tiers.json) — restart to pick up edits. No env vars: everything shortfall needs lives here.",
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"tau": 0.24,
|
|
7
|
+
"weights": {
|
|
8
|
+
"reasoning": 0.25,
|
|
9
|
+
"codegen": 0.25,
|
|
10
|
+
"debugging": 0.25,
|
|
11
|
+
"tool_use": 0.25
|
|
12
|
+
},
|
|
13
|
+
"tierProfiles": {
|
|
14
|
+
"SIMPLE": { "reasoning": 0.2, "codegen": 0.3, "debugging": 0.2, "tool_use": 0.3 },
|
|
15
|
+
"MEDIUM": { "reasoning": 0.45, "codegen": 0.55, "debugging": 0.45, "tool_use": 0.55 },
|
|
16
|
+
"COMPLEX": { "reasoning": 0.7, "codegen": 0.75, "debugging": 0.7, "tool_use": 0.7 },
|
|
17
|
+
"REASONING": { "reasoning": 0.9, "codegen": 0.9, "debugging": 0.9, "tool_use": 0.9 }
|
|
18
|
+
},
|
|
19
|
+
"modelOverrides": {}
|
|
20
|
+
}
|