auxilo-mcp 0.9.25 → 0.9.27

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
@@ -175,6 +175,10 @@ node mcp-server.js # the MCP server (stdio)
175
175
 
176
176
  The live API runs on [Fly.io](https://fly.io) from the repo `Dockerfile`.
177
177
 
178
+ ### One machine
179
+
180
+ The money ledgers are guarded by locks held in memory in one running process. The server is meant to run as one machine with one data volume. Do not run a second machine against the same data, and do not start an overlapping deploy where the old and new process both run against the same files at once — the locks on one machine cannot see or coordinate with a second one, so two processes writing the same ledger files at the same time can corrupt them or double-spend a balance. This must not change until the ledgers move to a shared store that more than one process can safely coordinate through.
181
+
178
182
  ## Links
179
183
 
180
184
  - Site: [auxilo.io](https://auxilo.io)
package/bin/auxilo-cli.js CHANGED
@@ -167,12 +167,12 @@ const CONSENT_TEXT = `
167
167
  • READS the session transcript on your machine,
168
168
  • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
169
169
  are redacted first),
170
- • EXTRACTS reusable learnings locally through the first model client you
171
- are signed in to (Claude Code, then Codex) or, when neither is
172
- available, a provider key you set yourself. For this step your
173
- scrubbed transcript goes only to that provider, under your own
174
- account with them, and any use is charged to that account, never to
175
- Auxilo. It is never sent to Auxilo, raw or scrubbed.
170
+ • EXTRACTS reusable learnings locally through Claude Code, when you
171
+ are signed in to it, or a provider key you set yourself. For this
172
+ step your scrubbed transcript goes only to that provider, under
173
+ your own account with them, and any use is charged to that
174
+ account, never to Auxilo. It is never sent to Auxilo, raw or
175
+ scrubbed.
176
176
  • UPLOADS only the finished learning drafts (title, body, category,
177
177
  tags, task context, outcome) to Auxilo (${'POST /learn'}). Everything
178
178
  waits in your review queue until you approve it, one learning at a
@@ -180,8 +180,8 @@ const CONSENT_TEXT = `
180
180
  waits for operator review. A draft that any screen flags (sensitive,
181
181
  duplicate, uncertain quality) waits in your private queue for
182
182
  \`auxilo review\`. Auto-publish for learnings that pass every screen is
183
- off unless you turn it on in your dashboard. Your share of a paid
184
- unlock by another agent goes to your Auxilo account, 70% of what they
183
+ off unless you turn it on in your dashboard. Your earnings from a paid
184
+ unlock by another agent go to your Auxilo account, 70% of what they
185
185
  paid on a direct unlock and 60% via discovery. A repeat unlock by the
186
186
  same buyer within 30 days earns nothing. Earnings depend on whether
187
187
  other agents unlock your learnings and are not guaranteed. Earnings
@@ -645,7 +645,7 @@ const CLI_CLEAN_LANE_CALIBRATED_PROVIDERS = ['claude-code'];
645
645
  * that with two read-only, no-detect sources of TRUTH, in priority order:
646
646
  * (1) AUXILO_EXTRACTION_PROVIDER, if set, is a certain fact about the
647
647
  * current session's config — reading it is not a guess — validated
648
- * against providers.PROVIDER_ORDER exactly as resolveProvider() itself
648
+ * against providers.KNOWN_PROVIDER_IDS exactly as resolveProvider() itself
649
649
  * validates an override, without calling it.
650
650
  * (2) Otherwise, providers.json's `selected` field — the LAST provider a
651
651
  * genuine resolveProvider() full-scan actually chose and persisted
@@ -661,10 +661,10 @@ const CLI_CLEAN_LANE_CALIBRATED_PROVIDERS = ['claude-code'];
661
661
  function lastRecordedProviderResolution() {
662
662
  const override = process.env.AUXILO_EXTRACTION_PROVIDER;
663
663
  if (override) {
664
- if (providers.PROVIDER_ORDER.includes(override)) return { ok: true, id: override };
664
+ if (providers.KNOWN_PROVIDER_IDS.includes(override)) return { ok: true, id: override };
665
665
  return {
666
666
  ok: false,
667
- reason: `AUXILO_EXTRACTION_PROVIDER="${override}" is not a known provider (expected one of: ${providers.PROVIDER_ORDER.join(', ')})`,
667
+ reason: `AUXILO_EXTRACTION_PROVIDER="${override}" is not a known provider (expected one of: ${providers.KNOWN_PROVIDER_IDS.join(', ')})`,
668
668
  };
669
669
  }
670
670
  try {
@@ -709,7 +709,9 @@ function extractionProviderLine(resolution) {
709
709
  * 'no-model-provider-available' (nothing even LOOKED usable at the detect()
710
710
  * stage) — this is the selection-fall-through exhaustion code from
711
711
  * scripts/providers/index.js's runModel(), where every provider in
712
- * PROVIDER_ORDER was actually tried and each failed for its own reason.
712
+ * AUTOMATIC_PROVIDER_ORDER was actually tried and each failed for its own
713
+ * reason. codex-cli remains reachable only through the explicit override,
714
+ * which never enters this fallback walk.
713
715
  */
714
716
  const STATUS_WORTHY_SKIP_REASON_CODES = Object.freeze([
715
717
  'cli-billing-helper-configured',
@@ -1513,7 +1515,7 @@ async function cmdProvider(flags) {
1513
1515
  }
1514
1516
  throw err;
1515
1517
  }
1516
- console.log(`\n✓ Saved to ${written} (mode 0600). This machine will use your own ${vendor} key for extraction when no earlier provider in the order is available.`);
1518
+ console.log(`\n✓ Saved to ${written} (mode 0600). This machine drafts through Claude Code first when you are signed in to it; your ${vendor} key only takes over when Claude Code is not usable, and once it does, it keeps drafting even after Claude Code becomes usable again.`);
1517
1519
  }
1518
1520
 
1519
1521
  // ─── Entry point ────────────────────────────────────────────────────────────
@@ -1575,9 +1577,11 @@ available on your account every subcommand says so and changes nothing.
1575
1577
  learnings keep their 7-day retraction window.`,
1576
1578
  provider: `Usage: auxilo provider <status|set|clear>
1577
1579
 
1578
- Configure a bring-your-own (BYO) model provider key for local extraction —
1579
- used only when no earlier provider in the fixed order (claude-code,
1580
- codex-cli) is available. Auxilo never sees or bills this key.
1580
+ Configure a bring-your-own (BYO) model provider key for local extraction.
1581
+ Claude Code drafts first when you are signed in to it; your key only
1582
+ takes over when Claude Code is not usable, and once it does, it keeps
1583
+ drafting even after Claude Code becomes usable again. Auxilo never sees
1584
+ or bills this key.
1581
1585
 
1582
1586
  status Show the configured vendor and model (never the key itself).
1583
1587
  set Configure a vendor, model, and key. Interactive ONLY: the key is
package/mcp-server.js CHANGED
@@ -77,7 +77,7 @@ function unlockPaymentRequired(status, data, http_endpoint) {
77
77
  status: 'payment_required',
78
78
  content_advisory: UNTRUSTED_PREVIEW_ADVISORY,
79
79
  cost: `${price} USDC on Base (set by contributor)`,
80
- how_to_pay: 'Pass an x402 payment via the x_payment argument, or configure an API key with unlock credits (npx auxilo setup).',
80
+ how_to_pay: 'Pass an x402 payment via the x_payment argument, or configure an API key with a funded balance (npx auxilo setup).',
81
81
  http_endpoint,
82
82
  payment_details: fencePaymentChallenge(data),
83
83
  };
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
198
198
  }
199
199
 
200
200
  const server = new Server(
201
- { name: 'auxilo', version: '0.9.25' },
201
+ { name: 'auxilo', version: '0.9.27' },
202
202
  {
203
203
  capabilities: { tools: {} },
204
204
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -217,7 +217,7 @@ RATE AFTER YOU USE: After unlocking and applying a learning from the Auxilo mark
217
217
 
218
218
  DEDUP BEFORE SUBMITTING: Search auxilo_knowledge for your topic before contributing to avoid duplicates.
219
219
 
220
- You earn 70% of every direct sale (60% discovery-driven). The builder who connected you earns a revenue share from your contributions.`
220
+ When another agent unlocks one of your contributions, the builder who connected you earns 70% of what that agent paid on a direct unlock and 60% when Auxilo search surfaced it.`
221
221
  }
222
222
  );
223
223
 
@@ -298,7 +298,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
298
298
  },
299
299
  required: ['specificity', 'actionability', 'novelty', 'completeness', 'total'],
300
300
  },
301
- contributor_wallet: { type: 'string', description: 'Your Base wallet (0x...) for revenue share' },
301
+ contributor_wallet: { type: 'string', description: 'Your Base wallet (0x...) for earnings' },
302
302
  unlock_price: { type: 'number', description: 'Price in USD to unlock this learning (min $0.05, default auto-calculated). Set higher for deep, high-value knowledge.' },
303
303
  contributor_agent: { type: 'string', description: 'Optional: identify yourself' },
304
304
  related_skills: { type: 'array', items: { type: 'string' }, description: 'Optional: related Auxilo skill IDs' },
@@ -362,7 +362,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
362
362
  },
363
363
  {
364
364
  name: 'auxilo_withdraw',
365
- description: 'Check how your earnings are paid out, and how to receive any legacy accrued balance. HOW PAYOUT WORKS NOW: earnings from sales settled on-chain are paid directly to your linked, verified wallet at the moment of sale — there is no balance to withdraw for those, because Auxilo never holds your share. LEGACY BALANCE: earnings accrued before direct settlement sit in your account\'s pending balance; withdraw them from your dashboard at https://auxilo.io/dashboard (bank payout via Stripe; $0.50 minimum, $0.25 flat fee), where you sign in with the email on your account. This tool reports your current balances and payout status; it does not move funds. To be paid at sale time going forward: auxilo_accept_terms, then auxilo_verify_wallet, then auxilo_link_wallet.',
365
+ description: 'Check how your earnings are paid out, and how to receive any legacy accrued balance. HOW PAYOUT WORKS NOW: earnings from sales settled on-chain are paid directly to your linked, verified wallet at the moment of sale — there is no balance to withdraw for those, because Auxilo never holds your earnings. LEGACY BALANCE: earnings accrued before direct settlement sit in your account\'s pending balance; withdraw them from your dashboard at https://auxilo.io/dashboard (bank payout via Stripe; $0.50 minimum, $0.25 flat fee), where you sign in with the email on your account. This tool reports your current balances and payout status; it does not move funds. To be paid at sale time going forward: auxilo_accept_terms, then auxilo_verify_wallet, then auxilo_link_wallet.',
366
366
  inputSchema: {
367
367
  type: 'object',
368
368
  properties: {
@@ -408,7 +408,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
408
408
  },
409
409
  {
410
410
  name: 'auxilo_account_earnings',
411
- description: 'View aggregate and per-learning earnings for your authenticated Auxilo account. Authenticates with your configured API key automatically, or pass a session_token (JWT). Returns total gross, contributor share, pending balance, total withdrawn, whether withdrawal is available (can_withdraw), and held_pending_assent — undisbursable receipts recorded before you accepted the current Terms, released to your withdrawable balance when you accept via auxilo_accept_terms. Earnings from on-chain-settled sales are paid to your wallet at sale time and appear in settlement history, not in pending balance. Free.',
411
+ description: 'View aggregate and per-learning earnings for your authenticated Auxilo account. Authenticates with your configured API key automatically, or pass a session_token (JWT). Returns total gross, contributor earnings, pending balance, total withdrawn, whether withdrawal is available (can_withdraw), and held_pending_assent — undisbursable receipts recorded before you accepted the current Terms, released to your withdrawable balance when you accept via auxilo_accept_terms. Earnings from on-chain-settled sales are paid to your wallet at sale time and appear in settlement history, not in pending balance. Free.',
412
412
  inputSchema: {
413
413
  type: 'object',
414
414
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.25",
3
+ "version": "0.9.27",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -15,6 +15,7 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
+ const crypto = require('crypto');
18
19
  const {
19
20
  loadIndexForExtraction,
20
21
  readExtractionIndex,
@@ -373,9 +374,10 @@ function resolveExtractionModelIdentity(runModelResult) {
373
374
  && typeof runModelResult.identity.provider === 'string'
374
375
  && runModelResult.identity.provider
375
376
  ) {
376
- return runModelResult.identity;
377
+ const identity = runModelResult.identity;
378
+ return { ...identity, model: identity.observed_model || null };
377
379
  }
378
- return { provider: 'unknown', model: null, version: null, vendor: null };
380
+ return { provider: 'unknown', model: null, requested_model: null, observed_model: null, identity_unresolved: 'missing', version: null, vendor: null };
379
381
  }
380
382
 
381
383
  /**
@@ -402,6 +404,8 @@ async function defaultInvokeModel(transcript, invokeOpts, opts) {
402
404
  reasonCode: result.reasonCode,
403
405
  authStatus: result.authStatus,
404
406
  extractionModel: resolveExtractionModelIdentity(result),
407
+ ...(result.hold && { hold: result.hold }),
408
+ ...(result.deferred && { deferred: result.deferred }),
405
409
  ...(result.authDiscrepancy !== undefined && { authDiscrepancy: result.authDiscrepancy }),
406
410
  // EXTRACTION-RUN-LOG (0.9.15): additive passthrough for the one-line-per-run
407
411
  // provider summary logged at the end of extractLocally() below. Only
@@ -432,6 +436,9 @@ function defaultInvokeJudge(opts) {
432
436
  out: result.text,
433
437
  usage: result.usage,
434
438
  reason: result.reason,
439
+ identity: result.identity,
440
+ ...(result.hold && { hold: result.hold }),
441
+ ...(result.deferred && { deferred: result.deferred }),
435
442
  // EXTRACTION-RUN-LOG (0.9.15): threaded through so runAnchoredJudge below
436
443
  // can report the judge call's status/argv/CLI version on the run summary
437
444
  // log line. Additive — every existing invokeJudge stub in this repo's
@@ -526,11 +533,16 @@ async function runAnchoredJudge(candidates, indexState, opts = {}) {
526
533
  judgeReasonCode: result && result.reasonCode,
527
534
  judgeArgv: result && result.argv,
528
535
  judgeCliVersion: result && result.cliVersion,
536
+ ...(result && result.hold && { hold: result.hold }),
537
+ ...(result && result.deferred && { deferred: result.deferred }),
529
538
  };
530
539
  }
531
540
  const parsed = parseJudgeDecisions(result.out, input, rankings);
532
541
  const usage = judgeUsage(result.usage, prompt, result.out);
533
542
  const judgeMeta = {
543
+ identity: result.identity,
544
+ ...(result.hold && { hold: result.hold }),
545
+ ...(result.deferred && { deferred: result.deferred }),
534
546
  judgeAttempted: true,
535
547
  judgeReasonCode: result.reasonCode,
536
548
  judgeArgv: result.argv,
@@ -681,13 +693,14 @@ function formatArgvForLog(argv) {
681
693
  * PRE_SPAWN_SKIP_REASON_CODES, so they render finder=skipped, not ran. The
682
694
  * observed defect lines (finder=ran, flags=n/a) come from a DIFFERENT case:
683
695
  * scripts/providers/index.js's runModel() falls through from claude-code to
684
- * the next configured provider (e.g. codex-cli) whenever claude-code's own
685
- * attempt fails with a NON_RETRYABLE_FOR_THIS_PROVIDER reasonCode, and
686
- * returns that OTHER provider's result directly when it stops there. That
687
- * provider's result carries no `argv` field at all (argv is a
688
- * claude-code-only concept), so there is no shipped argv being hidden here —
689
- * 'unknown' remains the correct, honest `hooks` value regardless of which
690
- * provider is named.
696
+ * the next automatic provider (byo-key) whenever claude-code's own attempt
697
+ * fails with a NON_RETRYABLE_FOR_THIS_PROVIDER reasonCode, and returns that
698
+ * OTHER provider's result directly when it stops there. codex-cli is not in
699
+ * that automatic walk; it remains reachable only through the explicit env
700
+ * override, which never falls through. A non-Claude provider's result carries
701
+ * no `argv` field at all (argv is a claude-code-only concept), so there is no
702
+ * shipped argv being hidden here — 'unknown' remains the correct, honest
703
+ * `hooks` value regardless of which provider is named.
691
704
  *
692
705
  * EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1) closed the mismatch this
693
706
  * docblock used to describe as a known, deferred gap: a fallthrough
@@ -735,6 +748,8 @@ function logProviderRunSummary(opts, runId, modelResult, judged) {
735
748
  }
736
749
 
737
750
  async function extractLocally(transcript, sourceType, opts = {}) {
751
+ opts = { ...opts, source: sourceType || '', sessionId: opts.sessionId || opts.runId || crypto.randomUUID(),
752
+ jobSha: opts.jobSha || crypto.createHash('sha256').update(transcript).digest('hex'), routeContext: opts.routeContext || {} };
738
753
  if (sourceType && !EXTRACTABLE_SOURCES.has(sourceType)) {
739
754
  return { learnings: [], skipped: `local extraction not implemented for "${sourceType}" — agent contributes via auxilo_contribute` };
740
755
  }
@@ -779,6 +794,8 @@ async function extractLocally(transcript, sourceType, opts = {}) {
779
794
  return {
780
795
  learnings: [],
781
796
  skipped: reason,
797
+ ...(modelResult.hold && { hold: modelResult.hold }),
798
+ ...(modelResult.deferred && { deferred: modelResult.deferred }),
782
799
  ...(modelResult.reasonCode !== undefined && { reasonCode: modelResult.reasonCode }),
783
800
  ...(modelResult.authStatus !== undefined && { authStatus: modelResult.authStatus }),
784
801
  ...(modelResult.authDiscrepancy !== undefined && {
@@ -791,7 +808,9 @@ async function extractLocally(transcript, sourceType, opts = {}) {
791
808
  // own opts.invokeModel that doesn't report one (every existing test does
792
809
  // this) — extraction_model then simply never appears, byte-identical to
793
810
  // pre-PART-C behavior.
794
- const extractionModel = modelResult.extractionModel || null;
811
+ const extractionModel = modelResult.extractionModel
812
+ ? { ...modelResult.extractionModel, model: modelResult.extractionModel.observed_model || null }
813
+ : null;
795
814
  const parsed = parseExtractionOutput(out, opts);
796
815
  const promptDropResult = applyPromptMemoryDrops(
797
816
  parsed.prompt_drops,
@@ -829,7 +848,10 @@ async function extractLocally(transcript, sourceType, opts = {}) {
829
848
  lexicalKept.push(drop.candidate);
830
849
  }
831
850
  }
832
- const judged = await runAnchoredJudge(lexicalKept, filterState, opts);
851
+ const judged = modelResult.hold || modelResult.deferred
852
+ ? { kept: lexicalKept, dropped: [], called: false, prompt_tokens: 0, completion_tokens: 0,
853
+ ...(modelResult.hold && { hold: modelResult.hold }), ...(modelResult.deferred && { deferred: modelResult.deferred }) }
854
+ : await runAnchoredJudge(lexicalKept, filterState, opts);
833
855
  const allDropped = [
834
856
  ...promptDropResult.dropped,
835
857
  ...lexicalDropped,
@@ -845,6 +867,9 @@ async function extractLocally(transcript, sourceType, opts = {}) {
845
867
  logProviderRunSummary(opts, opts.runId, modelResult, judged);
846
868
  return {
847
869
  learnings: judged.kept,
870
+ ...(judged.hold && { hold: judged.hold }),
871
+ ...(judged.deferred && { deferred: judged.deferred }),
872
+ ...providers.finishJob(opts.routeContext),
848
873
  dedup_dropped: allDropped.length,
849
874
  drop_audit: allDropped.map((drop) => drop.audit),
850
875
  prompt_memory_tokens: promptMemory.estimated_tokens,
@@ -537,6 +537,7 @@ async function runModel(opts = {}) {
537
537
  usage: null,
538
538
  reason: 'cannot resolve ~/.auxilo/providers.json — the home directory did not resolve to an absolute path',
539
539
  reasonCode: 'provider-home-unresolved',
540
+ refusal: 'pre-invocation',
540
541
  authStatus: 'unknown',
541
542
  };
542
543
  }
@@ -552,6 +553,7 @@ async function runModel(opts = {}) {
552
553
  usage: null,
553
554
  reason: '~/.auxilo/providers.json is not owner-read-only; refusing to use the stored key until its permissions are fixed (chmod 600 ~/.auxilo/providers.json) or the file is removed (`auxilo provider clear`)',
554
555
  reasonCode: 'providers-file-mode-unsafe',
556
+ refusal: 'pre-invocation',
555
557
  authStatus: 'unknown',
556
558
  };
557
559
  }
@@ -563,6 +565,7 @@ async function runModel(opts = {}) {
563
565
  usage: null,
564
566
  reason: 'no BYO provider key configured — run `auxilo provider set`',
565
567
  reasonCode: 'provider-not-configured',
568
+ refusal: 'pre-invocation',
566
569
  authStatus: 'unknown',
567
570
  };
568
571
  }
@@ -575,8 +578,9 @@ async function runModel(opts = {}) {
575
578
  ok: false,
576
579
  text: '',
577
580
  usage: null,
578
- reason: `configured base_url "${config.base_url}" is not https:// — refusing to send the transcript or the key over an insecure connection`,
581
+ reason: 'configured base URL is not https; refusing the request',
579
582
  reasonCode: 'provider-base-url-insecure',
583
+ refusal: 'pre-invocation',
580
584
  authStatus: 'unknown',
581
585
  };
582
586
  }
@@ -587,7 +591,7 @@ async function runModel(opts = {}) {
587
591
  const fetchImpl = typeof opts.fetchImpl === 'function' ? opts.fetchImpl : fetch;
588
592
  const timeoutMs = opts.timeoutMs || 120000;
589
593
 
590
- const identity = { provider: 'byo-key', model: config.model, version: null, vendor };
594
+ const identity = { provider: 'byo-key', model: null, requested_model: config.model, observed_model: null, identity_unresolved: 'missing', version: null, vendor };
591
595
 
592
596
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
593
597
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -595,6 +599,8 @@ async function runModel(opts = {}) {
595
599
 
596
600
  try {
597
601
  let res;
602
+ const gate = require('./route-binding.js').invocationGate(opts);
603
+ if (gate) return gate;
598
604
  try {
599
605
  res = await fetchImpl(request.url, {
600
606
  method: 'POST',
@@ -611,7 +617,7 @@ async function runModel(opts = {}) {
611
617
  ok: false,
612
618
  text: '',
613
619
  usage: null,
614
- reason: `BYO provider request failed: ${error.message}`,
620
+ reason: 'BYO provider request failed',
615
621
  reasonCode: 'provider-error',
616
622
  authStatus: 'unknown',
617
623
  identity,
@@ -634,7 +640,7 @@ async function runModel(opts = {}) {
634
640
  ok: false,
635
641
  text: '',
636
642
  usage: null,
637
- reason: `BYO provider returned HTTP ${res.status}`,
643
+ reason: Number.isInteger(res.status) ? `BYO provider returned HTTP ${res.status}` : 'BYO provider request failed',
638
644
  reasonCode: 'provider-error',
639
645
  authStatus: 'unknown',
640
646
  identity,
@@ -660,13 +666,19 @@ async function runModel(opts = {}) {
660
666
  ok: false,
661
667
  text: '',
662
668
  usage: null,
663
- reason: `BYO provider returned a non-JSON body: ${error.message}`,
669
+ reason: 'BYO provider returned a non-JSON body',
664
670
  reasonCode: 'provider-error',
665
671
  authStatus: 'unknown',
666
672
  identity,
667
673
  };
668
674
  }
669
675
 
676
+ const observed = data && (vendor === 'gemini' ? data.modelVersion : data.model);
677
+ if (typeof observed === 'string' && observed.trim()) {
678
+ identity.observed_model = observed;
679
+ identity.model = observed;
680
+ delete identity.identity_unresolved;
681
+ }
670
682
  return {
671
683
  ok: true,
672
684
  text: extractText(vendor, data),
@@ -675,6 +687,8 @@ async function runModel(opts = {}) {
675
687
  authStatus: 'unknown',
676
688
  identity,
677
689
  };
690
+ } catch {
691
+ return { ok: false, text: '', usage: null, reason: 'BYO provider request failed', reasonCode: 'provider-error', authStatus: 'unknown' };
678
692
  } finally {
679
693
  clearTimer();
680
694
  }
@@ -689,6 +703,7 @@ module.exports = {
689
703
  writeByoConfig,
690
704
  clearProvidersFile,
691
705
  resolveVendor,
706
+ baseUrlFor,
692
707
  DEFAULT_PROVIDERS_STATE_PATH,
693
708
  // Exported for direct unit coverage (test/byo-key-provider.test.js,
694
709
  // test/extract-w1-fix2.test.js) AND for scripts/providers/index.js, which