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,150 @@
1
+ import type { ProviderStreams } from "@earendil-works/pi-ai";
2
+ import {
3
+ createAgentSession,
4
+ DefaultResourceLoader,
5
+ ModelRuntime,
6
+ SessionManager,
7
+ SettingsManager,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { requestSettings } from "../model-settings.ts";
10
+ import { nebiusProvider } from "../provider.ts";
11
+ import { Instrumentation, redactor } from "./instrumentation.ts";
12
+ import type { WorkerInput, WorkerMessage } from "./types.ts";
13
+
14
+ async function run(input: WorkerInput) {
15
+ const redact = redactor([input.apiKey]);
16
+ const send = (message: WorkerMessage) => {
17
+ if (process.connected) process.send?.(JSON.parse(redact(JSON.stringify(message))), () => {});
18
+ };
19
+ const start = performance.now();
20
+ const observer = new Instrumentation(
21
+ (observation) => send({ type: "observation", observation }),
22
+ () => performance.now() - start,
23
+ redact,
24
+ );
25
+ let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
26
+ let cancelled = false;
27
+ let error: string | null = null;
28
+ const settings = SettingsManager.inMemory();
29
+ const requests: Record<string, unknown>[] = [];
30
+ const effectiveSettings: Record<string, unknown> = {
31
+ modelOverrides: input.modelSettings ?? {},
32
+ modelLimits: { contextWindow: input.model.contextWindow, maxTokens: input.model.maxTokens },
33
+ requestParameters: requests,
34
+ configuration: settings.getGlobalSettings(),
35
+ tools: input.definition.tools,
36
+ compaction: settings.getCompactionSettings(),
37
+ retry: settings.getRetrySettings(),
38
+ providerRetry: settings.getProviderRetrySettings(),
39
+ thinkingLevel: settings.getDefaultThinkingLevel() ?? "medium",
40
+ resourcePolicy: "no external extensions, skills, context files, prompt templates, or themes",
41
+ sdkCwd: ".",
42
+ };
43
+ const abort = () => {
44
+ cancelled = true;
45
+ void session?.abort().catch(() => {});
46
+ };
47
+ process.on("message", (message) => {
48
+ if (message === "abort") abort();
49
+ if (message === "shutdown") process.exit(0);
50
+ });
51
+ process.on("SIGTERM", abort);
52
+ try {
53
+ const base = nebiusProvider(
54
+ [input.model],
55
+ { [input.model.id]: input.modelSettings ?? {} },
56
+ (payload) => {
57
+ requests.push(requestSettings(payload));
58
+ send({ type: "settings", effectiveSettings });
59
+ },
60
+ );
61
+ const baseStreams: ProviderStreams = base;
62
+ const provider = {
63
+ ...base,
64
+ // Key arrives over IPC, never in argv, config files, or child tool environments.
65
+ auth: {
66
+ apiKey: {
67
+ name: "NEBIUS_API_KEY",
68
+ async resolve() {
69
+ return { auth: { apiKey: input.apiKey }, source: "NEBIUS_API_KEY (ephemeral IPC)" };
70
+ },
71
+ },
72
+ },
73
+ stream: ((model, context, options) =>
74
+ baseStreams.stream(model, context, {
75
+ ...options,
76
+ fetch: observer.observeFetch(options?.fetch ?? globalThis.fetch),
77
+ })) as typeof base.stream,
78
+ streamSimple: ((model, context, options) =>
79
+ base.streamSimple(model, context, {
80
+ ...options,
81
+ fetch: observer.observeFetch(options?.fetch ?? globalThis.fetch),
82
+ })) as typeof base.streamSimple,
83
+ };
84
+ const runtime = await ModelRuntime.create({
85
+ authPath: `${input.agentDir}/auth.json`,
86
+ modelsPath: null,
87
+ modelsStorePath: `${input.agentDir}/models-store.json`,
88
+ allowModelNetwork: false,
89
+ });
90
+ const loader = new DefaultResourceLoader({
91
+ cwd: ".",
92
+ agentDir: input.agentDir,
93
+ settingsManager: settings,
94
+ noExtensions: true,
95
+ noSkills: true,
96
+ noPromptTemplates: true,
97
+ noThemes: true,
98
+ noContextFiles: true,
99
+ ...(input.definition.systemPrompt ? { systemPrompt: input.definition.systemPrompt } : {}),
100
+ extensionFactories: [
101
+ (pi) => {
102
+ pi.registerProvider(provider);
103
+ pi.on("before_agent_start", (event) => {
104
+ observer.systemPrompt(event.systemPrompt);
105
+ });
106
+ },
107
+ ],
108
+ });
109
+ await loader.reload();
110
+ if (loader.getExtensions().errors.length)
111
+ throw new Error("Pi could not load benchmark instrumentation");
112
+ ({ session } = await createAgentSession({
113
+ cwd: ".",
114
+ agentDir: input.agentDir,
115
+ modelRuntime: runtime,
116
+ model: input.model,
117
+ settingsManager: settings,
118
+ resourceLoader: loader,
119
+ sessionManager: SessionManager.inMemory("."),
120
+ tools: input.definition.tools,
121
+ }));
122
+ session.subscribe((event) => observer.onEvent(event));
123
+ effectiveSettings.effectiveThinkingLevel = session.thinkingLevel;
124
+ send({ type: "settings", effectiveSettings });
125
+ if (cancelled) throw new Error("Cancelled during Pi initialization");
126
+ await session.prompt(input.definition.task);
127
+ } catch (caught) {
128
+ error = redact(String(caught));
129
+ } finally {
130
+ session?.dispose();
131
+ await new Promise<void>((resolve) => {
132
+ if (!process.connected) {
133
+ resolve();
134
+ return;
135
+ }
136
+ process.send?.({ type: "done", error, effectiveSettings } satisfies WorkerMessage, () =>
137
+ resolve(),
138
+ );
139
+ });
140
+ // Parent cleans any detached tool descendants, then requests shutdown.
141
+ }
142
+ }
143
+
144
+ if (process.send)
145
+ process.once("message", (input: WorkerInput) => {
146
+ void run(input).catch(() => {
147
+ process.exitCode = 1;
148
+ process.disconnect?.();
149
+ });
150
+ });
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, copyFile, lstat, mkdir, readdir, readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+
5
+ interface Entry {
6
+ path: string;
7
+ directory: boolean;
8
+ executable: number;
9
+ }
10
+
11
+ /** Reject links/special files rather than risk a copy retaining access to the source fixture. */
12
+ async function treeEntries(root: string, prefix = ""): Promise<Entry[]> {
13
+ const rootInfo = await lstat(root);
14
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
15
+ throw new Error("Fixture root must be a real directory, not a symbolic link");
16
+ const files: Entry[] = [];
17
+ for (const name of (await readdir(join(root, prefix))).sort()) {
18
+ if (name === ".git") continue;
19
+ if (name === ".env" || name.startsWith(".env."))
20
+ throw new Error("Fixtures must not contain .env credential files");
21
+ const relative = prefix ? `${prefix}/${name}` : name;
22
+ const info = await lstat(join(root, relative));
23
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()))
24
+ throw new Error(
25
+ `Unsupported fixture entry: ${relative} (links/special files are not allowed)`,
26
+ );
27
+ files.push({ path: relative, directory: info.isDirectory(), executable: info.mode & 0o111 });
28
+ if (info.isDirectory()) files.push(...(await treeEntries(root, relative)));
29
+ }
30
+ return files;
31
+ }
32
+ export async function treeFiles(root: string): Promise<string[]> {
33
+ return (await treeEntries(root)).filter((entry) => !entry.directory).map((entry) => entry.path);
34
+ }
35
+ export async function hashTree(root: string): Promise<string> {
36
+ const digest = createHash("sha256");
37
+ for (const entry of await treeEntries(root)) {
38
+ const content = entry.directory ? Buffer.alloc(0) : await readFile(join(root, entry.path));
39
+ digest.update(
40
+ JSON.stringify([
41
+ entry.path,
42
+ entry.directory,
43
+ entry.directory ? 0 : entry.executable,
44
+ content.length,
45
+ ]),
46
+ );
47
+ digest.update(content);
48
+ }
49
+ return digest.digest("hex");
50
+ }
51
+ export async function copyTree(source: string, target: string) {
52
+ await mkdir(target, { recursive: true, mode: 0o700 });
53
+ for (const entry of await treeEntries(source)) {
54
+ const sourcePath = join(source, entry.path);
55
+ const targetPath = join(target, entry.path);
56
+ if (entry.directory) {
57
+ await mkdir(targetPath, { recursive: true, mode: 0o700 });
58
+ continue;
59
+ }
60
+ await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
61
+ await copyFile(sourcePath, targetPath);
62
+ await chmod(targetPath, 0o600 | entry.executable);
63
+ }
64
+ }
@@ -0,0 +1,176 @@
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, type NebiusModel, parseModels } from "./models.ts";
5
+
6
+ export const MISSING_KEY = 'Nebius Token Factory: set export NEBIUS_API_KEY="..." and restart Pi.';
7
+ const TTL_MS = 24 * 60 * 60 * 1000;
8
+ const MAX_BYTES = 4 * 1024 * 1024;
9
+
10
+ export class DiscoveryError extends Error {
11
+ constructor(
12
+ message: string,
13
+ readonly status?: number,
14
+ ) {
15
+ super(message);
16
+ }
17
+ }
18
+
19
+ export interface DiscoveryOptions {
20
+ apiKey?: string;
21
+ agentDir: string;
22
+ fetch?: typeof globalThis.fetch;
23
+ now?: number;
24
+ force?: boolean;
25
+ signal?: AbortSignal;
26
+ }
27
+
28
+ export interface DiscoveryResult {
29
+ models: NebiusModel[];
30
+ source: "network" | "cache" | "stale" | "none";
31
+ warning?: string;
32
+ }
33
+
34
+ export function cachePath(agentDir: string, apiKey: string): string {
35
+ const fingerprint = createHash("sha256").update(`${BASE_URL}\0${apiKey}`).digest("hex");
36
+ return join(agentDir, "cache", "pi-nebius", `${fingerprint}.json`);
37
+ }
38
+
39
+ async function readCache(
40
+ path: string,
41
+ ): Promise<{ checkedAt: number; models: NebiusModel[] } | undefined> {
42
+ try {
43
+ const raw = await readFile(path, "utf8");
44
+ if (Buffer.byteLength(raw) > MAX_BYTES) return undefined;
45
+ const value: unknown = JSON.parse(raw);
46
+ if (
47
+ !isRecord(value) ||
48
+ value.version !== 1 ||
49
+ typeof value.checkedAt !== "number" ||
50
+ !Number.isFinite(value.checkedAt) ||
51
+ !Array.isArray(value.models)
52
+ )
53
+ return undefined;
54
+ // Cache only normalized discovery fields, and remap through the same validation path.
55
+ return {
56
+ checkedAt: value.checkedAt,
57
+ models: parseModels({ object: "list", data: value.models }),
58
+ };
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ async function writeCache(path: string, models: NebiusModel[], checkedAt: number): Promise<void> {
65
+ const temp = `${path}.${randomUUID()}.tmp`;
66
+ try {
67
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
68
+ await writeFile(
69
+ temp,
70
+ JSON.stringify({
71
+ version: 1,
72
+ checkedAt,
73
+ models: models.map((model) => ({
74
+ id: model.id,
75
+ name: model.name,
76
+ context_length: model.contextWindow,
77
+ architecture: {
78
+ modality: model.input.includes("image") ? "text+image->text" : "text->text",
79
+ },
80
+ supported_features: model.reasoning ? ["reasoning"] : [],
81
+ supported_sampling_parameters: model.nebiusSupportedParameters ?? [],
82
+ })),
83
+ }),
84
+ { mode: 0o600, flag: "wx" },
85
+ );
86
+ await rename(temp, path);
87
+ } finally {
88
+ await unlink(temp).catch(() => {});
89
+ }
90
+ }
91
+
92
+ export async function discoverModels(options: DiscoveryOptions): Promise<DiscoveryResult> {
93
+ const key = options.apiKey?.trim();
94
+ if (!key) return { models: [], source: "none", warning: MISSING_KEY };
95
+ const path = cachePath(options.agentDir, key);
96
+ const cached = await readCache(path);
97
+ const now = options.now ?? Date.now();
98
+ if (!options.force && cached && now >= cached.checkedAt && now - cached.checkedAt < TTL_MS) {
99
+ return { models: cached.models, source: "cache" };
100
+ }
101
+ try {
102
+ const signal = AbortSignal.any([
103
+ AbortSignal.timeout(8000),
104
+ ...(options.signal ? [options.signal] : []),
105
+ ]);
106
+ const response = await (options.fetch ?? globalThis.fetch)(`${BASE_URL}/models?verbose=true`, {
107
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
108
+ signal,
109
+ redirect: "error",
110
+ });
111
+ if (!response.ok) {
112
+ await response.body?.cancel();
113
+ const detail =
114
+ response.status === 401 || response.status === 403
115
+ ? "Token Factory rejected the credentials; check NEBIUS_API_KEY and project access."
116
+ : response.status === 429
117
+ ? "Token Factory rate limit reached."
118
+ : response.status >= 500
119
+ ? "Token Factory is temporarily unavailable."
120
+ : "Token Factory model discovery failed.";
121
+ const retry = response.headers.get("retry-after");
122
+ // Do not log arbitrary error bodies: they may echo credentials/request headers.
123
+ const safeRetry = retry && /^[\w ,:+.-]{1,100}$/.test(retry) ? ` Retry-After: ${retry}.` : "";
124
+ throw new DiscoveryError(`HTTP ${response.status}: ${detail}${safeRetry}`, response.status);
125
+ }
126
+ const reader = response.body?.getReader();
127
+ if (!reader) throw new DiscoveryError("Empty Token Factory model response.");
128
+ const chunks: Uint8Array[] = [];
129
+ let size = 0;
130
+ try {
131
+ while (true) {
132
+ const { done, value } = await reader.read();
133
+ if (done) break;
134
+ size += value.byteLength;
135
+ if (size > MAX_BYTES)
136
+ throw new DiscoveryError("Token Factory model response exceeds 4 MiB.");
137
+ chunks.push(value);
138
+ }
139
+ } finally {
140
+ await reader.cancel().catch(() => {});
141
+ }
142
+ let payload: unknown;
143
+ try {
144
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
145
+ } catch {
146
+ throw new DiscoveryError("Malformed JSON in Token Factory model response.");
147
+ }
148
+ const models = parseModels(payload);
149
+ try {
150
+ await writeCache(path, models, now);
151
+ } catch {
152
+ return {
153
+ models,
154
+ source: "network",
155
+ warning: "Nebius models loaded, but the metadata cache could not be written.",
156
+ };
157
+ }
158
+ return { models, source: "network" };
159
+ } catch (error) {
160
+ const authFailure =
161
+ error instanceof DiscoveryError && (error.status === 401 || error.status === 403);
162
+ if (authFailure) await unlink(path).catch(() => {});
163
+ const fallback = !authFailure && !options.signal?.aborted ? cached : undefined;
164
+ const detail =
165
+ error instanceof DiscoveryError
166
+ ? error.message
167
+ : error instanceof Error && error.message.startsWith("Malformed Token Factory")
168
+ ? error.message
169
+ : "Token Factory model discovery could not complete (connectivity, timeout, or cancellation).";
170
+ return {
171
+ models: fallback?.models ?? [],
172
+ source: fallback ? "stale" : "none",
173
+ warning: `Nebius: ${detail} ${fallback ? "Using stale cached models." : "Use models.json for an offline model definition, then retry with /nebius-refresh."}`,
174
+ };
175
+ }
176
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,32 @@
1
+ /** Preserve retry diagnostics that Pi's SDK error-to-string conversion otherwise drops. */
2
+ export function withErrorDetails(fetcher: typeof fetch): typeof fetch {
3
+ return async (input, init) => {
4
+ const response = await fetcher(input, init);
5
+ if (response.ok) return response; // Successful SSE bodies remain completely untouched.
6
+ const details: string[] = [];
7
+ for (const [name, value] of response.headers) {
8
+ if (
9
+ name === "retry-after" ||
10
+ name === "retry-after-ms" ||
11
+ name === "x-request-id" ||
12
+ name.startsWith("x-ratelimit-") ||
13
+ name.startsWith("ratelimit-")
14
+ ) {
15
+ details.push(`${name}: ${value}`);
16
+ }
17
+ }
18
+ if (details.length === 0) 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/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { registerBenchmarkCommand } from "./benchmark/command.ts";
3
+ import { discoverModels, MISSING_KEY } from "./discovery.ts";
4
+ import {
5
+ applyModelSettings,
6
+ loadSettings,
7
+ type ModelSettingsMap,
8
+ settingsPath,
9
+ } from "./model-settings.ts";
10
+ import { registerModelSettingsCommand } from "./model-settings-command.ts";
11
+ import type { NebiusModel } from "./models.ts";
12
+ import { nebiusProvider } from "./provider.ts";
13
+
14
+ export default async function nebius(pi: ExtensionAPI) {
15
+ const path = settingsPath(getAgentDir());
16
+ let settings: ModelSettingsMap = {};
17
+ try {
18
+ settings = await loadSettings(path);
19
+ } catch (error) {
20
+ process.stderr.write(`Nebius settings were not loaded: ${String(error)}\n`);
21
+ }
22
+ let catalog: NebiusModel[] = [];
23
+ const register = () =>
24
+ pi.registerProvider(
25
+ nebiusProvider(
26
+ catalog.map((model) => applyModelSettings(model, settings[model.id])),
27
+ settings,
28
+ ),
29
+ );
30
+ registerBenchmarkCommand(pi, () => settings);
31
+ registerModelSettingsCommand(pi, {
32
+ path,
33
+ models: () => catalog,
34
+ settings: () => settings,
35
+ update: (updated) => {
36
+ settings = updated;
37
+ register();
38
+ },
39
+ });
40
+ let pending: Promise<Awaited<ReturnType<typeof discoverModels>>> | undefined;
41
+ const initialize = (force = false) => {
42
+ pending ??= discoverModels({
43
+ apiKey: process.env.NEBIUS_API_KEY,
44
+ agentDir: getAgentDir(),
45
+ force,
46
+ })
47
+ .then((result) => {
48
+ catalog = result.models;
49
+ register();
50
+ return result;
51
+ })
52
+ .finally(() => {
53
+ pending = undefined;
54
+ });
55
+ return pending;
56
+ };
57
+ const initial = await initialize();
58
+ if (initial.warning) process.stderr.write(`${initial.warning}\n`);
59
+
60
+ pi.on("session_start", async (_event, ctx) => {
61
+ if (initial.warning && ctx.hasUI) ctx.ui.notify(initial.warning, "warning");
62
+ });
63
+ pi.registerCommand("nebius-refresh", {
64
+ description: "Refresh the Nebius Token Factory model catalog",
65
+ handler: async (_args, ctx) => {
66
+ const result = await initialize(true);
67
+ const message = result.warning ?? `Nebius: loaded ${result.models.length} models.`;
68
+ if (ctx.hasUI) ctx.ui.notify(message, result.warning ? "warning" : "info");
69
+ else process.stderr.write(`${message}\n`);
70
+ },
71
+ });
72
+ pi.on("message_end", async (event) => {
73
+ const message = event.message;
74
+ if (
75
+ message.role !== "assistant" ||
76
+ message.provider !== "nebius" ||
77
+ message.stopReason !== "error"
78
+ )
79
+ return;
80
+ const original = message.errorMessage ?? "Unknown provider error";
81
+ if (original.startsWith("Nebius Token Factory:")) return;
82
+ const hint = /\b(?:401|403)\b/.test(original)
83
+ ? "Token Factory rejected the credentials; check NEBIUS_API_KEY and project access."
84
+ : /\b429\b|rate.?limit/i.test(original)
85
+ ? "Rate limited; retry after the server's requested delay."
86
+ : /\b404\b|model.*(?:not found|unavailable|does not exist)/i.test(original)
87
+ ? "Model unavailable; run /nebius-refresh and select a current model with /model."
88
+ : /\b5\d\d\b/.test(original)
89
+ ? "Token Factory is temporarily unavailable; retry later."
90
+ : /no.*(?:api.?key|auth)|not configured/i.test(original)
91
+ ? MISSING_KEY
92
+ : "Request failed.";
93
+ // Preserve Pi's normalized error, including status, retry hints, and overflow markers.
94
+ return { message: { ...message, errorMessage: `Nebius Token Factory: ${hint}\n${original}` } };
95
+ });
96
+ }
@@ -0,0 +1,151 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ applyModelSettings,
4
+ efforts,
5
+ type ModelSettings,
6
+ type ModelSettingsMap,
7
+ saveModelSettings,
8
+ validateSettings,
9
+ } from "./model-settings.ts";
10
+ import type { NebiusModel } from "./models.ts";
11
+
12
+ export function registerModelSettingsCommand(
13
+ pi: ExtensionAPI,
14
+ options: {
15
+ path: string;
16
+ models: () => NebiusModel[];
17
+ settings: () => ModelSettingsMap;
18
+ update: (settings: ModelSettingsMap) => void;
19
+ },
20
+ ) {
21
+ pi.registerCommand("nebius-model", {
22
+ description: "Configure saved per-model Nebius settings",
23
+ handler: async (args, ctx) => {
24
+ if (!ctx.hasUI) {
25
+ ctx.ui.notify("/nebius-model requires interactive Pi.", "warning");
26
+ return;
27
+ }
28
+ if (!ctx.isIdle()) {
29
+ ctx.ui.notify("Wait for the current response before changing model settings.", "warning");
30
+ return;
31
+ }
32
+ const models = options.models();
33
+ const id =
34
+ args.trim() ||
35
+ (await ctx.ui.select(
36
+ "Nebius model settings",
37
+ models.map((model) => model.id),
38
+ ));
39
+ if (!id) return;
40
+ const base = models.find((model) => model.id === id);
41
+ if (!base) {
42
+ ctx.ui.notify("Unknown model. Run /nebius-refresh first.", "error");
43
+ return;
44
+ }
45
+ try {
46
+ while (true) {
47
+ const current = options.settings()[id] ?? {};
48
+ const effective = applyModelSettings(base, current);
49
+ const choices = [
50
+ `Temperature: ${current.temperature ?? "inherit"}`,
51
+ `Reasoning effort: ${current.reasoningEffort ?? "inherit"}`,
52
+ `Maximum output tokens: ${current.maxTokens ?? `inherit (${base.maxTokens})`}`,
53
+ "Advanced metadata settings",
54
+ "Reset all overrides",
55
+ "Done",
56
+ ];
57
+ const action = await ctx.ui.select(id, choices);
58
+ if (!action || action === "Done") return;
59
+ const next: ModelSettings = { ...current };
60
+ if (action === "Reset all overrides") {
61
+ for (const key of Object.keys(next)) delete next[key as keyof ModelSettings];
62
+ } else if (action === "Advanced metadata settings") {
63
+ const advanced = await ctx.ui.select(
64
+ "Local metadata only; does not change server capabilities",
65
+ ["Context window", "Reasoning support", "Back"],
66
+ );
67
+ if (!advanced || advanced === "Back") continue;
68
+ if (advanced === "Context window") {
69
+ const value = await ctx.ui.input(
70
+ "Context window: positive integer; blank to inherit",
71
+ String(current.contextWindow ?? ""),
72
+ );
73
+ if (value === undefined) continue;
74
+ if (!value.trim()) delete next.contextWindow;
75
+ else next.contextWindow = Number(value);
76
+ } else {
77
+ const value = await ctx.ui.select("Reasoning support", [
78
+ "Inherit",
79
+ "Enabled",
80
+ "Disabled",
81
+ ]);
82
+ if (!value) continue;
83
+ if (value === "Inherit") delete next.reasoning;
84
+ else next.reasoning = value === "Enabled";
85
+ if (value === "Disabled") delete next.reasoningEffort;
86
+ }
87
+ } else if (action === choices[1]) {
88
+ if (!effective.reasoning || !effective.compat?.supportsReasoningEffort) {
89
+ ctx.ui.notify(
90
+ "The catalog does not advertise reasoning-effort control for this model. No override will be sent.",
91
+ "warning",
92
+ );
93
+ continue;
94
+ }
95
+ const value = await ctx.ui.select(
96
+ "Reasoning effort (saved value overrides Pi's thinking setting)",
97
+ ["Inherit", ...efforts],
98
+ );
99
+ if (!value) continue;
100
+ if (value === "Inherit") delete next.reasoningEffort;
101
+ else next.reasoningEffort = value as ModelSettings["reasoningEffort"];
102
+ } else {
103
+ const temperature = action === choices[0];
104
+ if (temperature && !base.nebiusSupportedParameters?.includes("temperature")) {
105
+ ctx.ui.notify(
106
+ "The catalog does not advertise temperature control for this model. No override will be sent.",
107
+ "warning",
108
+ );
109
+ continue;
110
+ }
111
+ const key = temperature ? "temperature" : "maxTokens";
112
+ const value = await ctx.ui.input(
113
+ temperature
114
+ ? "Temperature: 0–2; blank to inherit"
115
+ : "Maximum output tokens: positive integer; blank to inherit",
116
+ String(current[key] ?? ""),
117
+ );
118
+ if (value === undefined) continue;
119
+ if (!value.trim()) delete next[key];
120
+ else next[key] = Number(value);
121
+ }
122
+ try {
123
+ validateSettings(next);
124
+ if (
125
+ next.maxTokens !== undefined &&
126
+ next.maxTokens > (next.contextWindow ?? base.contextWindow)
127
+ )
128
+ throw new Error("Maximum output tokens cannot exceed the context window.");
129
+ } catch (error) {
130
+ ctx.ui.notify(String(error), "error");
131
+ continue;
132
+ }
133
+ const saved = await saveModelSettings(options.path, id, next);
134
+ options.update(saved);
135
+ if (ctx.model?.provider === "nebius" && ctx.model.id === id) {
136
+ const model = ctx.modelRegistry
137
+ .getAll()
138
+ .find((candidate) => candidate.provider === "nebius" && candidate.id === id);
139
+ if (model) await pi.setModel(model);
140
+ }
141
+ ctx.ui.notify(
142
+ "Saved. New requests use these settings; running benchmarks keep their starting settings.",
143
+ "info",
144
+ );
145
+ }
146
+ } catch (error) {
147
+ ctx.ui.notify(`Could not update Nebius settings: ${String(error)}`, "error");
148
+ }
149
+ },
150
+ });
151
+ }