@companion-ai/feynman 0.5.20 → 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,10 @@ 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
+
9
13
  ## v0.5.20 - 2026-10-01
10
14
 
11
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.
@@ -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
+ }
@@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
3
3
  import { parseJsonText, readJsonFile } from "../config/json-file.js";
4
4
  import { BUNDLED_PI_PACKAGES, getFeynmanPackageSources, resolvePackageRoot } from "./runtime.js";
5
5
  import { choosePreferredModelRecord, getAvailableModelRecords } from "../model/catalog.js";
6
+ import { migrateBareEnvApiKeys } from "../model/models-json.js";
7
+ import { getModelsJsonPath } from "../model/registry.js";
6
8
  // Written by Feynman <= 0.4.0 next to a subagent extension path it managed.
7
9
  const LEGACY_RESEARCHER_EXTENSION_MARKER = "_feynmanResearchToolsExtension";
8
10
  function findModel(modelLookup, provider, id) {
@@ -180,6 +182,7 @@ export async function ensureFeynmanSettings(settingsPath, bundledSettingsPath, a
180
182
  const existing = existsSync(settingsPath) ? readConfigObject(settingsPath, "Feynman settings") : undefined;
181
183
  const settings = existing ? { ...existing.value } : {};
182
184
  const defaults = readConfigObject(bundledSettingsPath, "bundled Feynman settings").value;
185
+ migrateBareEnvApiKeys(getModelsJsonPath(authPath));
183
186
  for (const [key, value] of Object.entries({ ...defaults, defaultThinkingLevel })) {
184
187
  if (settings[key] === undefined)
185
188
  settings[key] = value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companion-ai/feynman",
3
- "version": "0.5.20",
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",