pi-omlx-picker 0.2.2 → 0.2.4

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 CHANGED
@@ -2,29 +2,40 @@
2
2
 
3
3
  > Seamlessly integrate your local [oMLX](https://github.com/jundot/omlx) models into Pi.
4
4
 
5
- This extension discovers models from a local OMLX server and registers them as native Pi providers. Switch between your local and remote models effortlessly using Pi's built-in `/model` command.
5
+ This extension discovers models from a local OMLX server and registers them as native Pi providers.
6
+ Switch between your local and remote models effortlessly using Pi's built-in `/model` command.
6
7
 
7
8
  ![Pi OMLX Picker Demo](./.assets/demo.gif)
8
9
 
9
10
  ## ✨ Features
10
11
 
11
- * **Zero-Friction Discovery:** Automatically fetches and registers available OMLX models on startup.
12
- * **Native Integration:** Models show up in the standard `/model` menu—no custom commands needed for chat.
13
- * **Smart Overrides:** Applies per-request thinking controls based on each model's `thinkingDefault` metadata.
12
+ * Auto discovery: fetches and registers available OMLX models without blocking Pi startup.
13
+ * Native integration: login uses Pi's standard `/login`, and models use Pi's standard `/model` menu.
14
+ * Smart overrides: applies per-request thinking controls based on each model's `thinkingDefault` metadata.
14
15
 
15
16
  ## 📦 Installation
16
17
 
17
- ```sh
18
+ ```bash
18
19
  pi install npm:pi-omlx-picker
19
20
  ```
20
21
 
22
+ ## 🛠️ Development
23
+
24
+ * `npm install`
25
+ * `npm run check`
26
+ * `npm run format`
27
+ * `npm test`
28
+ * `npm run test:watch`
29
+
21
30
  ## 🚀 Quick Start
22
31
 
23
- 1. Run `/omlx-login` in Pi.
24
- 2. Paste your OMLX base URL and API key when prompted.
25
- 3. Type `/model` to see and select your OMLX models.
32
+ 1. Start your local OMLX server.
33
+ 2. Run `/login` in Pi, choose **API key**, then choose **OMLX**.
34
+ 3. Enter your OMLX API key.
35
+ 4. Type `/model` to see and select your OMLX models.
36
+
37
+ The default base URL is `http://127.0.0.1:8000/v1`. Set `OMLX_BASE_URL` before starting Pi if your OMLX server uses a different URL.
26
38
 
27
- Re-run `/omlx-login` to update credentials.
28
39
 
29
40
  ## ⚙️ Configuration
30
41
 
package/index.ts CHANGED
@@ -1,23 +1,55 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { PROVIDER_KEY, saveOmlxCredential } from "./src/auth-storage.ts";
3
- import { fetchModels, resolveLocalModelSettingsPath, type OmlxModel } from "./src/catalog.ts";
4
- import { applyStoredCredentialToEnv, loadConfig, MissingEnvError, normalizeBaseUrl, type OmlxConfig } from "./src/config.ts";
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ createAssistantMessageEventStream,
5
+ type Model,
6
+ } from "@earendil-works/pi-ai";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { PROVIDER_KEY } from "./src/auth-storage.ts";
9
+ import { loadDotenvFromExtensionDir } from "./src/dotenv.ts";
10
+
11
+ loadDotenvFromExtensionDir(import.meta.url);
12
+ import {
13
+ fetchModels,
14
+ type OmlxModel,
15
+ readCatalogCache,
16
+ readLastCatalogCache,
17
+ resolveLocalModelSettingsPath,
18
+ writeCatalogCache,
19
+ } from "./src/catalog.ts";
20
+ import {
21
+ DEFAULT_OMLX_BASE_URL,
22
+ loadConfig,
23
+ type OmlxConfig,
24
+ resolveConfiguredApiKey,
25
+ } from "./src/config.ts";
5
26
  import { toProviderConfig } from "./src/provider.ts";
6
27
  import { applyOmlxThinkingControls } from "./src/thinking.ts";
7
28
 
8
29
  const PROVIDER = PROVIDER_KEY;
9
30
  const EXTENSION_SINGLETON_KEY = Symbol.for("pi-omlx-picker/loaded");
10
31
 
32
+ const STARTUP_TIMEOUT_MS = 2_000;
33
+ const POLL_INTERVAL_MS = 10 * 60 * 1000;
34
+ const BACKOFF_BASE_MS = 2_000;
35
+ const BACKOFF_MAX_MS = 60_000;
36
+
37
+ const SETUP_MODEL: OmlxModel = {
38
+ id: "setup",
39
+ displayName: "OMLX (run /login)",
40
+ };
41
+
11
42
  interface State {
12
43
  config: OmlxConfig | undefined;
13
44
  catalog: OmlxModel[];
14
45
  registered: boolean;
46
+ stopped: boolean;
15
47
  lastError: string | undefined;
16
48
  lastRefreshAt: string | undefined;
17
49
  modelSettingsPath: string | undefined;
18
50
  }
19
51
 
20
- export default async function (pi: ExtensionAPI): Promise<void> {
52
+ export default function (pi: ExtensionAPI): void {
21
53
  const globalState = globalThis as Record<PropertyKey, unknown>;
22
54
  if (globalState[EXTENSION_SINGLETON_KEY]) return;
23
55
  globalState[EXTENSION_SINGLETON_KEY] = true;
@@ -26,108 +58,192 @@ export default async function (pi: ExtensionAPI): Promise<void> {
26
58
  config: undefined,
27
59
  catalog: [],
28
60
  registered: false,
61
+ stopped: false,
29
62
  lastError: undefined,
30
63
  lastRefreshAt: undefined,
31
64
  modelSettingsPath: undefined,
32
65
  };
33
66
 
34
- applyStoredCredentialToEnv();
35
- await refreshProvider(pi, state);
67
+ registerCachedOrSetupModels(pi, state);
68
+ void startPolling(pi, state).catch((err) => {
69
+ state.lastError = err instanceof Error ? err.message : String(err);
70
+ });
36
71
 
37
72
  pi.on("before_provider_request", (event, ctx) => {
38
73
  if (ctx.model?.provider !== PROVIDER) return;
39
74
  const activeModel = findCatalogModel(state, ctx.model.id);
40
- return applyOmlxThinkingControls(event.payload, pi.getThinkingLevel(), activeModel?.thinkingDefault);
75
+ return applyOmlxThinkingControls(
76
+ event.payload,
77
+ pi.getThinkingLevel(),
78
+ activeModel?.thinkingDefault,
79
+ );
41
80
  });
42
81
 
43
- pi.registerCommand("omlx-login", {
44
- description: "Sign in to OMLX (set base URL and API key)",
45
- handler: async (_args, ctx) => {
46
- const baseUrlInput = await ctx.ui.input("OMLX base URL", "http://127.0.0.1:8000/v1");
47
- if (!baseUrlInput) return;
48
- const apiKey = await ctx.ui.input("OMLX API key", "omlx-...");
49
- if (!apiKey) return;
50
-
51
- let baseUrl: string;
52
- try {
53
- baseUrl = normalizeBaseUrl(baseUrlInput);
54
- } catch (err) {
55
- ctx.ui.notify(`Invalid base URL: ${err instanceof Error ? err.message : String(err)}`, "error");
56
- return;
57
- }
58
-
59
- ctx.ui.notify("Validating OMLX credentials…", "info");
60
- try {
61
- await fetchModels(baseUrl, apiKey, { timeoutMs: VALIDATE_TIMEOUT_MS });
62
- } catch (err) {
63
- ctx.ui.notify(`OMLX login failed: ${err instanceof Error ? err.message : String(err)}`, "error");
64
- return;
65
- }
66
-
67
- saveOmlxCredential(baseUrl, apiKey);
68
- process.env.OMLX_BASE_URL = baseUrl;
69
- process.env.OMLX_API_KEY = apiKey;
70
- await refreshProvider(pi, state);
71
- const message = state.registered
72
- ? `OMLX connected — ${state.catalog.length} models available`
73
- : `OMLX login saved but provider failed: ${state.lastError ?? "unknown error"}`;
74
- ctx.ui.notify(message, state.registered ? "info" : "warning");
75
- },
82
+ pi.on("session_shutdown", () => {
83
+ state.stopped = true;
84
+ delete globalState[EXTENSION_SINGLETON_KEY];
76
85
  });
77
86
  }
78
87
 
79
- const VALIDATE_TIMEOUT_MS = 10_000;
80
-
81
- function clearRegisteredProvider(pi: ExtensionAPI, state: State): void {
82
- if (state.registered) {
83
- pi.unregisterProvider(PROVIDER);
88
+ async function startPolling(pi: ExtensionAPI, state: State): Promise<void> {
89
+ let backoffMs = BACKOFF_BASE_MS;
90
+
91
+ while (!state.stopped) {
92
+ const result = await refreshProvider(pi, state, {
93
+ timeoutMs: STARTUP_TIMEOUT_MS,
94
+ });
95
+
96
+ if (result === "registered") {
97
+ backoffMs = BACKOFF_BASE_MS;
98
+ await sleep(POLL_INTERVAL_MS);
99
+ } else {
100
+ registerCachedOrSetupModels(pi, state);
101
+ await sleep(backoffMs);
102
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
103
+ }
84
104
  }
85
- state.catalog = [];
86
- state.registered = false;
87
- state.config = undefined;
88
- state.modelSettingsPath = undefined;
89
105
  }
90
106
 
91
- async function refreshProvider(pi: ExtensionAPI, state: State): Promise<void> {
92
- let config: OmlxConfig;
107
+ function tryLoadConfig(): OmlxConfig | undefined {
93
108
  try {
94
- config = loadConfig();
95
- } catch (err) {
96
- state.lastError =
97
- err instanceof MissingEnvError
98
- ? `${err.varName} is not set`
99
- : err instanceof Error
100
- ? err.message
101
- : String(err);
102
- clearRegisteredProvider(pi, state);
109
+ return loadConfig();
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ function registerModels(
116
+ pi: ExtensionAPI,
117
+ state: State,
118
+ config: OmlxConfig,
119
+ models: OmlxModel[],
120
+ modelSettingsPath?: string,
121
+ ): void {
122
+ pi.registerProvider(PROVIDER, {
123
+ name: "OMLX",
124
+ ...toProviderConfig(config.apiRoot, config.apiKeyEnvVar, models),
125
+ });
126
+ state.config = config;
127
+ state.catalog = models;
128
+ state.registered = true;
129
+ state.lastError = undefined;
130
+ state.lastRefreshAt = new Date().toISOString();
131
+ state.modelSettingsPath = modelSettingsPath;
132
+ }
133
+
134
+ function registerCachedOrSetupModels(pi: ExtensionAPI, state: State): void {
135
+ const config = tryLoadConfig() ?? {
136
+ apiRoot: DEFAULT_OMLX_BASE_URL,
137
+ apiKeyEnvVar: "OMLX_API_KEY",
138
+ };
139
+ const cached = readCatalogCache(config.apiRoot);
140
+ const fallbackCached = resolveConfiguredApiKey()
141
+ ? undefined
142
+ : readLastCatalogCache();
143
+ const models =
144
+ cached && cached.length > 0
145
+ ? cached
146
+ : fallbackCached && fallbackCached.length > 0
147
+ ? fallbackCached
148
+ : [SETUP_MODEL];
149
+
150
+ if (resolveConfiguredApiKey()) {
151
+ registerModels(pi, state, config, models);
103
152
  return;
104
153
  }
105
154
 
106
- const modelSettingsPath = resolveLocalModelSettingsPath(process.env.OMLX_MODEL_SETTINGS_PATH);
155
+ pi.registerProvider(PROVIDER, {
156
+ ...toProviderConfig(config.apiRoot, config.apiKeyEnvVar, models),
157
+ name: "OMLX",
158
+ authHeader: false,
159
+ streamSimple: streamMissingCredentials,
160
+ });
161
+ state.config = config;
162
+ state.catalog = models;
163
+ state.registered = true;
164
+ state.lastError = "OMLX credentials are not set. Run /login and choose OMLX.";
165
+ state.lastRefreshAt = new Date().toISOString();
166
+ state.modelSettingsPath = undefined;
167
+ }
168
+
169
+ function streamMissingCredentials(model: Model<Api>) {
170
+ const stream = createAssistantMessageEventStream();
171
+ const message: AssistantMessage = {
172
+ role: "assistant",
173
+ content: [],
174
+ api: model.api,
175
+ provider: model.provider,
176
+ model: model.id,
177
+ usage: {
178
+ input: 0,
179
+ output: 0,
180
+ cacheRead: 0,
181
+ cacheWrite: 0,
182
+ totalTokens: 0,
183
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
184
+ },
185
+ stopReason: "error",
186
+ errorMessage:
187
+ "OMLX credentials are not configured. Run /login, choose API key, select OMLX, then try the model again.",
188
+ timestamp: Date.now(),
189
+ };
190
+ queueMicrotask(() => {
191
+ stream.push({ type: "start", partial: message });
192
+ stream.push({ type: "error", reason: "error", error: message });
193
+ stream.end();
194
+ });
195
+ return stream;
196
+ }
197
+
198
+ type RefreshResult = "registered" | "not_configured" | "failed";
199
+
200
+ async function refreshProvider(
201
+ pi: ExtensionAPI,
202
+ state: State,
203
+ opts: { timeoutMs?: number } = {},
204
+ ): Promise<RefreshResult> {
205
+ const config = loadConfig();
206
+ const apiKey = resolveConfiguredApiKey();
207
+ if (!apiKey) {
208
+ state.lastError = "OMLX credentials are not set";
209
+ return "not_configured";
210
+ }
211
+
212
+ const modelSettingsPath = resolveLocalModelSettingsPath(
213
+ process.env.OMLX_MODEL_SETTINGS_PATH,
214
+ );
107
215
 
108
216
  let models: OmlxModel[];
109
217
  try {
110
- models = await fetchModels(config.apiRoot, process.env.OMLX_API_KEY!, { modelSettingsPath });
218
+ models = await fetchModels(config.apiRoot, apiKey, {
219
+ modelSettingsPath,
220
+ timeoutMs: opts.timeoutMs,
221
+ });
111
222
  } catch (err) {
112
223
  state.lastError = err instanceof Error ? err.message : String(err);
113
- return;
224
+ return "failed";
114
225
  }
115
226
 
116
227
  if (models.length === 0) {
117
228
  state.lastError = "OMLX returned 0 models";
118
- clearRegisteredProvider(pi, state);
119
- return;
229
+ return "failed";
120
230
  }
121
231
 
122
- pi.registerProvider(PROVIDER, toProviderConfig(config.apiRoot, config.apiKeyEnvVar, models));
123
- state.config = config;
124
- state.catalog = models;
125
- state.registered = true;
126
- state.lastError = undefined;
127
- state.lastRefreshAt = new Date().toISOString();
128
- state.modelSettingsPath = modelSettingsPath;
232
+ writeCatalogCache(config.apiRoot, models);
233
+ registerModels(pi, state, config, models, modelSettingsPath);
234
+ return "registered";
129
235
  }
130
236
 
131
- function findCatalogModel(state: State, id: string | undefined): OmlxModel | undefined {
237
+ function findCatalogModel(
238
+ state: State,
239
+ id: string | undefined,
240
+ ): OmlxModel | undefined {
132
241
  return id ? state.catalog.find((model) => model.id === id) : undefined;
133
242
  }
243
+
244
+ function sleep(ms: number): Promise<void> {
245
+ return new Promise((resolve) => {
246
+ const timer = setTimeout(resolve, ms);
247
+ timer.unref?.();
248
+ });
249
+ }
package/package.json CHANGED
@@ -1,25 +1,39 @@
1
1
  {
2
- "name": "pi-omlx-picker",
3
- "version": "0.2.2",
4
- "type": "module",
5
- "description": "Pi extension that discovers models from a local OMLX server and registers them as a native Pi provider.",
6
- "license": "MIT",
7
- "scripts": {
8
- "test": "node --import tsx --test 'test/*.test.ts'",
9
- "debug:omlx": "tsx scripts/debug-omlx.ts",
10
- "debug:pi": "tsx scripts/debug-pi.ts",
11
- "smoke:omlx": "tsx scripts/smoke-live-omlx.ts",
12
- "typecheck": "tsc --noEmit"
13
- },
14
- "pi": {
15
- "extensions": [
16
- "./index.ts"
17
- ]
18
- },
19
- "devDependencies": {
20
- "@mariozechner/pi-ai": "^0.73.1",
21
- "@mariozechner/pi-coding-agent": "*",
22
- "tsx": "^4.7.0",
23
- "typescript": "^6.0.3"
24
- }
2
+ "name": "pi-omlx-picker",
3
+ "version": "0.2.4",
4
+ "type": "module",
5
+ "description": "Pi extension that discovers models from a local OMLX server and registers them as a native Pi provider.",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "scripts": {
14
+ "check": "biome check *.ts *.json src test scripts && tsc --noEmit && vitest run",
15
+ "format": "biome check --write *.ts *.json src test scripts",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "publish": "npm publish --access public"
20
+ },
21
+ "pi": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-ai": "*",
28
+ "@earendil-works/pi-coding-agent": "*"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "^2.4.15",
32
+ "@earendil-works/pi-ai": "0.75.5",
33
+ "@earendil-works/pi-coding-agent": "0.75.5",
34
+ "@types/node": "^22.19.19",
35
+ "tsx": "^4.22.3",
36
+ "typescript": "^6.0.3",
37
+ "vitest": "^4.1.7"
38
+ }
25
39
  }
@@ -1,16 +1,18 @@
1
1
  import {
2
2
  type ApiKeyCredential,
3
3
  AuthStorage,
4
- } from "@mariozechner/pi-coding-agent";
4
+ type OAuthCredential,
5
+ } from "@earendil-works/pi-coding-agent";
5
6
 
6
7
  export const PROVIDER_KEY = "omlx";
7
8
 
8
9
  export interface OmlxStoredCredential {
9
- baseUrl: string;
10
+ baseUrl?: string;
10
11
  apiKey: string;
11
12
  }
12
13
 
13
14
  type OmlxApiKeyCredential = ApiKeyCredential & { baseUrl?: string };
15
+ type OmlxOAuthCredential = OAuthCredential & { baseUrl?: string };
14
16
 
15
17
  let storage: AuthStorage | undefined;
16
18
  function getStorage(): AuthStorage {
@@ -25,10 +27,18 @@ export function _setStorageForTesting(s: AuthStorage | undefined): void {
25
27
  export function loadOmlxCredential(): OmlxStoredCredential | undefined {
26
28
  const cred = getStorage().get(PROVIDER_KEY) as
27
29
  | OmlxApiKeyCredential
30
+ | OmlxOAuthCredential
28
31
  | undefined;
29
- if (!cred || cred.type !== "api_key") return undefined;
30
- if (!cred.baseUrl || !cred.key) return undefined;
31
- return { baseUrl: cred.baseUrl, apiKey: cred.key };
32
+ if (!cred) return undefined;
33
+ if (cred.type === "api_key") {
34
+ if (!cred.key) return undefined;
35
+ return { baseUrl: cred.baseUrl, apiKey: cred.key };
36
+ }
37
+ if (cred.type === "oauth") {
38
+ if (!cred.access) return undefined;
39
+ return { baseUrl: cred.baseUrl, apiKey: cred.access };
40
+ }
41
+ return undefined;
32
42
  }
33
43
 
34
44
  export function saveOmlxCredential(baseUrl: string, apiKey: string): void {
package/src/catalog.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
5
 
5
6
  export interface OmlxModel {
6
7
  id: string;
@@ -441,6 +442,45 @@ function applyModelSettingsEntry(
441
442
  return next;
442
443
  }
443
444
 
445
+ const CACHE_FILE = join(getAgentDir(), "cache", "omlx-models.json");
446
+
447
+ interface CachedCatalog {
448
+ apiRoot: string;
449
+ models: OmlxModel[];
450
+ savedAt: number;
451
+ }
452
+
453
+ export function readCatalogCache(apiRoot: string): OmlxModel[] | undefined {
454
+ const data = readCatalogCacheFile();
455
+ if (!data || data.apiRoot !== apiRoot) return undefined;
456
+ return data.models;
457
+ }
458
+
459
+ export function readLastCatalogCache(): OmlxModel[] | undefined {
460
+ return readCatalogCacheFile()?.models;
461
+ }
462
+
463
+ function readCatalogCacheFile(): CachedCatalog | undefined {
464
+ try {
465
+ const raw = readFileSync(CACHE_FILE, "utf-8");
466
+ const data = JSON.parse(raw) as CachedCatalog;
467
+ if (!Array.isArray(data.models)) return undefined;
468
+ return data;
469
+ } catch {
470
+ return undefined;
471
+ }
472
+ }
473
+
474
+ export function writeCatalogCache(apiRoot: string, models: OmlxModel[]): void {
475
+ try {
476
+ mkdirSync(join(getAgentDir(), "cache"), { recursive: true });
477
+ const data: CachedCatalog = { apiRoot, models, savedAt: Date.now() };
478
+ writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
479
+ } catch {
480
+ // Ignore write errors — cache is best-effort
481
+ }
482
+ }
483
+
444
484
  export function resolveLocalModelSettingsPath(
445
485
  modelSettingsPath: string | undefined,
446
486
  ): string | undefined {
package/src/config.ts CHANGED
@@ -1,17 +1,12 @@
1
1
  import { loadOmlxCredential } from "./auth-storage.ts";
2
2
 
3
+ export const DEFAULT_OMLX_BASE_URL = "http://127.0.0.1:8000/v1";
4
+
3
5
  export interface OmlxConfig {
4
6
  apiRoot: string;
5
7
  apiKeyEnvVar: string;
6
8
  }
7
9
 
8
- export class MissingEnvError extends Error {
9
- constructor(public readonly varName: string) {
10
- super(`${varName} is not set`);
11
- this.name = "MissingEnvError";
12
- }
13
- }
14
-
15
10
  export function normalizeBaseUrl(raw: string): string {
16
11
  const trimmed = raw.trim().replace(/\/+$/, "");
17
12
  if (!trimmed) throw new Error("OMLX_BASE_URL is empty");
@@ -19,30 +14,35 @@ export function normalizeBaseUrl(raw: string): string {
19
14
  }
20
15
 
21
16
  export function loadConfig(env: NodeJS.ProcessEnv = process.env): OmlxConfig {
22
- const baseUrl = env.OMLX_BASE_URL;
23
- if (!baseUrl) throw new MissingEnvError("OMLX_BASE_URL");
24
- if (!env.OMLX_API_KEY) throw new MissingEnvError("OMLX_API_KEY");
17
+ const stored = loadOmlxCredential();
18
+ const baseUrl = env.OMLX_BASE_URL
19
+ ? env.OMLX_BASE_URL
20
+ : env.OMLX_API_KEY
21
+ ? DEFAULT_OMLX_BASE_URL
22
+ : (stored?.baseUrl ?? DEFAULT_OMLX_BASE_URL);
25
23
  return {
26
24
  apiRoot: normalizeBaseUrl(baseUrl),
27
25
  apiKeyEnvVar: "OMLX_API_KEY",
28
26
  };
29
27
  }
30
28
 
31
- // Env vars win over stored creds so CI and per-shell overrides work.
29
+ export function resolveConfiguredApiKey(
30
+ env: NodeJS.ProcessEnv = process.env,
31
+ ): string | undefined {
32
+ if (env.OMLX_API_KEY) return env.OMLX_API_KEY;
33
+ if (env.OMLX_BASE_URL) return undefined;
34
+ return loadOmlxCredential()?.apiKey;
35
+ }
36
+
37
+ // Legacy helper for older stored api_key credentials. Never fills only one side
38
+ // of the env pair; partial shell overrides remain explicit shell state.
32
39
  export function applyStoredCredentialToEnv(
33
40
  env: NodeJS.ProcessEnv = process.env,
34
41
  ): boolean {
35
- if (env.OMLX_BASE_URL && env.OMLX_API_KEY) return false;
42
+ if (env.OMLX_BASE_URL || env.OMLX_API_KEY) return false;
36
43
  const stored = loadOmlxCredential();
37
- if (!stored) return false;
38
- let applied = false;
39
- if (!env.OMLX_BASE_URL) {
40
- env.OMLX_BASE_URL = stored.baseUrl;
41
- applied = true;
42
- }
43
- if (!env.OMLX_API_KEY) {
44
- env.OMLX_API_KEY = stored.apiKey;
45
- applied = true;
46
- }
47
- return applied;
44
+ if (!stored?.apiKey) return false;
45
+ env.OMLX_BASE_URL = stored.baseUrl ?? DEFAULT_OMLX_BASE_URL;
46
+ env.OMLX_API_KEY = stored.apiKey;
47
+ return true;
48
48
  }
package/src/dotenv.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export function parseDotenv(content: string): Record<string, string> {
6
+ const result: Record<string, string> = {};
7
+ for (const raw of content.split(/\r?\n/)) {
8
+ const line = raw.trim();
9
+ if (!line || line.startsWith("#")) continue;
10
+ const stripped = line.startsWith("export ") ? line.slice(7).trim() : line;
11
+ const eq = stripped.indexOf("=");
12
+ if (eq === -1) continue;
13
+ const key = stripped.slice(0, eq).trim();
14
+ if (!key) continue;
15
+ let val = stripped.slice(eq + 1).trim();
16
+ if (
17
+ (val.startsWith('"') && val.endsWith('"')) ||
18
+ (val.startsWith("'") && val.endsWith("'"))
19
+ ) {
20
+ val = val.slice(1, -1);
21
+ }
22
+ result[key] = val;
23
+ }
24
+ return result;
25
+ }
26
+
27
+ export function mergeDotenv(
28
+ parsed: Record<string, string>,
29
+ env: NodeJS.ProcessEnv = process.env,
30
+ ): void {
31
+ for (const [key, val] of Object.entries(parsed)) {
32
+ if (env[key] === undefined || env[key] === "") env[key] = val;
33
+ }
34
+ }
35
+
36
+ export function loadDotenvFromExtensionDir(
37
+ importMetaUrl: string,
38
+ env: NodeJS.ProcessEnv = process.env,
39
+ ): void {
40
+ try {
41
+ const dir = dirname(fileURLToPath(importMetaUrl));
42
+ const dotenvPath = join(dir, ".env");
43
+ if (!existsSync(dotenvPath)) return;
44
+ const content = readFileSync(dotenvPath, "utf8");
45
+ mergeDotenv(parseDotenv(content), env);
46
+ } catch {
47
+ // non-fatal
48
+ }
49
+ }
package/src/provider.ts CHANGED
@@ -8,11 +8,11 @@ import {
8
8
  type Model,
9
9
  type SimpleStreamOptions,
10
10
  streamSimpleOpenAICompletions,
11
- } from "@mariozechner/pi-ai";
11
+ } from "@earendil-works/pi-ai";
12
12
  import type {
13
13
  ProviderConfig,
14
14
  ProviderModelConfig,
15
- } from "@mariozechner/pi-coding-agent";
15
+ } from "@earendil-works/pi-coding-agent";
16
16
  import type { OmlxModel } from "./catalog.ts";
17
17
 
18
18
  // Pi's documented defaults when the server doesn't report per-model values.
package/.assets/demo.gif DELETED
Binary file
package/.assets/demo.tape DELETED
@@ -1,31 +0,0 @@
1
- Output demo.gif
2
-
3
- # Visual Settings
4
- Set FontSize 15
5
- Set Width 900
6
- Set Height 500
7
- Set Padding 20
8
- Set Theme "Catppuccin Mocha"
9
- Set WindowBar Colorful
10
-
11
- Sleep 1s
12
-
13
- Type "pi"
14
- Enter
15
- Sleep 1.5s
16
-
17
- Type "/mo"
18
- Sleep 1.5s
19
-
20
- Enter
21
- Sleep 1.5s
22
-
23
- Down
24
- Sleep 500ms
25
- Down
26
- Sleep 500ms
27
- Up
28
- Sleep 500ms
29
-
30
- Enter
31
- Sleep 3s
package/.biomeignore DELETED
@@ -1 +0,0 @@
1
- references/
@@ -1,11 +0,0 @@
1
- # To get started with Dependabot version updates, you'll need to specify which
2
- # package ecosystems to update and where the package manifests are located.
3
- # Please see the documentation for all configuration options:
4
- # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
-
6
- version: 2
7
- updates:
8
- - package-ecosystem: "npm" # See documentation for possible values
9
- directory: "/" # Location of package manifests
10
- schedule:
11
- interval: "weekly"
package/CONTRIBUTING.md DELETED
@@ -1,52 +0,0 @@
1
- # Contributing
2
-
3
- Local development for `pi-omlx-picker`. User config: [docs/CONFIGURATION.md](./docs/CONFIGURATION.md).
4
-
5
- ## Setup
6
-
7
- ```sh
8
- npm install
9
- mise run verify
10
- ```
11
-
12
- A live OMLX server is needed for `mise run smoke:omlx` and `mise run verify:live`.
13
-
14
- ## Tasks
15
-
16
- Run via [`mise`](https://mise.jdx.dev/):
17
-
18
- - `mise run verify` — Biome, TypeScript type checking, unit tests.
19
- - `mise run smoke:omlx` — live OMLX probe: reasoning model, non-thinking model, tool-flow request.
20
- - `mise run verify:live` — typecheck, unit tests, and live smoke (skips Biome; run `mise run verify` first if you want lint coverage).
21
- - `mise run debug:omlx` — OMLX config, model path, template, log, and cache diagnostics. Pass model names after `--` to narrow, e.g. `mise run debug:omlx -- opus sonnet`.
22
- - `mise run debug:pi` — Pi install, config, session, log, and cache diagnostics. Pass `install`, `config`, `sessions`, `logs`, or `cache` after `--`.
23
- - `mise run debug:pi -- timeline <session-id|session-file|iso-time>` — Pi provider + OMLX server + changed-files window around a stuck turn. Use `--minutes=N` to set the window (default 3).
24
-
25
- ## Triage order
26
-
27
- 1. Read the latest provider events in `log/provider-debug.log` (or `~/.pi/packages/pi-omlx-picker/log/provider-debug.log` when installed via Pi). Look for `stream_first_delta_timeout`, `assistant_stop_diagnosis`, and the most recent `before_provider_request` / `after_provider_response` pair. The latest `mise run smoke:omlx` proof lives in `log/smoke-test/<iso-timestamp>.json`.
28
- 2. Inspect Pi host state: `mise run debug:pi`.
29
- 3. Inspect OMLX config and model files: `mise run debug:omlx`.
30
- 4. Run live smoke: `mise run smoke:omlx`.
31
- 5. Check upstream OMLX repo releases and issues before writing local workarounds.
32
- 6. Only then change code.
33
-
34
- Most fixes are upstream (OMLX `model_settings.json`, `chat_template.jinja`), not in Pi-side code. New-session-OK plus takeover-broken means session state, not model capacity.
35
-
36
- ## Failure families
37
-
38
- ```text
39
- symptom
40
- ├─ package didn't load, wrong install, stale session, compaction, takeover
41
- │ └─ mise run debug:pi
42
- ├─ model alias, model settings, template, rope config, OMLX/HF cache
43
- │ └─ mise run debug:omlx
44
- ├─ live model request behavior
45
- │ └─ mise run smoke:omlx
46
- ├─ new session good, takeover bad
47
- │ └─ mise run debug:pi -- sessions, then compare model_settings.json and chat_template.jinja
48
- ├─ local checks pass but live smoke fails
49
- │ └─ check upstream OMLX repo, releases, and issues
50
- └─ model not appearing in Pi /model list
51
- └─ check OMLX_BASE_URL is reachable, then mise run debug:omlx
52
- ```