pi-nebius 0.3.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +239 -0
  5. package/SECURITY.md +47 -0
  6. package/benchmarks/add-api-endpoint/benchmark.yaml +13 -0
  7. package/benchmarks/add-api-endpoint/fixture/app.mjs +6 -0
  8. package/benchmarks/add-api-endpoint/fixture/app.test.mjs +8 -0
  9. package/benchmarks/add-api-endpoint/fixture/package.json +8 -0
  10. package/benchmarks/add-api-endpoint/validation/check.test.mjs +37 -0
  11. package/benchmarks/fix-auth-bug/benchmark.yaml +14 -0
  12. package/benchmarks/fix-auth-bug/fixture/auth.mjs +4 -0
  13. package/benchmarks/fix-auth-bug/fixture/auth.test.mjs +26 -0
  14. package/benchmarks/fix-auth-bug/fixture/package.json +8 -0
  15. package/benchmarks/fix-auth-bug/validation/check.test.mjs +25 -0
  16. package/benchmarks/multi-file-feature/benchmark.yaml +15 -0
  17. package/benchmarks/multi-file-feature/fixture/package.json +8 -0
  18. package/benchmarks/multi-file-feature/fixture/routes.mjs +11 -0
  19. package/benchmarks/multi-file-feature/fixture/routes.test.mjs +14 -0
  20. package/benchmarks/multi-file-feature/fixture/serialize.mjs +3 -0
  21. package/benchmarks/multi-file-feature/fixture/store.mjs +10 -0
  22. package/benchmarks/multi-file-feature/validation/check.test.mjs +52 -0
  23. package/benchmarks/refactor-module/benchmark.yaml +12 -0
  24. package/benchmarks/refactor-module/fixture/invoice.mjs +8 -0
  25. package/benchmarks/refactor-module/fixture/invoice.test.mjs +9 -0
  26. package/benchmarks/refactor-module/fixture/package.json +8 -0
  27. package/benchmarks/refactor-module/validation/check.test.mjs +36 -0
  28. package/dist/benchmark/cli.js +112 -0
  29. package/dist/benchmark/command.js +194 -0
  30. package/dist/benchmark/definition.js +109 -0
  31. package/dist/benchmark/host-worker.js +14 -0
  32. package/dist/benchmark/instrumentation.js +296 -0
  33. package/dist/benchmark/metrics.js +78 -0
  34. package/dist/benchmark/process.js +122 -0
  35. package/dist/benchmark/project.js +70 -0
  36. package/dist/benchmark/report.js +94 -0
  37. package/dist/benchmark/runner.js +376 -0
  38. package/dist/benchmark/types.js +1 -0
  39. package/dist/benchmark/worker.js +134 -0
  40. package/dist/benchmark/workspace.js +55 -0
  41. package/dist/discovery.js +154 -0
  42. package/dist/errors.js +32 -0
  43. package/dist/index.js +86 -0
  44. package/dist/model-settings-command.js +130 -0
  45. package/dist/model-settings.js +101 -0
  46. package/dist/models.js +62 -0
  47. package/dist/provider.js +48 -0
  48. package/docs/benchmark-research.md +35 -0
  49. package/docs/benchmarking.md +253 -0
  50. package/docs/security-review.md +49 -0
  51. package/docs/validation.md +51 -0
  52. package/examples/models.json +31 -0
  53. package/package.json +74 -0
  54. package/src/benchmark/cli.ts +118 -0
  55. package/src/benchmark/command.ts +218 -0
  56. package/src/benchmark/definition.ts +112 -0
  57. package/src/benchmark/host-worker.ts +14 -0
  58. package/src/benchmark/instrumentation.ts +298 -0
  59. package/src/benchmark/metrics.ts +101 -0
  60. package/src/benchmark/process.ts +120 -0
  61. package/src/benchmark/project.ts +71 -0
  62. package/src/benchmark/report.ts +111 -0
  63. package/src/benchmark/runner.ts +458 -0
  64. package/src/benchmark/types.ts +180 -0
  65. package/src/benchmark/worker.ts +150 -0
  66. package/src/benchmark/workspace.ts +64 -0
  67. package/src/discovery.ts +176 -0
  68. package/src/errors.ts +32 -0
  69. package/src/index.ts +96 -0
  70. package/src/model-settings-command.ts +151 -0
  71. package/src/model-settings.ts +129 -0
  72. package/src/models.ts +73 -0
  73. package/src/provider.ts +63 -0
@@ -0,0 +1,154 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { BASE_URL, isRecord, parseModels } from "./models.js";
5
+ export const MISSING_KEY = 'Nebius Token Factory: set export NEBIUS_API_KEY="..." and restart Pi.';
6
+ const TTL_MS = 24 * 60 * 60 * 1000;
7
+ const MAX_BYTES = 4 * 1024 * 1024;
8
+ export class DiscoveryError extends Error {
9
+ status;
10
+ constructor(message, status) {
11
+ super(message);
12
+ this.status = status;
13
+ }
14
+ }
15
+ export function cachePath(agentDir, apiKey) {
16
+ const fingerprint = createHash("sha256").update(`${BASE_URL}\0${apiKey}`).digest("hex");
17
+ return join(agentDir, "cache", "pi-nebius", `${fingerprint}.json`);
18
+ }
19
+ async function readCache(path) {
20
+ try {
21
+ const raw = await readFile(path, "utf8");
22
+ if (Buffer.byteLength(raw) > MAX_BYTES)
23
+ return undefined;
24
+ const value = JSON.parse(raw);
25
+ if (!isRecord(value) ||
26
+ value.version !== 1 ||
27
+ typeof value.checkedAt !== "number" ||
28
+ !Number.isFinite(value.checkedAt) ||
29
+ !Array.isArray(value.models))
30
+ return undefined;
31
+ // Cache only normalized discovery fields, and remap through the same validation path.
32
+ return {
33
+ checkedAt: value.checkedAt,
34
+ models: parseModels({ object: "list", data: value.models }),
35
+ };
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ async function writeCache(path, models, checkedAt) {
42
+ const temp = `${path}.${randomUUID()}.tmp`;
43
+ try {
44
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
45
+ await writeFile(temp, JSON.stringify({
46
+ version: 1,
47
+ checkedAt,
48
+ models: models.map((model) => ({
49
+ id: model.id,
50
+ name: model.name,
51
+ context_length: model.contextWindow,
52
+ architecture: {
53
+ modality: model.input.includes("image") ? "text+image->text" : "text->text",
54
+ },
55
+ supported_features: model.reasoning ? ["reasoning"] : [],
56
+ supported_sampling_parameters: model.nebiusSupportedParameters ?? [],
57
+ })),
58
+ }), { mode: 0o600, flag: "wx" });
59
+ await rename(temp, path);
60
+ }
61
+ finally {
62
+ await unlink(temp).catch(() => { });
63
+ }
64
+ }
65
+ export async function discoverModels(options) {
66
+ const key = options.apiKey?.trim();
67
+ if (!key)
68
+ return { models: [], source: "none", warning: MISSING_KEY };
69
+ const path = cachePath(options.agentDir, key);
70
+ const cached = await readCache(path);
71
+ const now = options.now ?? Date.now();
72
+ if (!options.force && cached && now >= cached.checkedAt && now - cached.checkedAt < TTL_MS) {
73
+ return { models: cached.models, source: "cache" };
74
+ }
75
+ try {
76
+ const signal = AbortSignal.any([
77
+ AbortSignal.timeout(8000),
78
+ ...(options.signal ? [options.signal] : []),
79
+ ]);
80
+ const response = await (options.fetch ?? globalThis.fetch)(`${BASE_URL}/models?verbose=true`, {
81
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
82
+ signal,
83
+ redirect: "error",
84
+ });
85
+ if (!response.ok) {
86
+ await response.body?.cancel();
87
+ const detail = response.status === 401 || response.status === 403
88
+ ? "Token Factory rejected the credentials; check NEBIUS_API_KEY and project access."
89
+ : response.status === 429
90
+ ? "Token Factory rate limit reached."
91
+ : response.status >= 500
92
+ ? "Token Factory is temporarily unavailable."
93
+ : "Token Factory model discovery failed.";
94
+ const retry = response.headers.get("retry-after");
95
+ // Do not log arbitrary error bodies: they may echo credentials/request headers.
96
+ const safeRetry = retry && /^[\w ,:+.-]{1,100}$/.test(retry) ? ` Retry-After: ${retry}.` : "";
97
+ throw new DiscoveryError(`HTTP ${response.status}: ${detail}${safeRetry}`, response.status);
98
+ }
99
+ const reader = response.body?.getReader();
100
+ if (!reader)
101
+ throw new DiscoveryError("Empty Token Factory model response.");
102
+ const chunks = [];
103
+ let size = 0;
104
+ try {
105
+ while (true) {
106
+ const { done, value } = await reader.read();
107
+ if (done)
108
+ break;
109
+ size += value.byteLength;
110
+ if (size > MAX_BYTES)
111
+ throw new DiscoveryError("Token Factory model response exceeds 4 MiB.");
112
+ chunks.push(value);
113
+ }
114
+ }
115
+ finally {
116
+ await reader.cancel().catch(() => { });
117
+ }
118
+ let payload;
119
+ try {
120
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
121
+ }
122
+ catch {
123
+ throw new DiscoveryError("Malformed JSON in Token Factory model response.");
124
+ }
125
+ const models = parseModels(payload);
126
+ try {
127
+ await writeCache(path, models, now);
128
+ }
129
+ catch {
130
+ return {
131
+ models,
132
+ source: "network",
133
+ warning: "Nebius models loaded, but the metadata cache could not be written.",
134
+ };
135
+ }
136
+ return { models, source: "network" };
137
+ }
138
+ catch (error) {
139
+ const authFailure = error instanceof DiscoveryError && (error.status === 401 || error.status === 403);
140
+ if (authFailure)
141
+ await unlink(path).catch(() => { });
142
+ const fallback = !authFailure && !options.signal?.aborted ? cached : undefined;
143
+ const detail = error instanceof DiscoveryError
144
+ ? error.message
145
+ : error instanceof Error && error.message.startsWith("Malformed Token Factory")
146
+ ? error.message
147
+ : "Token Factory model discovery could not complete (connectivity, timeout, or cancellation).";
148
+ return {
149
+ models: fallback?.models ?? [],
150
+ source: fallback ? "stale" : "none",
151
+ warning: `Nebius: ${detail} ${fallback ? "Using stale cached models." : "Use models.json for an offline model definition, then retry with /nebius-refresh."}`,
152
+ };
153
+ }
154
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ /** Preserve retry diagnostics that Pi's SDK error-to-string conversion otherwise drops. */
2
+ export function withErrorDetails(fetcher) {
3
+ return async (input, init) => {
4
+ const response = await fetcher(input, init);
5
+ if (response.ok)
6
+ return response; // Successful SSE bodies remain completely untouched.
7
+ const details = [];
8
+ for (const [name, value] of response.headers) {
9
+ if (name === "retry-after" ||
10
+ name === "retry-after-ms" ||
11
+ name === "x-request-id" ||
12
+ name.startsWith("x-ratelimit-") ||
13
+ name.startsWith("ratelimit-")) {
14
+ details.push(`${name}: ${value}`);
15
+ }
16
+ }
17
+ if (details.length === 0)
18
+ return response;
19
+ const body = await response.text();
20
+ const headers = new Headers(response.headers);
21
+ headers.delete("content-length");
22
+ headers.delete("content-encoding");
23
+ headers.set("content-type", "text/plain; charset=utf-8");
24
+ // Headers come first so Pi's error-body truncation keeps retry diagnostics.
25
+ // The original body is retained verbatim, including context-overflow markers.
26
+ return new Response(`${details.join("\n")}\n${body}`, {
27
+ status: response.status,
28
+ statusText: response.statusText,
29
+ headers,
30
+ });
31
+ };
32
+ }
package/dist/index.js ADDED
@@ -0,0 +1,86 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { registerBenchmarkCommand } from "./benchmark/command.js";
3
+ import { discoverModels, MISSING_KEY } from "./discovery.js";
4
+ import { applyModelSettings, loadSettings, settingsPath, } from "./model-settings.js";
5
+ import { registerModelSettingsCommand } from "./model-settings-command.js";
6
+ import { nebiusProvider } from "./provider.js";
7
+ export default async function nebius(pi) {
8
+ const path = settingsPath(getAgentDir());
9
+ let settings = {};
10
+ try {
11
+ settings = await loadSettings(path);
12
+ }
13
+ catch (error) {
14
+ process.stderr.write(`Nebius settings were not loaded: ${String(error)}\n`);
15
+ }
16
+ let catalog = [];
17
+ const register = () => pi.registerProvider(nebiusProvider(catalog.map((model) => applyModelSettings(model, settings[model.id])), settings));
18
+ registerBenchmarkCommand(pi, () => settings);
19
+ registerModelSettingsCommand(pi, {
20
+ path,
21
+ models: () => catalog,
22
+ settings: () => settings,
23
+ update: (updated) => {
24
+ settings = updated;
25
+ register();
26
+ },
27
+ });
28
+ let pending;
29
+ const initialize = (force = false) => {
30
+ pending ??= discoverModels({
31
+ apiKey: process.env.NEBIUS_API_KEY,
32
+ agentDir: getAgentDir(),
33
+ force,
34
+ })
35
+ .then((result) => {
36
+ catalog = result.models;
37
+ register();
38
+ return result;
39
+ })
40
+ .finally(() => {
41
+ pending = undefined;
42
+ });
43
+ return pending;
44
+ };
45
+ const initial = await initialize();
46
+ if (initial.warning)
47
+ process.stderr.write(`${initial.warning}\n`);
48
+ pi.on("session_start", async (_event, ctx) => {
49
+ if (initial.warning && ctx.hasUI)
50
+ ctx.ui.notify(initial.warning, "warning");
51
+ });
52
+ pi.registerCommand("nebius-refresh", {
53
+ description: "Refresh the Nebius Token Factory model catalog",
54
+ handler: async (_args, ctx) => {
55
+ const result = await initialize(true);
56
+ const message = result.warning ?? `Nebius: loaded ${result.models.length} models.`;
57
+ if (ctx.hasUI)
58
+ ctx.ui.notify(message, result.warning ? "warning" : "info");
59
+ else
60
+ process.stderr.write(`${message}\n`);
61
+ },
62
+ });
63
+ pi.on("message_end", async (event) => {
64
+ const message = event.message;
65
+ if (message.role !== "assistant" ||
66
+ message.provider !== "nebius" ||
67
+ message.stopReason !== "error")
68
+ return;
69
+ const original = message.errorMessage ?? "Unknown provider error";
70
+ if (original.startsWith("Nebius Token Factory:"))
71
+ return;
72
+ const hint = /\b(?:401|403)\b/.test(original)
73
+ ? "Token Factory rejected the credentials; check NEBIUS_API_KEY and project access."
74
+ : /\b429\b|rate.?limit/i.test(original)
75
+ ? "Rate limited; retry after the server's requested delay."
76
+ : /\b404\b|model.*(?:not found|unavailable|does not exist)/i.test(original)
77
+ ? "Model unavailable; run /nebius-refresh and select a current model with /model."
78
+ : /\b5\d\d\b/.test(original)
79
+ ? "Token Factory is temporarily unavailable; retry later."
80
+ : /no.*(?:api.?key|auth)|not configured/i.test(original)
81
+ ? MISSING_KEY
82
+ : "Request failed.";
83
+ // Preserve Pi's normalized error, including status, retry hints, and overflow markers.
84
+ return { message: { ...message, errorMessage: `Nebius Token Factory: ${hint}\n${original}` } };
85
+ });
86
+ }
@@ -0,0 +1,130 @@
1
+ import { applyModelSettings, efforts, saveModelSettings, validateSettings, } from "./model-settings.js";
2
+ export function registerModelSettingsCommand(pi, options) {
3
+ pi.registerCommand("nebius-model", {
4
+ description: "Configure saved per-model Nebius settings",
5
+ handler: async (args, ctx) => {
6
+ if (!ctx.hasUI) {
7
+ ctx.ui.notify("/nebius-model requires interactive Pi.", "warning");
8
+ return;
9
+ }
10
+ if (!ctx.isIdle()) {
11
+ ctx.ui.notify("Wait for the current response before changing model settings.", "warning");
12
+ return;
13
+ }
14
+ const models = options.models();
15
+ const id = args.trim() ||
16
+ (await ctx.ui.select("Nebius model settings", models.map((model) => model.id)));
17
+ if (!id)
18
+ return;
19
+ const base = models.find((model) => model.id === id);
20
+ if (!base) {
21
+ ctx.ui.notify("Unknown model. Run /nebius-refresh first.", "error");
22
+ return;
23
+ }
24
+ try {
25
+ while (true) {
26
+ const current = options.settings()[id] ?? {};
27
+ const effective = applyModelSettings(base, current);
28
+ const choices = [
29
+ `Temperature: ${current.temperature ?? "inherit"}`,
30
+ `Reasoning effort: ${current.reasoningEffort ?? "inherit"}`,
31
+ `Maximum output tokens: ${current.maxTokens ?? `inherit (${base.maxTokens})`}`,
32
+ "Advanced metadata settings",
33
+ "Reset all overrides",
34
+ "Done",
35
+ ];
36
+ const action = await ctx.ui.select(id, choices);
37
+ if (!action || action === "Done")
38
+ return;
39
+ const next = { ...current };
40
+ if (action === "Reset all overrides") {
41
+ for (const key of Object.keys(next))
42
+ delete next[key];
43
+ }
44
+ else if (action === "Advanced metadata settings") {
45
+ const advanced = await ctx.ui.select("Local metadata only; does not change server capabilities", ["Context window", "Reasoning support", "Back"]);
46
+ if (!advanced || advanced === "Back")
47
+ continue;
48
+ if (advanced === "Context window") {
49
+ const value = await ctx.ui.input("Context window: positive integer; blank to inherit", String(current.contextWindow ?? ""));
50
+ if (value === undefined)
51
+ continue;
52
+ if (!value.trim())
53
+ delete next.contextWindow;
54
+ else
55
+ next.contextWindow = Number(value);
56
+ }
57
+ else {
58
+ const value = await ctx.ui.select("Reasoning support", [
59
+ "Inherit",
60
+ "Enabled",
61
+ "Disabled",
62
+ ]);
63
+ if (!value)
64
+ continue;
65
+ if (value === "Inherit")
66
+ delete next.reasoning;
67
+ else
68
+ next.reasoning = value === "Enabled";
69
+ if (value === "Disabled")
70
+ delete next.reasoningEffort;
71
+ }
72
+ }
73
+ else if (action === choices[1]) {
74
+ if (!effective.reasoning || !effective.compat?.supportsReasoningEffort) {
75
+ ctx.ui.notify("The catalog does not advertise reasoning-effort control for this model. No override will be sent.", "warning");
76
+ continue;
77
+ }
78
+ const value = await ctx.ui.select("Reasoning effort (saved value overrides Pi's thinking setting)", ["Inherit", ...efforts]);
79
+ if (!value)
80
+ continue;
81
+ if (value === "Inherit")
82
+ delete next.reasoningEffort;
83
+ else
84
+ next.reasoningEffort = value;
85
+ }
86
+ else {
87
+ const temperature = action === choices[0];
88
+ if (temperature && !base.nebiusSupportedParameters?.includes("temperature")) {
89
+ ctx.ui.notify("The catalog does not advertise temperature control for this model. No override will be sent.", "warning");
90
+ continue;
91
+ }
92
+ const key = temperature ? "temperature" : "maxTokens";
93
+ const value = await ctx.ui.input(temperature
94
+ ? "Temperature: 0–2; blank to inherit"
95
+ : "Maximum output tokens: positive integer; blank to inherit", String(current[key] ?? ""));
96
+ if (value === undefined)
97
+ continue;
98
+ if (!value.trim())
99
+ delete next[key];
100
+ else
101
+ next[key] = Number(value);
102
+ }
103
+ try {
104
+ validateSettings(next);
105
+ if (next.maxTokens !== undefined &&
106
+ next.maxTokens > (next.contextWindow ?? base.contextWindow))
107
+ throw new Error("Maximum output tokens cannot exceed the context window.");
108
+ }
109
+ catch (error) {
110
+ ctx.ui.notify(String(error), "error");
111
+ continue;
112
+ }
113
+ const saved = await saveModelSettings(options.path, id, next);
114
+ options.update(saved);
115
+ if (ctx.model?.provider === "nebius" && ctx.model.id === id) {
116
+ const model = ctx.modelRegistry
117
+ .getAll()
118
+ .find((candidate) => candidate.provider === "nebius" && candidate.id === id);
119
+ if (model)
120
+ await pi.setModel(model);
121
+ }
122
+ ctx.ui.notify("Saved. New requests use these settings; running benchmarks keep their starting settings.", "info");
123
+ }
124
+ }
125
+ catch (error) {
126
+ ctx.ui.notify(`Could not update Nebius settings: ${String(error)}`, "error");
127
+ }
128
+ },
129
+ });
130
+ }
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { isRecord } from "./models.js";
5
+ export const efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
6
+ export const settingsPath = (agentDir) => join(agentDir, "pi-nebius", "model-settings.json");
7
+ export function validateSettings(value) {
8
+ if (!isRecord(value))
9
+ throw new Error("Model settings must be an object.");
10
+ const allowed = ["temperature", "reasoningEffort", "maxTokens", "contextWindow", "reasoning"];
11
+ for (const key of Object.keys(value)) {
12
+ if (!allowed.includes(key))
13
+ throw new Error(`Unknown model setting: ${key}`);
14
+ const v = value[key];
15
+ if (key === "temperature" && (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 2))
16
+ throw new Error("Temperature must be between 0 and 2.");
17
+ if ((key === "maxTokens" || key === "contextWindow") &&
18
+ (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0))
19
+ throw new Error(`${key} must be a positive integer.`);
20
+ if (key === "reasoningEffort" && !efforts.includes(v))
21
+ throw new Error("Invalid reasoning effort.");
22
+ if (key === "reasoning" && typeof v !== "boolean")
23
+ throw new Error("Reasoning must be a boolean.");
24
+ }
25
+ if (typeof value.maxTokens === "number" &&
26
+ typeof value.contextWindow === "number" &&
27
+ value.maxTokens > value.contextWindow)
28
+ throw new Error("Maximum output tokens cannot exceed the context window.");
29
+ return { ...value };
30
+ }
31
+ export async function loadSettings(path) {
32
+ let text;
33
+ try {
34
+ text = await readFile(path, "utf8");
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return {};
39
+ throw error;
40
+ }
41
+ const value = JSON.parse(text);
42
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.models))
43
+ throw new Error("Invalid Nebius model settings file.");
44
+ return Object.fromEntries(Object.entries(value.models).map(([id, settings]) => [id, validateSettings(settings)]));
45
+ }
46
+ export async function saveModelSettings(path, id, settings) {
47
+ // Re-read before each edit so unrelated changes from another Pi session are retained.
48
+ const models = await loadSettings(path);
49
+ const next = validateSettings(settings);
50
+ const entries = Object.entries(models).filter(([key]) => key !== id);
51
+ if (Object.keys(next).length)
52
+ entries.push([id, next]);
53
+ const updated = Object.fromEntries(entries);
54
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
55
+ const temporary = `${path}.${randomUUID()}.tmp`;
56
+ try {
57
+ await writeFile(temporary, `${JSON.stringify({ version: 1, models: updated }, null, 2)}\n`, {
58
+ mode: 0o600,
59
+ flag: "wx",
60
+ });
61
+ await rename(temporary, path);
62
+ }
63
+ finally {
64
+ await rm(temporary, { force: true });
65
+ }
66
+ return updated;
67
+ }
68
+ export function applyModelSettings(model, settings = {}) {
69
+ const contextWindow = settings.contextWindow ?? model.contextWindow;
70
+ return {
71
+ ...model,
72
+ contextWindow,
73
+ maxTokens: Math.min(settings.maxTokens ?? model.maxTokens, contextWindow),
74
+ reasoning: settings.reasoning ?? model.reasoning,
75
+ };
76
+ }
77
+ /** Apply only supported request overrides; never store prompts or messages. */
78
+ export function applyRequestSettings(payload, model, settings = {}) {
79
+ if (!isRecord(payload))
80
+ return payload;
81
+ const result = { ...payload };
82
+ if (settings.temperature !== undefined &&
83
+ model.nebiusSupportedParameters?.includes("temperature"))
84
+ result.temperature = settings.temperature;
85
+ if (settings.reasoningEffort !== undefined &&
86
+ model.reasoning &&
87
+ model.compat?.supportsReasoningEffort)
88
+ result.reasoning_effort = settings.reasoningEffort;
89
+ if (settings.maxTokens !== undefined) {
90
+ delete result.max_completion_tokens;
91
+ result.max_tokens = Math.min(settings.maxTokens, model.contextWindow);
92
+ }
93
+ return result;
94
+ }
95
+ export function requestSettings(payload) {
96
+ if (!isRecord(payload))
97
+ return {};
98
+ return Object.fromEntries(["temperature", "reasoning_effort", "max_tokens", "max_completion_tokens", "top_p"]
99
+ .filter((key) => payload[key] !== undefined)
100
+ .map((key) => [key, payload[key]]));
101
+ }
package/dist/models.js ADDED
@@ -0,0 +1,62 @@
1
+ export const BASE_URL = "https://api.tokenfactory.nebius.com/v1";
2
+ export function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ /** Only retain fields we understand: no credentials or arbitrary server data reach the cache. */
6
+ export function parseModels(payload) {
7
+ if (!isRecord(payload) || payload.object !== "list" || !Array.isArray(payload.data)) {
8
+ throw new Error("Malformed Token Factory model list: expected { object: 'list', data: [...] }");
9
+ }
10
+ const models = new Map();
11
+ for (const entry of payload.data) {
12
+ if (!isRecord(entry) ||
13
+ typeof entry.id !== "string" ||
14
+ !entry.id.trim() ||
15
+ entry.id.length > 512 ||
16
+ [...entry.id].some((char) => /\s/.test(char) || char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
17
+ throw new Error("Malformed Token Factory model list: invalid model identifier");
18
+ }
19
+ if (entry.status != null && entry.status !== "active")
20
+ continue;
21
+ const modality = isRecord(entry.architecture) ? entry.architecture.modality : undefined;
22
+ // Nebius also serves embeddings and image generation. Only expose text-output models.
23
+ if (typeof modality === "string" && !/->text$/.test(modality))
24
+ continue;
25
+ const parameters = Array.isArray(entry.supported_sampling_parameters)
26
+ ? entry.supported_sampling_parameters
27
+ : [];
28
+ const features = Array.isArray(entry.supported_features) ? entry.supported_features : [];
29
+ const effort = parameters.includes("reasoning_effort");
30
+ const contextWindow = positiveInteger(entry.context_length) ?? 32768;
31
+ models.set(entry.id, {
32
+ id: entry.id,
33
+ name: typeof entry.name === "string" && entry.name.trim() ? entry.name : entry.id,
34
+ provider: "nebius",
35
+ api: "openai-completions",
36
+ baseUrl: BASE_URL,
37
+ reasoning: effort || features.includes("reasoning"),
38
+ input: typeof modality === "string" && modality.split("->")[0]?.includes("image")
39
+ ? ["text", "image"]
40
+ : ["text"],
41
+ nebiusSupportedParameters: parameters.filter((value) => typeof value === "string"),
42
+ contextWindow,
43
+ // No documented output-token limit.
44
+ maxTokens: Math.min(4096, Math.max(1, Math.floor(contextWindow / 4))),
45
+ // Required by Pi's Model type; unused by our benchmark.
46
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
47
+ compat: {
48
+ supportsStore: false,
49
+ supportsDeveloperRole: false,
50
+ supportsStrictMode: false,
51
+ supportsOpenAIGrammarTools: false,
52
+ supportsReasoningEffort: effort,
53
+ supportsUsageInStreaming: true,
54
+ maxTokensField: "max_tokens",
55
+ },
56
+ });
57
+ }
58
+ return [...models.values()].sort((a, b) => a.id.localeCompare(b.id));
59
+ }
60
+ function positiveInteger(value) {
61
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
62
+ }
@@ -0,0 +1,48 @@
1
+ import { createProvider } from "@earendil-works/pi-ai";
2
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
3
+ import { withErrorDetails } from "./errors.js";
4
+ import { applyRequestSettings } from "./model-settings.js";
5
+ import { BASE_URL } from "./models.js";
6
+ export function nebiusProvider(models, settings = {}, onRequest) {
7
+ const adapter = openAICompletionsApi();
8
+ const api = {
9
+ stream: (model, context, options) => adapter.stream(model, context, {
10
+ ...options,
11
+ onPayload: async (payload, selected) => {
12
+ const previous = await options?.onPayload?.(payload, selected);
13
+ const adjusted = applyRequestSettings(previous ?? payload, model, settings[model.id]);
14
+ onRequest?.(adjusted);
15
+ return adjusted;
16
+ },
17
+ fetch: withErrorDetails(options?.fetch ?? globalThis.fetch),
18
+ }),
19
+ streamSimple: (model, context, options) => adapter.streamSimple(model, context, {
20
+ ...options,
21
+ onPayload: async (payload, selected) => {
22
+ const previous = await options?.onPayload?.(payload, selected);
23
+ const adjusted = applyRequestSettings(previous ?? payload, model, settings[model.id]);
24
+ onRequest?.(adjusted);
25
+ return adjusted;
26
+ },
27
+ fetch: withErrorDetails(options?.fetch ?? globalThis.fetch),
28
+ }),
29
+ };
30
+ return createProvider({
31
+ id: "nebius",
32
+ name: "Nebius Token Factory",
33
+ baseUrl: BASE_URL,
34
+ models,
35
+ api,
36
+ auth: {
37
+ apiKey: {
38
+ name: "NEBIUS_API_KEY",
39
+ // Ambient-only auth: no login flow and no credential persistence.
40
+ async resolve({ ctx, signal }) {
41
+ signal.throwIfAborted();
42
+ const key = (await ctx.env("NEBIUS_API_KEY"))?.trim();
43
+ return key ? { auth: { apiKey: key }, source: "NEBIUS_API_KEY" } : undefined;
44
+ },
45
+ },
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,35 @@
1
+ # Benchmark research notes
2
+
3
+ Inspected 2026-09-16. Pi published packages: **0.85.1**. Current repository head during inspection: `60e7e76bd7ea25cad1dd6f3f1ce0d18814a42759`. Nebius OpenAPI version: **20260910-cd76b4886**.
4
+
5
+ ## Pi contracts inspected
6
+
7
+ - [SDK guide](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/sdk.md) and [SDK implementation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/sdk.ts): session creation, isolated resource loaders, normal built-in tools, configured runtime, and in-memory sessions.
8
+ - [Extension event types](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts): `before_agent_start`, `before_provider_request`, `after_provider_response`, `turn_start`, `message_end`, and tool execution events. Request hooks permit transformation, but this benchmark does not return any modifications.
9
+ - [AgentSession](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/agent-session.ts): `subscribe`, `prompt`, `abort`, retry events, compaction events, `agent_settled`, and effective thinking level.
10
+ - [System prompt builder](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/system-prompt.ts): includes cwd even when a custom system prompt is supplied. The SDK resolves relative cwd before constructing the session. This drove the sequential fixed-active-path design.
11
+ - [Model runtime](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/model-runtime.ts) and [provider contracts](https://github.com/earendil-works/pi/blob/main/packages/ai/src/models.ts): native provider registration and stream options with injected `fetch`.
12
+ - [Chat Completions adapter](https://github.com/earendil-works/pi/blob/main/packages/ai/src/api/openai-completions.ts): parses streamed usage, subtracts cache reads/writes from Pi's `usage.input`, tracks reasoning as an output subset, and can default missing usage/detail fields to zero. Raw provider usage is observed to preserve the distinction between absent and zero.
13
+ - [Telemetry package](https://github.com/earendil-works/pi/tree/main/packages/telemetry): span context/start/end/event contracts exist. Client spans do not establish server-only generation timing or guarantee a usage measurement for every HTTP retry. The benchmark uses public events and provider transport injection instead of installing a telemetry exporter or patching Pi core.
14
+ - [Bash tool](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/tools/bash.ts): cancellation kills process trees, but shell processes can be detached. The runner asks Pi to abort first, then applies a bounded process-tree cleanup fallback. POSIX-only behavior is documented.
15
+
16
+ `before_provider_request` counts logical adapter calls, not necessarily every HTTP attempt. `after_provider_response` occurs after successful response acquisition and lacks raw stream usage. Neither alone satisfies request-level accounting across retries. The observer therefore instruments the existing adapter's fetch call and passes request arguments and body bytes through unchanged. Session events provide separate turn/tool boundaries; requests during default Pi compaction are labeled and included.
17
+
18
+ ## Nebius usage and models
19
+
20
+ - [Chat Completions API](https://docs.tokenfactory.nebius.com/api-reference/inference/create-chat-completion) and [OpenAPI schema](https://api.tokenfactory.nebius.com/openapi.json): `Usage` requires `prompt_tokens`, `completion_tokens`, and `total_tokens`; optional prompt details include `cached_tokens`, and optional completion details include `reasoning_tokens`. Streaming supports `stream_options.include_usage`, which the existing provider already enables.
21
+ - [List models](https://docs.tokenfactory.nebius.com/api-reference/models/list-models): `verbose=true` returns rich metadata, including context length, modality and optional capabilities. `created` and response fingerprints do not establish an immutable model revision. No revision field is documented in `RichModel`.
22
+
23
+ No live key was available. Documentation defines the supported fields; tests exercise them using schema-shaped fixtures. No fixture is presented as a recording of an authenticated Nebius account response.
24
+
25
+ ## Agentic benchmark methodology
26
+
27
+ Read the official [cookbook article](https://dev.nebius.com/cookbook/agent-cost-benchmark) and its [implementation](https://github.com/nebius/token-factory-cookbook/blob/main/agents/agent-cost-comparison-1/agent_cost_comparison_1.py).
28
+
29
+ Its methodology runs the same data-analysis task through a filesystem agent with per-model input copies, validates generated JSON against expected values, accumulates message usage/tool counts,. It retains recoverable usage after errors/timeouts rather than discarding failed runs. The Pi runner retains these methodological choices while using coding fixtures, external executable validators, and physical-request traces.
30
+
31
+ The cookbook uses Deep Agents and includes a model-specific harness-middleware workaround. This project copies neither the agent framework nor that behavioral change. Our repetitions, timing boundaries, and unknown-usage handling are explicit so comparisons can be audited.
32
+
33
+ ## Measurement limits
34
+
35
+ Provider-reported tokens are exact as reported, not independently audited billing data. Unknown fields remain unknown. Client timings cannot isolate server compute or pure agent overhead. The last submitted prompt is measurable when usage is returned; final conversation token size is not. Model alias drift, serving hardware/load, account quotas, caches, and upstream updates remain external reproducibility limits.