@lincoln504/pi-research 1.0.3 → 1.0.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/dist/cli.mjs CHANGED
@@ -3252,7 +3252,7 @@ function buildModelRegistry(apiKey, provider) {
3252
3252
  fs5.existsSync(modelsJsonPath) ? modelsJsonPath : void 0
3253
3253
  );
3254
3254
  }
3255
- if (fs5.existsSync(authPath)) {
3255
+ if (fs5.existsSync(authPath) || fs5.existsSync(modelsJsonPath)) {
3256
3256
  const authStorage = AuthStorage.create(authPath);
3257
3257
  return ModelRegistry.create(
3258
3258
  authStorage,
@@ -3266,7 +3266,17 @@ function constructMinimalModel(provider, modelId, _apiKey) {
3266
3266
  provider,
3267
3267
  id: modelId,
3268
3268
  name: modelId,
3269
- api: provider === "openai" ? "openai-completions" : provider + "-conversations",
3269
+ // Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
3270
+ // keyed no-registry setup would pass pre-flight then die on the first call.
3271
+ // Unknown providers default to the OpenAI-compatible protocol, which is what
3272
+ // virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
3273
+ api: {
3274
+ openai: "openai-completions",
3275
+ anthropic: "anthropic-messages",
3276
+ google: "google-generative-ai",
3277
+ gemini: "google-generative-ai",
3278
+ mistral: "mistral-conversations"
3279
+ }[provider] ?? "openai-completions",
3270
3280
  baseUrl: "",
3271
3281
  // Provider-specific base URLs are handled by pi-ai internal registry
3272
3282
  reasoning: false,
@@ -19812,6 +19822,7 @@ function getPiState(piSessionId) {
19812
19822
  if (!state) {
19813
19823
  state = {
19814
19824
  failures: /* @__PURE__ */ new Map(),
19825
+ failureReasons: /* @__PURE__ */ new Map(),
19815
19826
  order: [],
19816
19827
  panels: /* @__PURE__ */ new Map(),
19817
19828
  aborts: /* @__PURE__ */ new Map(),
@@ -19883,6 +19894,7 @@ function refreshAllSessions(piSessionId) {
19883
19894
  for (const id of state.order) {
19884
19895
  if (!validIds.includes(id)) {
19885
19896
  state.failures.delete(id);
19897
+ state.failureReasons.delete(id);
19886
19898
  state.panels.delete(id);
19887
19899
  }
19888
19900
  }
@@ -19914,6 +19926,7 @@ function endResearchSession(piSessionId, researchId) {
19914
19926
  const state = piSessions.get(sid);
19915
19927
  if (!state) return;
19916
19928
  state.failures.delete(researchId);
19929
+ state.failureReasons.delete(researchId);
19917
19930
  state.panels.delete(researchId);
19918
19931
  state.aborts.delete(researchId);
19919
19932
  const index = state.order.indexOf(researchId);
@@ -19931,6 +19944,7 @@ function endResearchSession(piSessionId, researchId) {
19931
19944
  piSessions.delete(sid);
19932
19945
  } else {
19933
19946
  state.failures.clear();
19947
+ state.failureReasons.clear();
19934
19948
  state.aborts.clear();
19935
19949
  }
19936
19950
  }
@@ -19952,12 +19966,23 @@ function clearAllSessionState() {
19952
19966
  clearAllSharedLinks();
19953
19967
  logger.log("[session-state] All session state cleared");
19954
19968
  }
19955
- function recordResearcherFailure(piSessionId, researchId, researcherId) {
19969
+ function recordResearcherFailure(piSessionId, researchId, researcherId, reason) {
19956
19970
  const sid = normalizeSessionId(piSessionId);
19957
19971
  const state = getPiState(sid);
19958
19972
  const failures = state.failures.get(researchId) || [];
19959
19973
  failures.push(researcherId);
19960
19974
  state.failures.set(researchId, failures);
19975
+ if (reason) {
19976
+ const reasons = state.failureReasons.get(researchId) ?? /* @__PURE__ */ new Map();
19977
+ if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
19978
+ state.failureReasons.set(researchId, reasons);
19979
+ }
19980
+ }
19981
+ function getResearcherFailureReasons(piSessionId, researchId) {
19982
+ const sid = normalizeSessionId(piSessionId);
19983
+ const state = getPiState(sid);
19984
+ const reasons = state.failureReasons.get(researchId);
19985
+ return reasons ? Object.fromEntries(reasons) : {};
19961
19986
  }
19962
19987
  function getFailedResearchers(piSessionId, researchId) {
19963
19988
  const sid = normalizeSessionId(piSessionId);
@@ -19974,9 +19999,13 @@ function getResearchStopMessage(piSessionId, researchId) {
19974
19999
  const sid = normalizeSessionId(piSessionId);
19975
20000
  const failed = getFailedResearchers(sid, researchId);
19976
20001
  const count = failed.length;
20002
+ const state = getPiState(sid);
20003
+ const reasons = state.failureReasons.get(researchId);
20004
+ const recorded = reasons && reasons.size > 0 ? ["Recorded errors:", ...[...reasons].map(([id, r]) => `\u2022 researcher ${id}: ${r}`), ""] : [];
19977
20005
  return [
19978
20006
  `Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(", ")}.`,
19979
20007
  "",
20008
+ ...recorded,
19980
20009
  "Most likely cause \u2014 the research model could not produce grounded results:",
19981
20010
  '\u2022 Try a more capable research model. Very small or "thinking-only" models often',
19982
20011
  " emit no final text after their tool calls, which fails every researcher.",
@@ -20026,7 +20055,7 @@ ${currentPlan.allQueries.map((q) => `- ${q}`).join("\n")}
20026
20055
  const researcherPromptTemplate = loadPrompt("researcher");
20027
20056
  if (initialLinks.length === 0 && historicalUrls.length === 0) {
20028
20057
  logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
20029
- recordResearcherFailure(sessionId, researchId, id);
20058
+ recordResearcherFailure(sessionId, researchId, id, "no initial search results or historical links (search produced nothing usable)");
20030
20059
  metrics.increment("researcher_skipped_total", 1, { mode: "deep", complexity: String(complexity), reason: "no_initial_links" });
20031
20060
  observer?.onResearcherFailure?.(id, "No initial search results or historical links available");
20032
20061
  return;
@@ -20966,7 +20995,12 @@ var DeepResearchOrchestrator = class {
20966
20995
  logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService2.getReportCount(researchId)} reports`);
20967
20996
  result = synthesisService2.buildFallbackSynthesis(researchId, this.currentRound);
20968
20997
  } else {
20969
- result = "Research completed but no summary was generated.";
20998
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
20999
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
21000
+ const causes = failedIds.map((id) => `researcher ${id}: ${reasons[id] ?? "no usable report"}`).join("; ");
21001
+ throw new Error(
21002
+ "Research produced no report \u2014 no source material was collected" + (causes ? `. Causes: ${causes}` : ".")
21003
+ );
20970
21004
  }
20971
21005
  }
20972
21006
  try {
@@ -21268,7 +21302,7 @@ var ResearchOrchestrationService = class {
21268
21302
  logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
21269
21303
  } else {
21270
21304
  logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
21271
- recordResearcherFailure(sessionId, researchId, id);
21305
+ recordResearcherFailure(sessionId, researchId, id, errMsg);
21272
21306
  observer?.onResearcherFailure?.(id, errMsg);
21273
21307
  }
21274
21308
  }
@@ -22000,6 +22034,10 @@ async function _doInit(options = {}) {
22000
22034
  }
22001
22035
  globalApiKey = options.apiKey || process.env["PI_RESEARCH_API_KEY"];
22002
22036
  let parsedProvider = options.provider || process.env["PI_RESEARCH_PROVIDER"];
22037
+ if (options.model) {
22038
+ const explicit = typeof options.model === "string" ? options.model : `${options.model.provider}/${options.model.id}`;
22039
+ globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
22040
+ }
22003
22041
  const effectiveModel = options.model ?? globalConfig.RESEARCH_MODEL;
22004
22042
  if (!parsedProvider && effectiveModel) {
22005
22043
  if (typeof effectiveModel === "string") {
@@ -22475,6 +22513,7 @@ Total cost: ${costStr}`;
22475
22513
 
22476
22514
  // src/cli.ts
22477
22515
  init_config();
22516
+ init_model_registry_factory();
22478
22517
  import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
22479
22518
  function readPkgVersion() {
22480
22519
  try {
@@ -22593,6 +22632,15 @@ function fileExists(p) {
22593
22632
  return false;
22594
22633
  }
22595
22634
  }
22635
+ function piKeysAvailable(piAuthPresent, provider) {
22636
+ try {
22637
+ const available = safeGetAvailable(buildModelRegistry(void 0, void 0));
22638
+ if (!provider) return available.length > 0;
22639
+ return available.some((m) => m.provider === provider);
22640
+ } catch {
22641
+ return piAuthPresent;
22642
+ }
22643
+ }
22596
22644
  function detectCredentials() {
22597
22645
  const paths = resolvedConfigPaths();
22598
22646
  const piAuthPresent = fileExists(paths.piAuth);
@@ -22604,6 +22652,7 @@ function detectCredentials() {
22604
22652
  const model = modelEnv ?? modelConfig;
22605
22653
  const modelFrom = modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
22606
22654
  const provider = providerEnv ?? (model && model.includes("/") ? model.slice(0, model.indexOf("/")) : void 0);
22655
+ const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
22607
22656
  if (apiKey) {
22608
22657
  if (!provider) {
22609
22658
  return {
@@ -22639,7 +22688,7 @@ function detectCredentials() {
22639
22688
  piModelsPresent
22640
22689
  };
22641
22690
  }
22642
- if (piAuthPresent) {
22691
+ if (piKeysUsable) {
22643
22692
  if (!model) {
22644
22693
  return {
22645
22694
  source: "pi-config",
@@ -22649,13 +22698,13 @@ function detectCredentials() {
22649
22698
  modelFrom,
22650
22699
  piAuthPresent,
22651
22700
  piModelsPresent,
22652
- problem: 'pi credentials were found (auth.json), but no research model is configured. The standalone CLI / agent skill run only on an explicitly configured model \u2014 they do not follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a "provider/model-id" (in the environment, or in config.env / cli.env below).'
22701
+ problem: `pi credentials were found (in pi's configuration), but no research model is configured. The standalone CLI / agent skill run only on an explicitly configured model \u2014 they do not follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a "provider/model-id" (in the environment, or in config.env / cli.env below).`
22653
22702
  };
22654
22703
  }
22655
22704
  return {
22656
22705
  source: "pi-config",
22657
22706
  apiKeyConfigured: true,
22658
- // key lives in auth.json; SDK reads it
22707
+ // key lives in pi's configuration; the SDK reads it at call time
22659
22708
  provider,
22660
22709
  model,
22661
22710
  modelFrom,
@@ -22671,7 +22720,7 @@ function detectCredentials() {
22671
22720
  modelFrom,
22672
22721
  piAuthPresent,
22673
22722
  piModelsPresent,
22674
- problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via ~/.pi/agent/auth.json."
22723
+ problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
22675
22724
  };
22676
22725
  }
22677
22726
  function configBlock(det, extraNote) {
@@ -23035,7 +23084,7 @@ function reportError(err, what, json) {
23035
23084
  const lower = msg2.toLowerCase();
23036
23085
  const isConfigError = lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key");
23037
23086
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23038
- const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
23087
+ const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23039
23088
  if (json) {
23040
23089
  const payload = { ok: false, error: msg2, exitCode };
23041
23090
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
@@ -23044,18 +23093,18 @@ function reportError(err, what, json) {
23044
23093
  toStdout(pretty(payload));
23045
23094
  return exitCode;
23046
23095
  }
23047
- if (isConfigError) {
23096
+ if (isRateLimit) {
23048
23097
  toStderr(`
23049
- Error: pi-research ${what} failed: ${msg2}
23050
-
23051
- ${configBlock(detectCredentials())}
23098
+ Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
23099
+ ${msg2}
23052
23100
  `);
23053
23101
  return exitCode;
23054
23102
  }
23055
- if (isRateLimit) {
23103
+ if (isConfigError) {
23056
23104
  toStderr(`
23057
- Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
23058
- ${msg2}
23105
+ Error: pi-research ${what} failed: ${msg2}
23106
+
23107
+ ${configBlock(detectCredentials())}
23059
23108
  `);
23060
23109
  return exitCode;
23061
23110
  }
@@ -132,7 +132,7 @@ API keys
132
132
 
133
133
  | Variable | Description |
134
134
  |----------|-------------|
135
- | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration — `~/.pi/agent/auth.json` — supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. |
135
+ | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. |
136
136
  | `STACKEXCHANGE_API_KEY` | Raises the Stack Exchange tool's limit from 300/day to 10,000/day. Obtain at <https://stackapps.com/apps/oauth>. |
137
137
  | `GITHUB_TOKEN` | Raises the security tool's GitHub Advisory limit from 60/hr to 5000/hr (any default-scope token). |
138
138
  | `NVD_API_KEY` | Raises the security tool's NVD limit ~10× and tightens request spacing. Request at <https://nvd.nist.gov/developers/request-an-api-key>. Recommended when using severity-filtered security searches: those issue a second (CVSS v2) NVD query to catch v2-only CVEs, which roughly doubles request time against the 6 s/request unauthenticated throttle. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.0.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.0.4", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -15,8 +15,8 @@ to configure (no research runs).
15
15
  export PI_RESEARCH_MODEL=openai/gpt-4o # provider/model-id
16
16
  ```
17
17
  2. **Global config** `~/.pi/research/config.env` (same KEY=VALUE keys; read at startup).
18
- 3. **pi configuration** (if you use `pi`): the API key comes from `~/.pi/agent/auth.json`
19
- automatically — `PI_RESEARCH_MODEL` must still be set (env or `config.env`).
18
+ 3. **pi configuration** (if you use `pi`): the API key comes from your pi
19
+ configuration automatically — `PI_RESEARCH_MODEL` must still be set (env or `config.env`).
20
20
 
21
21
  Real env vars always win. Optional CLI-only overlay `~/.pi/research/cli.env` layers
22
22
  over `config.env` for the CLI only. Precedence:
package/src/cli.ts CHANGED
@@ -39,6 +39,7 @@ import { validateInitialLink, MAX_INITIAL_LINKS } from './utils/url-utils.ts';
39
39
  import { exportResearchReport, appendExportMessage } from './utils/research-export.ts';
40
40
  import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFilePath, saveConfig, resetConfig, describeKnowledgeStoreMode, isProjectScopedKey, parseDotEnv } from './config.ts';
41
41
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
42
+ import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
42
43
 
43
44
  // ---------------------------------------------------------------------------
44
45
  // Constants
@@ -226,6 +227,32 @@ function fileExists(p: string): boolean {
226
227
  }
227
228
  }
228
229
 
230
+ /**
231
+ * Are keys usable from pi's configuration? Answered by pi's OWN semantics, not
232
+ * by file presence: build pi's model registry (two small JSON reads — no
233
+ * services, network, or native deps) and ask for the authed subset. That check
234
+ * (`hasConfiguredAuth`) covers every source the registry consults at call
235
+ * time: an auth.json provider entry (api-key or OAuth), a per-provider apiKey
236
+ * embedded in models.json (env-template aware), and provider env vars like
237
+ * OPENAI_API_KEY. File presence alone is neither necessary (keys can live in
238
+ * models.json / env with no auth.json) nor sufficient (auth.json can be `{}`).
239
+ */
240
+ function piKeysAvailable(piAuthPresent: boolean, provider?: string): boolean {
241
+ try {
242
+ const available = safeGetAvailable(buildModelRegistry(undefined, undefined));
243
+ if (!provider) return available.length > 0;
244
+ // Provider known (from the configured provider/model-id): keys must exist
245
+ // for THAT provider — a key for some other provider would pass pre-flight
246
+ // and then die on the first LLM call ("No API key found for <provider>").
247
+ return available.some((m) => m.provider === provider);
248
+ } catch {
249
+ // Cannot inspect (e.g. malformed models.json) — fall back to file
250
+ // presence: pre-flight must never block a possibly-working setup; the run
251
+ // itself reports the precise error.
252
+ return piAuthPresent;
253
+ }
254
+ }
255
+
229
256
  export interface CredentialDetection {
230
257
  /** Where credentials will come from once initResearchSDK runs. */
231
258
  source: 'explicit-env' | 'pi-config' | 'none';
@@ -258,6 +285,9 @@ function detectCredentials(): CredentialDetection {
258
285
  // Provider is either explicit, or inferred from a "provider/id" model string.
259
286
  const provider =
260
287
  providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
288
+ // Provider-aware when the configured model names one: a key for a DIFFERENT
289
+ // provider must not greenlight this run.
290
+ const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
261
291
 
262
292
  // --- Explicit API-key path -------------------------------------------------
263
293
  if (apiKey) {
@@ -300,13 +330,13 @@ function detectCredentials(): CredentialDetection {
300
330
  };
301
331
  }
302
332
 
303
- // --- pi configuration path (key from auth.json) -----------------------------
304
- if (piAuthPresent) {
305
- // Keys come from auth.json, but the MODEL must be configured explicitly:
306
- // the standalone CLI / agent skill run only on the configured model — they
307
- // never follow the model selected inside the pi extension, and silently
308
- // falling back to the registry's first authed entry picks a model nobody
309
- // chose. Fail fast here (pre-flight, before any SDK init).
333
+ // --- pi configuration path (key from auth.json, models.json, or provider env) ---
334
+ if (piKeysUsable) {
335
+ // Keys come from pi's configuration, but the MODEL must be configured
336
+ // explicitly: the standalone CLI / agent skill run only on the configured
337
+ // model — they never follow the model selected inside the pi extension, and
338
+ // silently falling back to the registry's first authed entry picks a model
339
+ // nobody chose. Fail fast here (pre-flight, before any SDK init).
310
340
  if (!model) {
311
341
  return {
312
342
  source: 'pi-config',
@@ -317,7 +347,7 @@ function detectCredentials(): CredentialDetection {
317
347
  piAuthPresent,
318
348
  piModelsPresent,
319
349
  problem:
320
- 'pi credentials were found (auth.json), but no research model is configured. ' +
350
+ 'pi credentials were found (in pi\'s configuration), but no research model is configured. ' +
321
351
  'The standalone CLI / agent skill run only on an explicitly configured model — they do not ' +
322
352
  'follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a ' +
323
353
  '"provider/model-id" (in the environment, or in config.env / cli.env below).',
@@ -325,7 +355,7 @@ function detectCredentials(): CredentialDetection {
325
355
  }
326
356
  return {
327
357
  source: 'pi-config',
328
- apiKeyConfigured: true, // key lives in auth.json; SDK reads it
358
+ apiKeyConfigured: true, // key lives in pi's configuration; the SDK reads it at call time
329
359
  provider,
330
360
  model,
331
361
  modelFrom,
@@ -346,7 +376,8 @@ function detectCredentials(): CredentialDetection {
346
376
  problem:
347
377
  'No model or API key is configured for pi-research. Provide credentials via environment ' +
348
378
  'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
349
- 'pi, via ~/.pi/agent/auth.json.',
379
+ "pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
380
+ 'apiKey in ~/.pi/agent/models.json).',
350
381
  };
351
382
  }
352
383
 
@@ -763,8 +794,12 @@ function reportError(err: unknown, what: string, json?: boolean): number {
763
794
  lower.includes('unauthorized') ||
764
795
  lower.includes('api key');
765
796
 
797
+ // Rate limits win over config-shaped matches: a provider 429 often mentions
798
+ // "api key" in its body (e.g. "rate limit exceeded for this api key"), and
799
+ // classifying that as a setup problem sends the user to reconfigure a
800
+ // perfectly working key instead of waiting out the limit.
766
801
  const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
767
- const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
802
+ const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
768
803
 
769
804
  // --json: emit a structured error on stdout so a machine consumer never has to
770
805
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
@@ -777,15 +812,15 @@ function reportError(err: unknown, what: string, json?: boolean): number {
777
812
  return exitCode;
778
813
  }
779
814
 
780
- if (isConfigError) {
781
- toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
782
- return exitCode;
783
- }
784
- // Rate limits are operational, not fatal setup problems.
815
+ // Rate limits are operational, not fatal setup problems — checked FIRST.
785
816
  if (isRateLimit) {
786
817
  toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
787
818
  return exitCode;
788
819
  }
820
+ if (isConfigError) {
821
+ toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
822
+ return exitCode;
823
+ }
789
824
  toStderr(`\nError: pi-research ${what} failed: ${msg}\n`);
790
825
  if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
791
826
  toStderr('\n' + err.stack + '\n');
@@ -123,12 +123,13 @@ export function buildModelRegistry(apiKey?: string, provider?: string): ModelReg
123
123
  );
124
124
  }
125
125
 
126
- // No explicit key: use the user's pi auth storage and model list if available.
126
+ // No explicit key: use the user's pi configuration when EITHER file exists.
127
127
  // ModelRegistry.create() reads embedded apiKeys from models.json providers
128
- // directly (via providerRequestConfigs), so glm-coding and other custom
129
- // providers with embedded keys are visible to hasConfiguredAuth() without
130
- // needing to duplicate them into AuthStorage.
131
- if (fs.existsSync(authPath)) {
128
+ // directly (via providerRequestConfigs), so custom providers with embedded
129
+ // keys work with no auth.json at all — auth.json presence must not gate
130
+ // models.json (AuthStorage.create tolerates a missing file). Env provider
131
+ // keys (e.g. OPENAI_API_KEY) are honored by AuthStorage in every branch.
132
+ if (fs.existsSync(authPath) || fs.existsSync(modelsJsonPath)) {
132
133
  const authStorage = AuthStorage.create(authPath);
133
134
  return ModelRegistry.create(
134
135
  authStorage,
@@ -152,7 +153,17 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
152
153
  provider,
153
154
  id: modelId,
154
155
  name: modelId,
155
- api: provider === 'openai' ? 'openai-completions' : (provider + '-conversations' as any),
156
+ // Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
157
+ // keyed no-registry setup would pass pre-flight then die on the first call.
158
+ // Unknown providers default to the OpenAI-compatible protocol, which is what
159
+ // virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
160
+ api: ({
161
+ openai: 'openai-completions',
162
+ anthropic: 'anthropic-messages',
163
+ google: 'google-generative-ai',
164
+ gemini: 'google-generative-ai',
165
+ mistral: 'mistral-conversations',
166
+ } as Record<string, string>)[provider] ?? 'openai-completions',
156
167
  baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
157
168
  reasoning: false,
158
169
  input: ['text'],
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
9
9
  import { logger } from '../logger.ts';
10
10
  import { safeUnref } from '../utils/safe-unref.ts';
11
11
  import { metrics } from '../utils/metrics.ts';
12
- import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages } from './session-state.ts';
12
+ import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
13
13
  import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
14
14
  import {
15
15
  getMaxRounds,
@@ -486,7 +486,22 @@ export class DeepResearchOrchestrator {
486
486
  logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService.getReportCount(researchId)} reports`);
487
487
  result = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
488
488
  } else {
489
- result = 'Research completed but no summary was generated.';
489
+ // ZERO reports were collected — the run produced nothing groundable.
490
+ // With the default single-researcher plan the fast-stop threshold
491
+ // (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone researcher that
492
+ // exhausts its retries (e.g. a provider rate limit) used to land here
493
+ // and return a hollow "no summary was generated" string on the SUCCESS
494
+ // path — exit 0. That is a FAILED run: throw, and surface the recorded
495
+ // root causes so the user sees the 429/model error, not a shrug.
496
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
497
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
498
+ const causes = failedIds
499
+ .map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
500
+ .join('; ');
501
+ throw new Error(
502
+ 'Research produced no report — no source material was collected' +
503
+ (causes ? `. Causes: ${causes}` : '.')
504
+ );
490
505
  }
491
506
  }
492
507
 
@@ -358,8 +358,9 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
358
358
  logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
359
359
  } else {
360
360
  logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
361
- // Record failure for stopping logic
362
- recordResearcherFailure(sessionId, researchId, id);
361
+ // Record failure (with its root cause) for stopping logic and for the
362
+ // zero-report failure surfaced at synthesis time.
363
+ recordResearcherFailure(sessionId, researchId, id, errMsg);
363
364
  // Notify observer
364
365
  observer?.onResearcherFailure?.(id, errMsg);
365
366
  }
@@ -69,7 +69,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
69
69
  // use), recording under ctx.sessionId would file this no-links skip under a
70
70
  // different id than shouldStopResearch() checks, so the fast-stop guard would
71
71
  // under-count exactly the broad-search-failure case it exists to catch.
72
- recordResearcherFailure(sessionId, researchId, id);
72
+ recordResearcherFailure(sessionId, researchId, id, 'no initial search results or historical links (search produced nothing usable)');
73
73
  metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
74
74
  observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
75
75
  return;
@@ -26,6 +26,8 @@ const MAX_STEERING_MESSAGE_LENGTH = 2000;
26
26
  interface PiSessionState {
27
27
  /** Map of research run ID → array of failed researcher IDs */
28
28
  failures: Map<string, string[]>;
29
+ /** Map of research run ID → (researcher ID → first failure reason) */
30
+ failureReasons: Map<string, Map<string, string>>;
29
31
  /** Ordered list of research run IDs for TUI stacking (Index 0 = oldest/bottom-most) */
30
32
  order: string[];
31
33
  /** Registry of panel states for each research run */
@@ -70,6 +72,7 @@ function getPiState(piSessionId: string | undefined): PiSessionState {
70
72
  if (!state) {
71
73
  state = {
72
74
  failures: new Map(),
75
+ failureReasons: new Map(),
73
76
  order: [],
74
77
  panels: new Map(),
75
78
  aborts: new Map(),
@@ -392,6 +395,7 @@ export function refreshAllSessions(piSessionId: string | undefined): void {
392
395
  for (const id of state.order) {
393
396
  if (!validIds.includes(id)) {
394
397
  state.failures.delete(id);
398
+ state.failureReasons.delete(id);
395
399
  state.panels.delete(id);
396
400
  }
397
401
  }
@@ -454,6 +458,7 @@ export function startResearchSession(piSessionId: string | undefined, customRese
454
458
  const sid = normalizeSessionId(piSessionId);
455
459
  const state = getPiState(sid);
456
460
  state.failures.set(researchId, []);
461
+ state.failureReasons.set(researchId, new Map());
457
462
  return researchId;
458
463
  }
459
464
 
@@ -501,6 +506,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
501
506
  if (!state) return;
502
507
 
503
508
  state.failures.delete(researchId);
509
+ state.failureReasons.delete(researchId);
504
510
  state.panels.delete(researchId);
505
511
  state.aborts.delete(researchId);
506
512
 
@@ -525,6 +531,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
525
531
  } else {
526
532
  // Clear research-specific state but preserve the session and steering
527
533
  state.failures.clear();
534
+ state.failureReasons.clear();
528
535
  state.aborts.clear();
529
536
  }
530
537
  }
@@ -563,12 +570,28 @@ export function isBottomMostSession(piSessionId: string | undefined, researchId:
563
570
  /**
564
571
  * Record a researcher failure
565
572
  */
566
- export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string): void {
573
+ export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string, reason?: string): void {
567
574
  const sid = normalizeSessionId(piSessionId);
568
575
  const state = getPiState(sid);
569
576
  const failures = state.failures.get(researchId) || [];
570
577
  failures.push(researcherId);
571
578
  state.failures.set(researchId, failures);
579
+ if (reason) {
580
+ const reasons = state.failureReasons.get(researchId) ?? new Map<string, string>();
581
+ // Keep the FIRST reason per researcher — the root cause; retries repeat it.
582
+ if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
583
+ state.failureReasons.set(researchId, reasons);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Get the recorded failure reason per failed researcher (first failure wins).
589
+ */
590
+ export function getResearcherFailureReasons(piSessionId: string | undefined, researchId: string): Record<string, string> {
591
+ const sid = normalizeSessionId(piSessionId);
592
+ const state = getPiState(sid);
593
+ const reasons = state.failureReasons.get(researchId);
594
+ return reasons ? Object.fromEntries(reasons) : {};
572
595
  }
573
596
 
574
597
  /**
@@ -610,9 +633,18 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
610
633
  // search engine blocking" here mis-sends the user to debug their network; the
611
634
  // readiness probes do not even run a search, so a failure is not evidence of bot
612
635
  // blocking.
636
+ // Lead with the RECORDED errors when we have them — the actual root cause
637
+ // (e.g. a provider 429) beats generic guidance every time.
638
+ const state = getPiState(sid);
639
+ const reasons = state.failureReasons.get(researchId);
640
+ const recorded = reasons && reasons.size > 0
641
+ ? ['Recorded errors:', ...[...reasons].map(([id, r]) => `• researcher ${id}: ${r}`), '']
642
+ : [];
643
+
613
644
  return [
614
645
  `Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(', ')}.`,
615
646
  '',
647
+ ...recorded,
616
648
  'Most likely cause — the research model could not produce grounded results:',
617
649
  '• Try a more capable research model. Very small or "thinking-only" models often',
618
650
  ' emit no final text after their tool calls, which fails every researcher.',
package/src/sdk.ts CHANGED
@@ -236,6 +236,19 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
236
236
  globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
237
237
  let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
238
238
 
239
+ // An EXPLICIT model option must govern the entire run — coordinator (via
240
+ // ctx.model) AND researchers/synthesis (which resolve through
241
+ // RESEARCH_MODEL, where config would otherwise outrank the option and split
242
+ // the run across two models, with the report metadata naming the wrong one).
243
+ // Pin the config to the explicit choice. No-op when the option was itself
244
+ // seeded from the config (the CLI does that).
245
+ if (options.model) {
246
+ const explicit = typeof options.model === 'string'
247
+ ? options.model
248
+ : `${(options.model as Model<any>).provider}/${(options.model as Model<any>).id}`;
249
+ globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
250
+ }
251
+
239
252
  // Infer provider from the effective model if not explicitly provided. The
240
253
  // effective model is the explicit option first, else the configured
241
254
  // RESEARCH_MODEL — the same precedence the resolve below uses.