@companion-ai/feynman 0.5.19 → 0.5.21

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/RELEASES.md CHANGED
@@ -6,6 +6,14 @@ GitHub release notes are generated from the matching `## vX.Y.Z` section in this
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## v0.5.21 - 2026-10-01
10
+
11
+ - **LiteLLM and custom providers that read their key from an environment variable work again.** Since Pi 0.99, `models.json` resolves an environment variable only when written as `$NAME`; a bare name is sent as the key itself. Feynman's LiteLLM setup wrote `LITELLM_MASTER_KEY`, so requests went out as `Authorization: Bearer LITELLM_MASTER_KEY` and the proxy rejected them, and custom providers set up with a variable name broke the same way. Feynman now writes `$NAME`, and on start rewrites any `apiKey` in `models.json` that is a bare all-caps variable name, such as `LITELLM_MASTER_KEY`, to `$LITELLM_MASTER_KEY`. Literal keys, placeholders such as `local`, and `!command` values are left alone.
12
+
13
+ ## v0.5.20 - 2026-10-01
14
+
15
+ - **Settings saved by Windows Notepad or PowerShell no longer stop Feynman.** Both can save UTF-8 with a byte-order mark, which JSON parsing rejects, so Feynman refused to start with "Invalid Feynman settings ... expected a JSON object", and other commands read the file as empty. Feynman now ignores the mark in `settings.json`, `auth.json`, and `web-search.json`, as Pi does. A settings file with a real JSON error now names the error, for example a trailing comma, instead of the generic message.
16
+
9
17
  ## v0.5.19 - 2026-10-01
10
18
 
11
19
  - **Word drafts open without LibreOffice.** `document_parse` needs LibreOffice for `.docx` files, which most machines lack, so reviewing a thesis or draft in Word format failed. When LibreOffice is missing, Feynman now reads the document's paragraph text directly from the `.docx` and says so; install LibreOffice for page layout, tables, footnotes, and images.
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+ // Windows Notepad and PowerShell 5.1 save UTF-8 with a byte-order mark, which
3
+ // JSON.parse rejects. Pi strips it when it reads the same files.
4
+ export function parseJsonText(source) {
5
+ return JSON.parse(source.replace(/^/, ""));
6
+ }
7
+ export function readJsonFile(path) {
8
+ return parseJsonText(readFileSync(path, "utf8"));
9
+ }
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { getEnvApiKey } from "@earendil-works/pi-ai/compat";
2
+ import { readJsonFile } from "../config/json-file.js";
3
3
  import { createModelRuntime } from "./registry.js";
4
4
  const PRO_CLASS_MODEL_PATTERN = /(?:^|[-_.:/])pro(?:$|[-_.:/])/i;
5
5
  // Pi catalogs these exact DeepSeek V4 Pro model IDs as ordinary open-weight
@@ -334,7 +334,7 @@ export async function getSupportedModelRecords(authPath) {
334
334
  function readExpiredOAuthProviders(authPath) {
335
335
  const expired = new Set();
336
336
  try {
337
- const parsed = JSON.parse(readFileSync(authPath, "utf8"));
337
+ const parsed = readJsonFile(authPath);
338
338
  for (const [provider, credential] of Object.entries(parsed)) {
339
339
  if (!credential || typeof credential !== "object")
340
340
  continue;
@@ -10,7 +10,7 @@ import { printInfo, printSection, printSuccess, printWarning } from "../ui/termi
10
10
  import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel, getAuthenticatedModelRecords, getAvailableModelRecords, getSupportedModelRecords, isProClassModelSpec, } from "./catalog.js";
11
11
  import { MODEL_API_KEY_PROVIDERS } from "./api-key-providers.js";
12
12
  import { createModelRegistry, createModelRuntime, getModelsJsonPath } from "./registry.js";
13
- import { upsertProviderBaseUrl, upsertProviderConfig } from "./models-json.js";
13
+ import { apiKeyReference, upsertProviderBaseUrl, upsertProviderConfig } from "./models-json.js";
14
14
  const exec = promisify(execCallback);
15
15
  async function collectModelStatus(settingsPath, authPath) {
16
16
  return buildModelStatusSnapshotFromRecords(await getSupportedModelRecords(authPath), await getAuthenticatedModelRecords(authPath), getCurrentModelSpec(settingsPath));
@@ -161,7 +161,7 @@ async function resolveApiKeyConfig(apiKeyConfig) {
161
161
  return undefined;
162
162
  }
163
163
  }
164
- const envValue = process.env[trimmed];
164
+ const envValue = process.env[trimmed.replace(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/, "$1")];
165
165
  if (typeof envValue === "string" && envValue.trim()) {
166
166
  return envValue.trim();
167
167
  }
@@ -267,10 +267,10 @@ async function promptCustomProviderSetup() {
267
267
  }
268
268
  printInfo("API key value supports:");
269
269
  printInfo(" - literal secret (stored in models.json)");
270
- printInfo(" - env var name (resolved at runtime)");
270
+ printInfo(" - env var name such as MY_API_KEY or $MY_API_KEY (resolved at runtime)");
271
271
  printInfo(" - !command (executes and uses stdout)");
272
272
  const apiKeyConfigRaw = (await promptText("API key / resolver", "")).trim();
273
- const apiKeyConfig = apiKeyConfigRaw || "local";
273
+ const apiKeyConfig = apiKeyReference(apiKeyConfigRaw) || "local";
274
274
  if (!apiKeyConfigRaw) {
275
275
  printInfo("Using placeholder apiKey value (required by Pi for custom providers).");
276
276
  }
@@ -346,7 +346,7 @@ async function promptLiteLlmProviderSetup() {
346
346
  return undefined;
347
347
  }
348
348
  const hasKey = keySelection === 0;
349
- const apiKeyConfig = hasKey ? "LITELLM_MASTER_KEY" : "local";
349
+ const apiKeyConfig = hasKey ? "$LITELLM_MASTER_KEY" : "local";
350
350
  const authHeader = hasKey;
351
351
  if (hasKey) {
352
352
  printInfo("Set LITELLM_MASTER_KEY in your shell or .env before using Feynman.");
@@ -63,7 +63,9 @@ export function upsertProviderConfig(modelsJsonPath, providerId, patch) {
63
63
  if (patch.models !== undefined)
64
64
  nextProvider.models = patch.models;
65
65
  providers[providerId] = nextProvider;
66
- const next = { ...value, providers };
66
+ return writeModelsJson(modelsJsonPath, { ...value, providers });
67
+ }
68
+ function writeModelsJson(modelsJsonPath, next) {
67
69
  try {
68
70
  mkdirSync(dirname(modelsJsonPath), { recursive: true });
69
71
  writeFileSync(modelsJsonPath, JSON.stringify(next, null, 2) + "\n", "utf8");
@@ -80,3 +82,31 @@ export function upsertProviderConfig(modelsJsonPath, providerId, patch) {
80
82
  return { ok: false, error: `Failed to write models.json: ${error instanceof Error ? error.message : String(error)}` };
81
83
  }
82
84
  }
85
+ // Pi resolves an environment variable in `apiKey` only as $NAME or ${NAME}; a
86
+ // bare name such as LITELLM_MASTER_KEY is sent as the key itself.
87
+ const BARE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
88
+ export function apiKeyReference(value) {
89
+ return BARE_ENV_NAME.test(value) ? `$${value}` : value;
90
+ }
91
+ // Feynman's setup wrote bare names before Pi 0.99 stopped resolving them, so a
92
+ // LiteLLM or custom provider sent "LITELLM_MASTER_KEY" as its key. Returns the
93
+ // providers it rewrote to $NAME.
94
+ export function migrateBareEnvApiKeys(modelsJsonPath) {
95
+ if (!existsSync(modelsJsonPath))
96
+ return [];
97
+ const read = readModelsJson(modelsJsonPath);
98
+ if (!read.ok)
99
+ return [];
100
+ const providers = { ...(read.value.providers ?? {}) };
101
+ const migrated = Object.keys(providers).filter((id) => {
102
+ const apiKey = providers[id]?.apiKey;
103
+ return typeof apiKey === "string" && BARE_ENV_NAME.test(apiKey);
104
+ });
105
+ if (migrated.length === 0)
106
+ return [];
107
+ for (const id of migrated) {
108
+ const provider = providers[id];
109
+ providers[id] = { ...provider, apiKey: apiKeyReference(provider.apiKey) };
110
+ }
111
+ return writeModelsJson(modelsJsonPath, { ...read.value, providers }).ok ? migrated : [];
112
+ }
@@ -1,5 +1,6 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { readJsonFile } from "../config/json-file.js";
3
4
  export const FEYNMAN_SERVICE_TIERS = [
4
5
  "auto",
5
6
  "default",
@@ -12,7 +13,7 @@ const OPENAI_SERVICE_TIERS = new Set(["auto", "default", "flex", "priority"]);
12
13
  const ANTHROPIC_SERVICE_TIERS = new Set(["auto", "standard_only"]);
13
14
  function readSettings(settingsPath) {
14
15
  try {
15
- return JSON.parse(readFileSync(settingsPath, "utf8"));
16
+ return readJsonFile(settingsPath);
16
17
  }
17
18
  catch {
18
19
  return {};
@@ -1,7 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { parseJsonText, readJsonFile } from "../config/json-file.js";
3
4
  import { BUNDLED_PI_PACKAGES, getFeynmanPackageSources, resolvePackageRoot } from "./runtime.js";
4
5
  import { choosePreferredModelRecord, getAvailableModelRecords } from "../model/catalog.js";
6
+ import { migrateBareEnvApiKeys } from "../model/models-json.js";
7
+ import { getModelsJsonPath } from "../model/registry.js";
5
8
  // Written by Feynman <= 0.4.0 next to a subagent extension path it managed.
6
9
  const LEGACY_RESEARCHER_EXTENSION_MARKER = "_feynmanResearchToolsExtension";
7
10
  function findModel(modelLookup, provider, id) {
@@ -50,7 +53,7 @@ export function readJson(path) {
50
53
  return {};
51
54
  }
52
55
  try {
53
- return JSON.parse(readFileSync(path, "utf8"));
56
+ return readJsonFile(path);
54
57
  }
55
58
  catch (error) {
56
59
  if (process.env.FEYNMAN_DEBUG === "1") {
@@ -66,10 +69,11 @@ function readConfigObject(path, label) {
66
69
  const source = readFileSync(path, "utf8");
67
70
  let value;
68
71
  try {
69
- value = JSON.parse(source);
72
+ value = parseJsonText(source);
70
73
  }
71
- catch {
72
- throw new Error(`Invalid ${label} at ${path}: expected a JSON object. The file was not changed.`);
74
+ catch (error) {
75
+ const reason = error instanceof Error ? error.message : String(error);
76
+ throw new Error(`Invalid ${label} at ${path}: ${reason}. Fix the JSON or delete the file. The file was not changed.`);
73
77
  }
74
78
  if (!isRecord(value)) {
75
79
  throw new Error(`Invalid ${label} at ${path}: expected a JSON object. The file was not changed.`);
@@ -178,6 +182,7 @@ export async function ensureFeynmanSettings(settingsPath, bundledSettingsPath, a
178
182
  const existing = existsSync(settingsPath) ? readConfigObject(settingsPath, "Feynman settings") : undefined;
179
183
  const settings = existing ? { ...existing.value } : {};
180
184
  const defaults = readConfigObject(bundledSettingsPath, "bundled Feynman settings").value;
185
+ migrateBareEnvApiKeys(getModelsJsonPath(authPath));
181
186
  for (const [key, value] of Object.entries({ ...defaults, defaultThinkingLevel })) {
182
187
  if (settings[key] === undefined)
183
188
  settings[key] = value;
@@ -1,5 +1,6 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
+ import { readJsonFile } from "../config/json-file.js";
3
4
  import { getFeynmanAgentDir, getFeynmanHome } from "../config/paths.js";
4
5
  // pi-web-access reads web-search.json from PI_CODING_AGENT_DIR.
5
6
  export function getPiWebSearchConfigPath(home) {
@@ -20,7 +21,7 @@ export function loadPiWebAccessConfig(configPath = getPiWebSearchConfigPath()) {
20
21
  return {};
21
22
  }
22
23
  try {
23
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
24
+ const parsed = readJsonFile(configPath);
24
25
  return parsed && typeof parsed === "object" ? parsed : {};
25
26
  }
26
27
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companion-ai/feynman",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Research-first CLI agent built on Pi and alphaXiv",
5
5
  "license": "MIT",
6
6
  "type": "module",