humanish 0.141.0 → 0.142.0

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.
Files changed (97) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +2 -0
  4. package/dist/analysis/admission.d.ts +8 -0
  5. package/dist/analysis/admission.js +15 -4
  6. package/dist/analysis/automatic-config.d.ts +5 -2
  7. package/dist/analysis/automatic-config.js +23 -8
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +17 -6
  10. package/dist/analysis/execute.js +36 -18
  11. package/dist/analysis/gemini-wire.d.ts +8 -0
  12. package/dist/analysis/gemini-wire.js +165 -0
  13. package/dist/analysis/image-tokens.d.ts +4 -2
  14. package/dist/analysis/image-tokens.js +24 -3
  15. package/dist/analysis/job.d.ts +1 -0
  16. package/dist/analysis/job.js +1 -0
  17. package/dist/analysis/openai-wire.d.ts +5 -0
  18. package/dist/analysis/openai-wire.js +101 -0
  19. package/dist/analysis/provider.d.ts +15 -7
  20. package/dist/analysis/provider.js +21 -101
  21. package/dist/analysis/responses.d.ts +3 -0
  22. package/dist/analysis/responses.js +19 -0
  23. package/dist/analysis/service.js +11 -3
  24. package/dist/analysis/types.d.ts +8 -2
  25. package/dist/analysis/validation.d.ts +1 -0
  26. package/dist/analysis/validation.js +13 -12
  27. package/dist/cli/commands/analyze.js +9 -7
  28. package/dist/cli/commands/observe.js +26 -1
  29. package/dist/cli/commands/run-command.d.ts +1 -1
  30. package/dist/cli/commands/run-command.js +12 -0
  31. package/dist/cli/commands/study-remote.d.ts +13 -0
  32. package/dist/cli/commands/study-remote.js +125 -0
  33. package/dist/cli/commands/study-route-computer-use.js +26 -2
  34. package/dist/cli/commands/study-route-scripted.js +1 -0
  35. package/dist/cli/commands/study-route-shared-world.js +1 -0
  36. package/dist/cli/commands/study-route-terminal.js +1 -0
  37. package/dist/cli/commands/study-run.js +34 -0
  38. package/dist/cli/commands/watch.js +5 -0
  39. package/dist/cli/doctor.js +7 -2
  40. package/dist/cli/findings.d.ts +8 -1
  41. package/dist/cli/findings.js +38 -15
  42. package/dist/cli/io.d.ts +8 -0
  43. package/dist/cli/io.js +2 -1
  44. package/dist/keys/key-resolution.d.ts +10 -0
  45. package/dist/keys/key-resolution.js +33 -5
  46. package/dist/observer/listen.d.ts +7 -2
  47. package/dist/observer/listen.js +10 -4
  48. package/dist/observer/render.d.ts +2 -0
  49. package/dist/observer/render.js +1 -1
  50. package/dist/observer-app.html +1 -1
  51. package/dist/remote/launch.d.ts +53 -0
  52. package/dist/remote/launch.js +320 -0
  53. package/dist/remote/observer-proxy.d.ts +11 -0
  54. package/dist/remote/observer-proxy.js +66 -0
  55. package/dist/remote/plan.d.ts +35 -0
  56. package/dist/remote/plan.js +114 -0
  57. package/dist/remote/reattach.d.ts +17 -0
  58. package/dist/remote/reattach.js +81 -0
  59. package/dist/remote/runner.d.ts +68 -0
  60. package/dist/remote/runner.js +216 -0
  61. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  62. package/dist/routes/computer-use/types.d.ts +2 -0
  63. package/dist/routes/scripted/types.d.ts +2 -0
  64. package/dist/routes/shared-world/types.d.ts +2 -0
  65. package/dist/routes/terminal/types.d.ts +2 -0
  66. package/dist/run/costs.d.ts +2 -1
  67. package/dist/run/pricing.d.ts +13 -1
  68. package/dist/run/pricing.js +50 -4
  69. package/dist/run/results.d.ts +1 -1
  70. package/dist/run/run-cost.d.ts +2 -1
  71. package/dist/run/run-cost.js +6 -1
  72. package/dist/run/run.d.ts +13 -0
  73. package/dist/run/run.js +18 -7
  74. package/dist/run/sandbox-creates.d.ts +10 -3
  75. package/dist/run/sandbox-creates.js +48 -6
  76. package/dist/run-study.d.ts +5 -0
  77. package/dist/study/doctor.js +14 -1
  78. package/dist/study/requirements.d.ts +1 -1
  79. package/dist/study/requirements.js +8 -6
  80. package/dist/study/route-inputs.js +4 -0
  81. package/dist/study/run-study-events.d.ts +1 -1
  82. package/dist/study/run-study-events.js +9 -2
  83. package/dist/substrates/e2b/base-module.d.ts +3 -0
  84. package/dist/substrates/e2b/base-module.js +40 -0
  85. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  86. package/dist/substrates/e2b/desktop-browser.js +24 -6
  87. package/dist/substrates/e2b/sdk.d.ts +24 -0
  88. package/dist/substrates/e2b/sdk.js +5 -1
  89. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  90. package/dist/substrates/e2b/shell-only.js +27 -0
  91. package/dist/substrates/modal/sandbox.js +27 -28
  92. package/dist/substrates/modal/sdk.d.ts +7 -0
  93. package/dist/tui-app.js +46 -46
  94. package/docs/contracts/study-analysis.md +51 -3
  95. package/docs/product/automatic-analysis.md +26 -2
  96. package/package.json +2 -2
  97. package/skills/humanish/SKILL.md +10 -0
package/ARCHITECTURE.md CHANGED
@@ -86,6 +86,7 @@ the analysis of the run:
86
86
  | `src/actors/` | The actor contract, the registry and each actor's session code | `src/actors/registry.ts` |
87
87
  | `src/subject/` | Subject provisioning over a `Shell`: clone, local tree, desktop CLI, serve | `src/subject/serve.ts` |
88
88
  | `src/substrates/` | Provider primitives: E2B in `e2b/`, Firecracker and Lima VMs in `local/`, the `Shell` | `src/substrates/desktop-session.ts` |
89
+ | `src/remote/` | `--remote`: plan a run for a runner sandbox, launch and follow it there, copy it back | `src/remote/launch.ts` |
89
90
  | `src/run/` | Run lifecycle, bundle types, bundle guards, paths, status, receipts, reclaim | `src/run/run.ts` |
90
91
  | `src/verify/` | `humanish verify`: evidence checks, share-safety grades, cost and rerun checks | `src/verify/verify.ts` |
91
92
  | `src/evidence/` | Redaction, screenshot checks and desktop recordings | `src/evidence/redaction.ts` |
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.142.0: Remote runs, Gemini and GPT-6 analysis models, and a view-only live stream (2026-10-21)
12
+
13
+ humanish 0.142.0 can run a study remotely: `humanish run <study> --remote` puts the study's own
14
+ process in a sandbox, so a long study keeps going after this machine sleeps, and `observe --run
15
+ <id>` reconnects to it. Study analysis can run on `gpt-6.1-sol` and `gpt-6-luna` as well as the
16
+ default `gpt-6-astra`, and on Google's Gemini API with `humanish keys set gemini`. A hosted
17
+ desktop's live stream is now view-only on the VNC server itself: before, anyone holding a
18
+ participant's stream URL could type into that desktop.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.142.0)
21
+
11
22
  ## 0.141.0: A run says when it is writing findings, and email links no longer open a dialog (2026-10-21)
12
23
 
13
24
  humanish 0.141.0 says when a run is writing its findings: the CLI prints progress with the
package/README.md CHANGED
@@ -242,6 +242,8 @@ beside Participants in Observer, with evidence links and separate participant
242
242
  feedback. You can explicitly select a restricted Codex account analyst with
243
243
  `npx humanish analyze --run latest --provider codex`; it uses remote inference,
244
244
  requires the qualified CLI and account login, and reports dollar cost as unknown.
245
+ `npx humanish analyze --run latest --provider gemini --max-cost 2` sends the same
246
+ evidence to Google's Gemini API instead, with the key from `npx humanish keys set gemini`.
245
247
  A study whose participant is a Codex `local-agent` uses that analyst by default. See the [analysis contract](docs/contracts/study-analysis.md) for
246
248
  coverage limits, estimated cost controls, corrections and sharing behavior.
247
249
 
@@ -1,4 +1,10 @@
1
1
  import type { ModelRate } from "../run/pricing.js";
2
+ /**
3
+ * How many times that expectation an API analyst writes. The OpenAI figures above come from its
4
+ * billed analyses. A gemini-3.8-flash analysis of one participant at high thinking wrote 24,246
5
+ * output tokens against 13,000 expected, so Gemini's expectation is doubled. That rests on one run.
6
+ */
7
+ export declare const EXPECTED_OUTPUT_SCALE: Readonly<Record<"openai" | "gemini", number>>;
2
8
  /**
3
9
  * When admission refuses a request, in words, for the cap as the sentence names it: `$3`, or `this`
4
10
  * in the help of the option that sets it. Admission compares the smaller of the worst case and the
@@ -21,6 +27,8 @@ interface AnalysisRequestSize {
21
27
  export interface AnalysisRequests {
22
28
  cohorts: readonly AnalysisRequestSize[];
23
29
  mergeTextBytes: number;
30
+ /** The analyst's EXPECTED_OUTPUT_SCALE; 1 when omitted. */
31
+ outputScale?: number;
24
32
  }
25
33
  export interface AnalysisCostEstimate {
26
34
  inputTokens: number;
@@ -15,6 +15,15 @@ const FRAMING_TOKENS = 2048;
15
15
  */
16
16
  const EXPECTED_OUTPUT_TOKENS = 12_000;
17
17
  const EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT = 1_000;
18
+ /**
19
+ * How many times that expectation an API analyst writes. The OpenAI figures above come from its
20
+ * billed analyses. A gemini-3.8-flash analysis of one participant at high thinking wrote 24,246
21
+ * output tokens against 13,000 expected, so Gemini's expectation is doubled. That rests on one run.
22
+ */
23
+ export const EXPECTED_OUTPUT_SCALE = {
24
+ openai: 1,
25
+ gemini: 2,
26
+ };
18
27
  /** Admission compares the expected cost times this margin with the cap. */
19
28
  const ADMISSION_MARGIN = 1.1;
20
29
  const MARGIN_PERCENT = Math.round((ADMISSION_MARGIN - 1) * 100);
@@ -38,12 +47,13 @@ function requestCost(rate, inputTokens, outputTokens) {
38
47
  outputTokens * rate.outputUsdPerToken * (long ? rate.longContext.outputMultiplier : 1));
39
48
  }
40
49
  /** One request's input and costs. `reportTokens` are earlier requests' reports it reads. */
41
- function estimateRequest(rate, size, reportTokens = 0) {
50
+ function estimateRequest(rate, size, outputScale, reportTokens = 0) {
42
51
  const inputTokens = Math.ceil(size.textBytes / BYTES_PER_INPUT_TOKEN) +
43
52
  FRAMING_TOKENS +
44
53
  size.imageTokens +
45
54
  reportTokens;
46
- const expectedOutputTokens = Math.min(size.outputAllowance, EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants);
55
+ const expectedOutputTokens = Math.min(size.outputAllowance, outputScale *
56
+ (EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants));
47
57
  return {
48
58
  inputTokens,
49
59
  expectedOutputTokens,
@@ -58,14 +68,15 @@ function estimateRequest(rate, size, reportTokens = 0) {
58
68
  */
59
69
  export function estimateAnalysisCost(rate, requests) {
60
70
  const { cohorts } = requests;
61
- const estimates = cohorts.map((size) => estimateRequest(rate, size));
71
+ const scale = requests.outputScale ?? 1;
72
+ const estimates = cohorts.map((size) => estimateRequest(rate, size, scale));
62
73
  if (cohorts.length > 1)
63
74
  estimates.push(estimateRequest(rate, {
64
75
  textBytes: requests.mergeTextBytes,
65
76
  imageTokens: 0,
66
77
  participants: cohorts.reduce((total, size) => total + size.participants, 0),
67
78
  outputAllowance: Math.max(...cohorts.map((size) => size.outputAllowance)),
68
- }, cohorts.reduce((total, size) => total + size.outputAllowance, 0)));
79
+ }, scale, cohorts.reduce((total, size) => total + size.outputAllowance, 0)));
69
80
  const sum = (key) => estimates.reduce((total, estimate) => total + estimate[key], 0);
70
81
  // Each request's cost is a whole number of micro-dollars, so rounding the sum to the nearest one
71
82
  // only removes the addition's float noise.
@@ -2,6 +2,9 @@ import type { StudyRoute } from "../study/plan.js";
2
2
  import type { AnalysisConfig } from "./types.js";
3
3
  export declare const DEFAULT_ANALYSIS_TIMEOUT_MS = 600000;
4
4
  export declare const DEFAULT_ANALYSIS_MODEL = "gpt-6-astra";
5
+ /** The model a Gemini analysis uses when the study or command names none: Google's current
6
+ * stable Flash model. */
7
+ export declare const DEFAULT_GEMINI_ANALYSIS_MODEL = "gemini-3.8-flash";
5
8
  interface StudyAnalysisSettings {
6
9
  model?: string;
7
10
  question?: string;
@@ -9,7 +12,7 @@ interface StudyAnalysisSettings {
9
12
  }
10
13
  /** The study's review.analysis: a provider selection for a separate review after a live run. */
11
14
  export type StudyAnalysis = StudyAnalysisSettings & ({
12
- provider?: "openai";
15
+ provider?: "openai" | "gemini";
13
16
  maxCostUsd: number;
14
17
  maxOutputTokens?: number;
15
18
  } | {
@@ -37,7 +40,7 @@ export declare function resolveAutomaticAnalysis(raw: unknown, participant?: Ana
37
40
  message: string;
38
41
  };
39
42
  export interface AutomaticAnalysisBudget {
40
- provider?: "openai" | "codex";
43
+ provider?: "openai" | "gemini" | "codex";
41
44
  billing?: "api-estimate" | "account-unknown";
42
45
  model: string;
43
46
  maxCostUsd: number | null;
@@ -7,6 +7,9 @@ import { scanEncodedText } from "../evidence/encoded-text.js";
7
7
  export const DEFAULT_ANALYSIS_TIMEOUT_MS = 600_000;
8
8
  const MAX_ANALYSIS_TIMEOUT_MS = 600_000;
9
9
  export const DEFAULT_ANALYSIS_MODEL = "gpt-6-astra";
10
+ /** The model a Gemini analysis uses when the study or command names none: Google's current
11
+ * stable Flash model. */
12
+ export const DEFAULT_GEMINI_ANALYSIS_MODEL = "gemini-3.8-flash";
10
13
  const FIELDS = new Set([
11
14
  "provider",
12
15
  "maxCostUsd",
@@ -60,8 +63,11 @@ export function resolveAutomaticAnalysis(raw, participant) {
60
63
  };
61
64
  }
62
65
  const value = raw;
63
- if (value.provider !== undefined && value.provider !== "openai" && value.provider !== "codex") {
64
- return { ok: false, message: "review.analysis.provider must be openai or codex." };
66
+ if (value.provider !== undefined &&
67
+ value.provider !== "openai" &&
68
+ value.provider !== "gemini" &&
69
+ value.provider !== "codex") {
70
+ return { ok: false, message: "review.analysis.provider must be openai, gemini or codex." };
65
71
  }
66
72
  if (value.provider === "codex") {
67
73
  const model = value.model === undefined ? CODEX_ANALYSIS_MODEL : value.model;
@@ -93,7 +99,12 @@ export function resolveAutomaticAnalysis(raw, participant) {
93
99
  };
94
100
  }
95
101
  const { maxCostUsd } = value;
96
- const model = value.model === undefined ? DEFAULT_ANALYSIS_MODEL : value.model;
102
+ const vendor = value.provider === "gemini" ? "gemini" : "openai";
103
+ const model = value.model !== undefined
104
+ ? value.model
105
+ : vendor === "gemini"
106
+ ? DEFAULT_GEMINI_ANALYSIS_MODEL
107
+ : DEFAULT_ANALYSIS_MODEL;
97
108
  const question = value.question === undefined ? null : value.question;
98
109
  const timeoutMs = value.timeoutMs === undefined ? DEFAULT_ANALYSIS_TIMEOUT_MS : value.timeoutMs;
99
110
  const maxOutputTokens = value.maxOutputTokens === undefined
@@ -104,7 +115,7 @@ export function resolveAutomaticAnalysis(raw, participant) {
104
115
  maxCostUsd <= 0 ||
105
116
  maxCostUsd > 1000 ||
106
117
  typeof model !== "string" ||
107
- !isSupportedAnalysisModel(model) ||
118
+ !isSupportedAnalysisModel(model, vendor) ||
108
119
  !validTimeout(timeoutMs) ||
109
120
  typeof maxOutputTokens !== "number" ||
110
121
  !Number.isSafeInteger(maxOutputTokens) ||
@@ -113,7 +124,7 @@ export function resolveAutomaticAnalysis(raw, participant) {
113
124
  !validQuestion(value.question)) {
114
125
  return {
115
126
  ok: false,
116
- message: `review.analysis requires a positive maxCostUsd up to 1000, a supported analysis model, timeoutMs 1–${MAX_ANALYSIS_TIMEOUT_MS}, maxOutputTokens 256–${MAX_ANALYSIS_OUTPUT_TOKENS}, and an optional question of at most 4000 characters.`,
127
+ message: `review.analysis requires a positive maxCostUsd up to 1000, a supported ${vendor === "gemini" ? "Gemini" : "OpenAI"} analysis model, timeoutMs 1–${MAX_ANALYSIS_TIMEOUT_MS}, maxOutputTokens 256–${MAX_ANALYSIS_OUTPUT_TOKENS}, and an optional question of at most 4000 characters.`,
117
128
  };
118
129
  }
119
130
  if (question !== null && scanEncodedText(question).sensitive)
@@ -121,7 +132,8 @@ export function resolveAutomaticAnalysis(raw, participant) {
121
132
  return {
122
133
  ok: true,
123
134
  config: {
124
- ...(value.provider === undefined ? {} : { provider: "openai" }),
135
+ // An omitted provider stays omitted: it is the OpenAI contract, and its hash predates the field.
136
+ ...(value.provider === undefined ? {} : { provider: vendor }),
125
137
  model,
126
138
  maxCostUsd,
127
139
  timeoutMs,
@@ -148,7 +160,9 @@ export function automaticAnalysisBudget(raw, route, participants, participant) {
148
160
  return {
149
161
  ...(config.provider === "codex"
150
162
  ? { provider: "codex", billing: "account-unknown" }
151
- : {}),
163
+ : config.provider === "gemini"
164
+ ? { provider: "gemini" }
165
+ : {}),
152
166
  model: config.model,
153
167
  maxCostUsd: config.maxCostUsd,
154
168
  trigger: raw === undefined ? "default" : "explicit",
@@ -179,5 +193,6 @@ export function formatAutomaticAnalysisBudget(budget) {
179
193
  const refused = budget.refusedFromUsd === undefined || who === undefined
180
194
  ? `refused before it starts if ${rule}`
181
195
  : `refused before it starts for ${who} even with no evidence (expected $${budget.refusedFromUsd.toFixed(2)}), since ${rule}`;
182
- return `After live runs: ${budget.trigger} analysis · ${budget.model}${range} · ${refused}; this is not a billing cap. Set review.analysis: false to disable.`;
196
+ const where = budget.provider === "gemini" ? " on Gemini, which sends the evidence to Google" : "";
197
+ return `After live runs: ${budget.trigger} analysis${where} · ${budget.model}${range} · ${refused}; this is not a billing cap. Set review.analysis: false to disable.`;
183
198
  }
@@ -3,6 +3,7 @@ import { resolveRunPath } from "../run/locate.js";
3
3
  import { refreshObserver, analyzeRun, readCompletedAnalysisSource, resolveAnalysisRun, } from "./service.js";
4
4
  import { preferLargerAnalysisOutput, ANALYSIS_PROMPT_VERSION } from "./execute.js";
5
5
  import { captureEvidence } from "./evidence.js";
6
+ import { analysisApiKey } from "../keys/key-resolution.js";
6
7
  import { loadRunTraces } from "../run/stream-trace.js";
7
8
  import { hashAnalysisValue } from "./validation.js";
8
9
  import { refusedCost } from "./admission.js";
@@ -56,7 +57,11 @@ function statusReason(result) {
56
57
  return "AUTOMATIC_ANALYSIS_CANCELLED";
57
58
  return null;
58
59
  }
59
- function outcomeOf(result) {
60
+ /** The skip reason for a missing API key, which names the key the selected analyst reads. */
61
+ const keyMissingReason = (config) => config.provider === "gemini"
62
+ ? "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING"
63
+ : "AUTOMATIC_ANALYSIS_KEY_MISSING";
64
+ function outcomeOf(result, config) {
60
65
  if (result.error?.code === "ANALYSIS_PUBLICATION_FAILED")
61
66
  return { state: "failed", reason: "AUTOMATIC_ANALYSIS_PUBLICATION_FAILED", result };
62
67
  if (result.error?.code?.startsWith("analysis_codex_"))
@@ -67,7 +72,7 @@ function outcomeOf(result) {
67
72
  if (code === "ANALYSIS_CANCELLED")
68
73
  return { state: "cancelled", reason: "AUTOMATIC_ANALYSIS_CANCELLED", result };
69
74
  if (code === "ANALYSIS_API_KEY_MISSING")
70
- return { ...skipped("AUTOMATIC_ANALYSIS_KEY_MISSING"), result };
75
+ return { ...skipped(keyMissingReason(config)), result };
71
76
  if (code === "ANALYSIS_BUSY")
72
77
  return { ...skipped("AUTOMATIC_ANALYSIS_BUSY"), result };
73
78
  if (code === "ANALYSIS_HISTORY_UNAVAILABLE")
@@ -158,7 +163,10 @@ export async function runAutomaticAnalysis(cwdInput, runId, configInput, deps =
158
163
  if (!prepared)
159
164
  return skipped("AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE");
160
165
  cwd = physicalCwdOf(prepared);
161
- const hasKey = Boolean((deps.apiKey ?? process.env.OPENAI_API_KEY)?.trim());
166
+ const hasKey = Boolean((deps.apiKey ??
167
+ (configInput.provider === "codex"
168
+ ? undefined
169
+ : analysisApiKey(configInput.provider ?? "openai", process.env)))?.trim());
162
170
  const source = await readAutomaticSource(cwd, prepared, structuredClone(configInput), deps, hasKey);
163
171
  if ("state" in source)
164
172
  return source;
@@ -225,9 +233,7 @@ export async function runAutomaticAnalysis(cwdInput, runId, configInput, deps =
225
233
  else if (deps.defaultRequest === true && (missingKey || !participantEvidence)) {
226
234
  outcome = signal.aborted
227
235
  ? { state: "cancelled", reason: "AUTOMATIC_ANALYSIS_CANCELLED" }
228
- : skipped(missingKey
229
- ? "AUTOMATIC_ANALYSIS_KEY_MISSING"
230
- : "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE");
236
+ : skipped(missingKey ? keyMissingReason(config) : "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE");
231
237
  }
232
238
  else {
233
239
  const result = await analyzeRun(cwd, runId, { config }, {
@@ -250,7 +256,7 @@ export async function runAutomaticAnalysis(cwdInput, runId, configInput, deps =
250
256
  });
251
257
  },
252
258
  });
253
- outcome = outcomeOf(result);
259
+ outcome = outcomeOf(result, config);
254
260
  }
255
261
  if (cancellationFailed)
256
262
  outcome = {
@@ -1,9 +1,19 @@
1
1
  import { type AnalysisArtifact, type AnalysisConfig, type AnalysisInput } from "./types.js";
2
- import { type AnalysisFetch, type AnalysisProvider } from "./provider.js";
2
+ import { type AnalysisApiVendor, type AnalysisFetch, type AnalysisProvider } from "./provider.js";
3
3
  import type { RejectedAnalysisOutput } from "./diagnostics.js";
4
4
  export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-8";
5
- export declare const isSupportedAnalysisModel: (model: string) => boolean;
6
- /** The OpenAI output limit when a study omits maxOutputTokens, and the most it may ask for. */
5
+ export declare const SUPPORTED_ANALYSIS_MODELS: readonly string[];
6
+ /**
7
+ * Gemini models that completed a live analysis. Each model's page under
8
+ * ai.google.dev/gemini-api/docs/models (read 2026-10-21) also lists image input and JSON-schema
9
+ * output for gemini-3.5-flash-lite, gemini-3-flash-preview and gemini-4-argon; they join this list
10
+ * once a live analysis on each completes.
11
+ */
12
+ export declare const GEMINI_ANALYSIS_MODELS: readonly string[];
13
+ /** Whether the API analyst `provider` (omitted is OpenAI) analyzes with `model`. */
14
+ export declare const isSupportedAnalysisModel: (model: string, provider?: AnalysisApiVendor) => boolean;
15
+ /** The API output limit when a study omits maxOutputTokens, and the most it may ask for. Gemini 4
16
+ * returns at most 32,768 output tokens from one generateContent request. */
7
17
  export declare const DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS = 16384;
8
18
  export declare const MAX_ANALYSIS_OUTPUT_TOKENS = 32768;
9
19
  export interface AnalysisAdmission {
@@ -38,9 +48,10 @@ export interface AnalysisProgress {
38
48
  * Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
39
49
  * call, credential, or network access. admission.ts holds the cost model: text at a calibrated
40
50
  * bytes-per-token ratio, the expected output, and the worst case that spends the whole output
41
- * allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
42
- * formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
43
- * is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
51
+ * allowance. image-tokens.ts prices each image: on OpenAI from its PNG size by the vision guide's
52
+ * patch formula, or at the 3,000-token ceiling when its size or the model's sizing is unknown; on
53
+ * Gemini at the fixed high-resolution count. The rates are those of the day admission runs.
54
+ * Unknown models/rates fail closed rather than inheriting those assumptions.
44
55
  * Known-sensitive decoded text denies admission with analysis_input_sensitive or
45
56
  * analysis_question_sensitive. Neither error includes rejected input values.
46
57
  */
@@ -1,20 +1,22 @@
1
1
  import { validCodexAnalysisConfig } from "./codex-config.js";
2
- import { HIGH_DETAIL_IMAGE_TOKEN_CEILING, highDetailImageTokens } from "./image-tokens.js";
3
- import { estimateAnalysisCost } from "./admission.js";
2
+ import { INPUT_IMAGE_DATA_URL, highDetailImageTokens, imageTokenCeiling } from "./image-tokens.js";
3
+ import { EXPECTED_OUTPUT_SCALE, estimateAnalysisCost } from "./admission.js";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
- import { MODEL_RATES } from "../run/pricing.js";
5
+ import { modelRate } from "../run/pricing.js";
6
6
  import { containsSensitive } from "../evidence/redaction.js";
7
7
  import { scanEncodedText } from "../evidence/encoded-text.js";
8
8
  import { ANALYSIS_ID_PATTERN, ANALYSIS_SCHEMA, } from "./types.js";
9
- import { INPUT_IMAGE_DATA_URL, createAnalysisProvider, } from "./provider.js";
9
+ import { createAnalysisProvider, } from "./provider.js";
10
10
  import { hashAnalysisValue, analysisResultJsonSchema, validateAnalysisInputMetadata, } from "./validation.js";
11
11
  import { analysisCohorts, analysisRequestCount, EVIDENCE_LIMITS } from "./analysis-limits.js";
12
12
  import { checkMergedResponse, cohortInputs, mergePacket, mergeResultJsonSchema, } from "./cohorts.js";
13
- import { checkProviderAnalysis, codexWarnings, recordProviderUsage, } from "./responses.js";
13
+ import { checkProviderAnalysis, codexWarnings, httpStatusWarning, recordProviderUsage, } from "./responses.js";
14
14
  import { mapWithConcurrency } from "../run/concurrency.js";
15
15
  export const ANALYSIS_PROMPT_VERSION = "study-evidence-8";
16
- const SUPPORTED_ANALYSIS_MODELS = Object.freeze([
16
+ export const SUPPORTED_ANALYSIS_MODELS = Object.freeze([
17
17
  "gpt-6-astra",
18
+ "gpt-6.1-sol",
19
+ "gpt-6-luna",
18
20
  "gpt-5.5",
19
21
  "gpt-5.6",
20
22
  "gpt-5.6-sol",
@@ -22,8 +24,18 @@ const SUPPORTED_ANALYSIS_MODELS = Object.freeze([
22
24
  "gpt-5.6-luna",
23
25
  ]);
24
26
  const SUPPORTED_MODELS = new Set(SUPPORTED_ANALYSIS_MODELS);
25
- export const isSupportedAnalysisModel = (model) => SUPPORTED_MODELS.has(model);
26
- /** The OpenAI output limit when a study omits maxOutputTokens, and the most it may ask for. */
27
+ /**
28
+ * Gemini models that completed a live analysis. Each model's page under
29
+ * ai.google.dev/gemini-api/docs/models (read 2026-10-21) also lists image input and JSON-schema
30
+ * output for gemini-3.5-flash-lite, gemini-3-flash-preview and gemini-4-argon; they join this list
31
+ * once a live analysis on each completes.
32
+ */
33
+ export const GEMINI_ANALYSIS_MODELS = Object.freeze(["gemini-3.8-flash"]);
34
+ const GEMINI_MODELS = new Set(GEMINI_ANALYSIS_MODELS);
35
+ /** Whether the API analyst `provider` (omitted is OpenAI) analyzes with `model`. */
36
+ export const isSupportedAnalysisModel = (model, provider = "openai") => (provider === "gemini" ? GEMINI_MODELS : SUPPORTED_MODELS).has(model);
37
+ /** The API output limit when a study omits maxOutputTokens, and the most it may ask for. Gemini 4
38
+ * returns at most 32,768 output tokens from one generateContent request. */
27
39
  export const DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS = 16_384;
28
40
  export const MAX_ANALYSIS_OUTPUT_TOKENS = 32_768;
29
41
  const MAX_EVIDENCE_BYTES = 1024 * 1024;
@@ -154,9 +166,10 @@ function inputError(input) {
154
166
  * Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
155
167
  * call, credential, or network access. admission.ts holds the cost model: text at a calibrated
156
168
  * bytes-per-token ratio, the expected output, and the worst case that spends the whole output
157
- * allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
158
- * formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
159
- * is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
169
+ * allowance. image-tokens.ts prices each image: on OpenAI from its PNG size by the vision guide's
170
+ * patch formula, or at the 3,000-token ceiling when its size or the model's sizing is unknown; on
171
+ * Gemini at the fixed high-resolution count. The rates are those of the day admission runs.
172
+ * Unknown models/rates fail closed rather than inheriting those assumptions.
160
173
  * Known-sensitive decoded text denies admission with analysis_input_sensitive or
161
174
  * analysis_question_sensitive. Neither error includes rejected input values.
162
175
  */
@@ -175,8 +188,10 @@ export function estimateAnalysisAdmission(input, config) {
175
188
  });
176
189
  if ((config.provider === "codex"
177
190
  ? !validCodexAnalysisConfig(config)
178
- : (config.provider !== undefined && config.provider !== "openai") ||
179
- !SUPPORTED_MODELS.has(config.model) ||
191
+ : (config.provider !== undefined &&
192
+ config.provider !== "openai" &&
193
+ config.provider !== "gemini") ||
194
+ !isSupportedAnalysisModel(config.model, config.provider) ||
180
195
  !Number.isFinite(config.maxCostUsd) ||
181
196
  config.maxCostUsd <= 0 ||
182
197
  config.maxCostUsd > 1000 ||
@@ -209,7 +224,7 @@ export function estimateAnalysisAdmission(input, config) {
209
224
  ratesAsOf: null,
210
225
  requests,
211
226
  };
212
- const rate = MODEL_RATES[config.model];
227
+ const rate = modelRate(config.model);
213
228
  if (!rate ||
214
229
  rate.placeholder ||
215
230
  !Number.isFinite(rate.inputUsdPerToken) ||
@@ -230,6 +245,7 @@ export function estimateAnalysisAdmission(input, config) {
230
245
  }),
231
246
  // Without reports: estimateAnalysisCost prices them at the cohorts' output allowances.
232
247
  mergeTextBytes: requestBytes(mergeRequest(input, config, [])),
248
+ outputScale: EXPECTED_OUTPUT_SCALE[config.provider ?? "openai"],
233
249
  });
234
250
  if (!Number.isFinite(cost.worstCaseCostUsd))
235
251
  return denied("analysis_rate_unknown");
@@ -284,7 +300,7 @@ const RANGE_BISECTIONS = 40;
284
300
  export function analysisCostRange(config, participants, preferLargerOutput = false) {
285
301
  if (config.provider === "codex")
286
302
  return undefined;
287
- const rate = MODEL_RATES[config.model];
303
+ const rate = modelRate(config.model);
288
304
  if (!rate || rate.placeholder)
289
305
  return undefined;
290
306
  const covered = Math.min(participants, EVIDENCE_LIMITS.participants);
@@ -306,9 +322,10 @@ export function analysisCostRange(config, participants, preferLargerOutput = fal
306
322
  outputAllowance,
307
323
  textBytes: cohortPrompt +
308
324
  share * (EVIDENCE_LIMITS.textBytes + EVIDENCE_LIMITS.evidence * ENTRY_STRUCTURE_BYTES),
309
- imageTokens: share * EVIDENCE_LIMITS.captures * HIGH_DETAIL_IMAGE_TOKEN_CEILING,
325
+ imageTokens: share * EVIDENCE_LIMITS.captures * imageTokenCeiling(config.model),
310
326
  })),
311
327
  mergeTextBytes: mergePrompt + share * covered * ENTRY_STRUCTURE_BYTES,
328
+ outputScale: EXPECTED_OUTPUT_SCALE[config.provider ?? "openai"],
312
329
  });
313
330
  const admits = (share, allowance) => request(share, allowance).admittedCostUsd <= config.maxCostUsd;
314
331
  // Cost grows with the share, so admission admits each allowance up to one share of the limits.
@@ -415,6 +432,7 @@ async function analysisProvider(config, options) {
415
432
  cliVersion: config.identity.cliVersion,
416
433
  }))
417
434
  : createAnalysisProvider({
435
+ vendor: config.provider ?? "openai",
418
436
  apiKey: options.apiKey,
419
437
  ...(options.fetch === undefined ? {} : { fetchFn: options.fetch }),
420
438
  });
@@ -534,8 +552,8 @@ export async function runAnalysis(input, config, options) {
534
552
  });
535
553
  responses.push(response);
536
554
  recordProviderUsage(artifact, config, responses);
537
- for (const warning of codexWarnings(config, response))
538
- if (!options.warnings?.includes(warning))
555
+ for (const warning of [...codexWarnings(config, response), httpStatusWarning(config, response)])
556
+ if (warning !== undefined && !options.warnings?.includes(warning))
539
557
  options.warnings?.push(warning);
540
558
  if (response.status !== "completed")
541
559
  return {
@@ -0,0 +1,8 @@
1
+ import type { AnalysisWire } from "./provider.js";
2
+ /**
3
+ * One stateless request: no tools, high thinking, JSON constrained to the schema, the standard
4
+ * service tier, and `store: false` so the request stays out of the project's request logs. Each
5
+ * capture goes at MEDIA_RESOLUTION_HIGH, the setting the media-resolution guide recommends for
6
+ * image analysis, after a text part naming its evidence key.
7
+ */
8
+ export declare const geminiWire: AnalysisWire;
@@ -0,0 +1,165 @@
1
+ // The Gemini API generateContent wire format of one analysis request
2
+ // (ai.google.dev/api/generate-content, read 2026-10-21): what the request body says and how a
3
+ // response becomes an AnalysisProviderResult. provider.ts owns the transport around it.
4
+ import { isRecord } from "../run/type-guards.js";
5
+ import { INPUT_IMAGE_DATA_URL } from "./image-tokens.js";
6
+ const record = (value) => (isRecord(value) ? value : {});
7
+ const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 1e12;
8
+ /**
9
+ * The response schema as Gemini accepts it, and the array limits it leaves out. Gemini refuses the
10
+ * analysis schemas with a 400 while they carry `maxItems`: its constrained decoder counts the
11
+ * states each nested array limit adds, and its structured-output guide names large nested
12
+ * `maxItems` as a cause of that refusal. So any Gemini model can return an array past its limit;
13
+ * validation.ts checks every response against the full schema and refuses that one as
14
+ * analysis_validation_failed_schema_invalid. The limits go to the model as instructions instead.
15
+ */
16
+ function withoutArrayLimits(schema) {
17
+ const limits = [];
18
+ const strip = (value, path) => {
19
+ if (Array.isArray(value))
20
+ return value.map((item) => strip(item, path));
21
+ if (!isRecord(value))
22
+ return value;
23
+ if (typeof value.maxItems === "number")
24
+ limits.push(`${path || "the answer"}: ${value.maxItems}`);
25
+ return Object.fromEntries(Object.entries(value)
26
+ .filter(([key]) => key !== "maxItems")
27
+ .map(([key, child]) => [
28
+ key,
29
+ key === "properties" && isRecord(child)
30
+ ? Object.fromEntries(Object.entries(child).map(([name, field]) => [
31
+ name,
32
+ strip(field, path ? `${path}.${name}` : name),
33
+ ]))
34
+ : strip(child, key === "items" ? `${path}[]` : path),
35
+ ]));
36
+ };
37
+ return { schema: strip(schema, ""), limits };
38
+ }
39
+ /**
40
+ * Proto3 JSON leaves out a count that is zero, so an absent thoughts, candidates or cached count is
41
+ * 0. promptTokenCount is never zero for a request that carries evidence, so it must be present.
42
+ * Thoughts bill as output (ai.google.dev/gemini-api/docs/pricing: "Output price (including
43
+ * thinking tokens)"). totalTokenCount must equal the parts, or none of them is trusted.
44
+ */
45
+ function usageOf(raw) {
46
+ const usage = record(record(raw).usageMetadata);
47
+ const counted = (key) => {
48
+ const value = usage[key] ?? 0;
49
+ return count(value) ? value : null;
50
+ };
51
+ const prompt = usage.promptTokenCount;
52
+ const toolPrompt = counted("toolUsePromptTokenCount");
53
+ const candidates = counted("candidatesTokenCount");
54
+ const thoughts = counted("thoughtsTokenCount");
55
+ const cached = counted("cachedContentTokenCount");
56
+ if (!count(prompt) ||
57
+ toolPrompt === null ||
58
+ candidates === null ||
59
+ thoughts === null ||
60
+ cached === null)
61
+ return null;
62
+ const input = prompt + toolPrompt;
63
+ const output = candidates + thoughts;
64
+ if (cached > input ||
65
+ (usage.totalTokenCount !== undefined && usage.totalTokenCount !== input + output))
66
+ return null;
67
+ return { input, output, ...(cached > 0 ? { cachedInput: cached } : {}) };
68
+ }
69
+ /** The finish reasons that end a candidate without a usable answer, by what they mean. The
70
+ * provider declined to answer: its safety, recitation, language, blocklist or policy filters. */
71
+ const DECLINED = new Set([
72
+ "SAFETY",
73
+ "RECITATION",
74
+ "LANGUAGE",
75
+ "BLOCKLIST",
76
+ "PROHIBITED_CONTENT",
77
+ "SPII",
78
+ "IMAGE_SAFETY",
79
+ "IMAGE_PROHIBITED_CONTENT",
80
+ "IMAGE_RECITATION",
81
+ "ESCALATION",
82
+ "PUP_LIMITED_DISABLED",
83
+ ]);
84
+ /** The answer stopped at the output limit, or at a long-decode hop this one request never
85
+ * follows. */
86
+ const CUT_OFF = new Set(["MAX_TOKENS", "CONTINUATION"]);
87
+ /** One candidate whose only non-thought part is the JSON answer; anything else fails closed. */
88
+ function parseGenerateContent(raw) {
89
+ const root = record(raw);
90
+ const base = { output: null, usage: usageOf(raw), dispatched: true };
91
+ if (record(root.promptFeedback).blockReason !== undefined)
92
+ return { ...base, status: "refused", errorCode: "refusal" };
93
+ const candidates = Array.isArray(root.candidates) ? root.candidates : [];
94
+ if (candidates.length !== 1)
95
+ return { ...base, status: "failed", errorCode: "invalid_response" };
96
+ const candidate = record(candidates[0]);
97
+ const reason = String(candidate.finishReason);
98
+ if (DECLINED.has(reason))
99
+ return { ...base, status: "refused", errorCode: "refusal" };
100
+ if (CUT_OFF.has(reason))
101
+ return { ...base, status: "incomplete", errorCode: "output_incomplete" };
102
+ const parts = record(candidate.content).parts;
103
+ // A thought summary is never part of the answer; thoughtSignature on a text part is metadata.
104
+ const answer = Array.isArray(parts) ? parts.filter((part) => record(part).thought !== true) : [];
105
+ const text = record(answer[0]).text;
106
+ if (reason !== "STOP" ||
107
+ answer.length !== 1 ||
108
+ typeof text !== "string" ||
109
+ Object.keys(record(answer[0])).some((key) => key !== "text" && key !== "thoughtSignature"))
110
+ return { ...base, status: "failed", errorCode: "invalid_response" };
111
+ try {
112
+ return { ...base, status: "completed", output: JSON.parse(text), errorCode: null };
113
+ }
114
+ catch {
115
+ return { ...base, status: "failed", errorCode: "invalid_response" };
116
+ }
117
+ }
118
+ /**
119
+ * One stateless request: no tools, high thinking, JSON constrained to the schema, the standard
120
+ * service tier, and `store: false` so the request stays out of the project's request logs. Each
121
+ * capture goes at MEDIA_RESOLUTION_HIGH, the setting the media-resolution guide recommends for
122
+ * image analysis, after a text part naming its evidence key.
123
+ */
124
+ export const geminiWire = {
125
+ url: (model) => `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
126
+ headers: (apiKey) => ({ "x-goog-api-key": apiKey, "Content-Type": "application/json" }),
127
+ body: (request) => {
128
+ const { schema, limits } = withoutArrayLimits(request.schema);
129
+ const limitText = limits.length === 0
130
+ ? ""
131
+ : `\n\nThe response schema sent with this request omits its array limits. Keep each array at or under its limit, by field path: ${limits.join("; ")}.`;
132
+ return {
133
+ systemInstruction: { parts: [{ text: request.instructions + limitText }] },
134
+ contents: [
135
+ {
136
+ // The API leaves role optional outside a multi-turn conversation.
137
+ parts: [
138
+ { text: request.evidence },
139
+ ...request.images.flatMap((image) => {
140
+ const [, type, data] = INPUT_IMAGE_DATA_URL.exec(image.dataUrl);
141
+ return [
142
+ { text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
143
+ {
144
+ inlineData: { mimeType: `image/${type}`, data },
145
+ mediaResolution: { level: "MEDIA_RESOLUTION_HIGH" },
146
+ },
147
+ ];
148
+ }),
149
+ ],
150
+ },
151
+ ],
152
+ generationConfig: {
153
+ candidateCount: 1,
154
+ maxOutputTokens: request.maxOutputTokens,
155
+ thinkingConfig: { thinkingLevel: "HIGH" },
156
+ responseFormat: {
157
+ text: { mimeType: "APPLICATION_JSON", schema },
158
+ },
159
+ },
160
+ serviceTier: "standard",
161
+ store: false,
162
+ };
163
+ },
164
+ parse: parseGenerateContent,
165
+ };
@@ -1,3 +1,5 @@
1
- /** 2,500 patches × 1.2: what any image costs on these models when its size is unknown. */
2
- export declare const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
1
+ /** An input image the analysis providers accept; the groups are the image type and its base64 bytes. */
2
+ export declare const INPUT_IMAGE_DATA_URL: RegExp;
3
+ /** The most one image can cost on `model`, whatever its size. */
4
+ export declare const imageTokenCeiling: (model: string) => number;
3
5
  export declare function highDetailImageTokens(model: string, dataUrl: string): number;