humanish 0.141.0 → 0.143.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 (99) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +3 -1
  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 +17 -3
  7. package/dist/analysis/automatic-config.js +56 -12
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +6 -5
  10. package/dist/analysis/execute.js +27 -26
  11. package/dist/analysis/gemini-wire.d.ts +9 -0
  12. package/dist/analysis/gemini-wire.js +166 -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/models.d.ts +12 -0
  18. package/dist/analysis/models.js +47 -0
  19. package/dist/analysis/openai-wire.d.ts +6 -0
  20. package/dist/analysis/openai-wire.js +102 -0
  21. package/dist/analysis/provider.d.ts +19 -7
  22. package/dist/analysis/provider.js +21 -101
  23. package/dist/analysis/responses.d.ts +3 -0
  24. package/dist/analysis/responses.js +19 -0
  25. package/dist/analysis/service.js +11 -3
  26. package/dist/analysis/types.d.ts +12 -2
  27. package/dist/analysis/validation.d.ts +1 -0
  28. package/dist/analysis/validation.js +17 -15
  29. package/dist/cli/commands/analyze.js +11 -7
  30. package/dist/cli/commands/observe.js +26 -1
  31. package/dist/cli/commands/run-command.d.ts +1 -1
  32. package/dist/cli/commands/run-command.js +12 -0
  33. package/dist/cli/commands/study-remote.d.ts +13 -0
  34. package/dist/cli/commands/study-remote.js +125 -0
  35. package/dist/cli/commands/study-route-computer-use.js +26 -2
  36. package/dist/cli/commands/study-route-scripted.js +1 -0
  37. package/dist/cli/commands/study-route-shared-world.js +1 -0
  38. package/dist/cli/commands/study-route-terminal.js +1 -0
  39. package/dist/cli/commands/study-run.js +34 -0
  40. package/dist/cli/commands/watch.js +5 -0
  41. package/dist/cli/doctor.js +7 -2
  42. package/dist/cli/findings.d.ts +9 -1
  43. package/dist/cli/findings.js +62 -17
  44. package/dist/cli/io.d.ts +8 -0
  45. package/dist/cli/io.js +2 -1
  46. package/dist/keys/key-resolution.d.ts +10 -0
  47. package/dist/keys/key-resolution.js +33 -5
  48. package/dist/observer/listen.d.ts +7 -2
  49. package/dist/observer/listen.js +10 -4
  50. package/dist/observer/render.d.ts +2 -0
  51. package/dist/observer/render.js +1 -1
  52. package/dist/observer-app.html +1 -1
  53. package/dist/remote/launch.d.ts +56 -0
  54. package/dist/remote/launch.js +365 -0
  55. package/dist/remote/observer-proxy.d.ts +11 -0
  56. package/dist/remote/observer-proxy.js +66 -0
  57. package/dist/remote/plan.d.ts +35 -0
  58. package/dist/remote/plan.js +114 -0
  59. package/dist/remote/reattach.d.ts +17 -0
  60. package/dist/remote/reattach.js +81 -0
  61. package/dist/remote/runner.d.ts +84 -0
  62. package/dist/remote/runner.js +241 -0
  63. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  64. package/dist/routes/computer-use/types.d.ts +2 -0
  65. package/dist/routes/scripted/types.d.ts +2 -0
  66. package/dist/routes/shared-world/types.d.ts +2 -0
  67. package/dist/routes/terminal/types.d.ts +2 -0
  68. package/dist/run/costs.d.ts +2 -1
  69. package/dist/run/pricing.d.ts +13 -1
  70. package/dist/run/pricing.js +50 -4
  71. package/dist/run/results.d.ts +1 -1
  72. package/dist/run/run-cost.d.ts +2 -1
  73. package/dist/run/run-cost.js +6 -1
  74. package/dist/run/run.d.ts +13 -0
  75. package/dist/run/run.js +18 -7
  76. package/dist/run/sandbox-creates.d.ts +10 -3
  77. package/dist/run/sandbox-creates.js +48 -6
  78. package/dist/run-study.d.ts +5 -0
  79. package/dist/study/doctor.js +14 -1
  80. package/dist/study/requirements.d.ts +1 -1
  81. package/dist/study/requirements.js +8 -6
  82. package/dist/study/route-inputs.js +4 -0
  83. package/dist/study/run-study-events.d.ts +1 -1
  84. package/dist/study/run-study-events.js +9 -2
  85. package/dist/substrates/e2b/base-module.d.ts +3 -0
  86. package/dist/substrates/e2b/base-module.js +40 -0
  87. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  88. package/dist/substrates/e2b/desktop-browser.js +24 -6
  89. package/dist/substrates/e2b/sdk.d.ts +24 -0
  90. package/dist/substrates/e2b/sdk.js +5 -1
  91. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  92. package/dist/substrates/e2b/shell-only.js +27 -0
  93. package/dist/substrates/modal/sandbox.js +27 -28
  94. package/dist/substrates/modal/sdk.d.ts +7 -0
  95. package/dist/tui-app.js +46 -46
  96. package/docs/contracts/study-analysis.md +85 -4
  97. package/docs/product/automatic-analysis.md +42 -8
  98. package/package.json +2 -2
  99. package/skills/humanish/SKILL.md +18 -1
@@ -0,0 +1,166 @@
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, the analysis's reasoning effort as its thinking level (the
120
+ * ThinkingLevel enum spells the same names in capitals), JSON constrained to the schema, the standard
121
+ * service tier, and `store: false` so the request stays out of the project's request logs. Each
122
+ * capture goes at MEDIA_RESOLUTION_HIGH, the setting the media-resolution guide recommends for
123
+ * image analysis, after a text part naming its evidence key.
124
+ */
125
+ export const geminiWire = {
126
+ url: (model) => `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
127
+ headers: (apiKey) => ({ "x-goog-api-key": apiKey, "Content-Type": "application/json" }),
128
+ body: (request) => {
129
+ const { schema, limits } = withoutArrayLimits(request.schema);
130
+ const limitText = limits.length === 0
131
+ ? ""
132
+ : `\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("; ")}.`;
133
+ return {
134
+ systemInstruction: { parts: [{ text: request.instructions + limitText }] },
135
+ contents: [
136
+ {
137
+ // The API leaves role optional outside a multi-turn conversation.
138
+ parts: [
139
+ { text: request.evidence },
140
+ ...request.images.flatMap((image) => {
141
+ const [, type, data] = INPUT_IMAGE_DATA_URL.exec(image.dataUrl);
142
+ return [
143
+ { text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
144
+ {
145
+ inlineData: { mimeType: `image/${type}`, data },
146
+ mediaResolution: { level: "MEDIA_RESOLUTION_HIGH" },
147
+ },
148
+ ];
149
+ }),
150
+ ],
151
+ },
152
+ ],
153
+ generationConfig: {
154
+ candidateCount: 1,
155
+ maxOutputTokens: request.maxOutputTokens,
156
+ thinkingConfig: { thinkingLevel: request.reasoningEffort.toUpperCase() },
157
+ responseFormat: {
158
+ text: { mimeType: "APPLICATION_JSON", schema },
159
+ },
160
+ },
161
+ serviceTier: "standard",
162
+ store: false,
163
+ };
164
+ },
165
+ parse: parseGenerateContent,
166
+ };
@@ -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;
@@ -1,22 +1,40 @@
1
+ /** An input image the analysis providers accept; the groups are the image type and its base64 bytes. */
2
+ export const INPUT_IMAGE_DATA_URL = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/;
1
3
  /**
2
- * Input tokens one image costs at `detail: "high"`, which is what the provider request sends.
4
+ * Input tokens one image costs at the detail the provider request sends: `detail: "high"` on
5
+ * OpenAI, MEDIA_RESOLUTION_HIGH on Gemini.
3
6
  *
4
7
  * OpenAI's vision guide (https://developers.openai.com/api/docs/guides/images-vision, read
5
8
  * 2026-09-30) prices these patch-based models by 32 px patches: ceil(width / 32) × ceil(height /
6
9
  * 32), scaled down to the model's high-detail patch budget, then ceil(patches × multiplier).
7
10
  * Scaling down only removes patches, so min(patches, budget) bounds the billed count from above.
8
11
  * The guide lists a 2,500-patch high budget and a 1.2 multiplier for each model below; its own
9
- * examples are 1024×1024 → 1,229 tokens and 2048×2048 → 3,000.
12
+ * examples are 1024×1024 → 1,229 tokens and 2048×2048 → 3,000. It does not list gpt-6-luna or
13
+ * gpt-6.1-sol (read 2026-10-21). Their entries copy gpt-6-astra's because OpenAI billed the same
14
+ * image tokens on all three on 2026-10-21, within the guide's one-token rounding.
10
15
  */
11
16
  const HIGH_DETAIL_SIZING = Object.freeze({
12
17
  "gpt-6-astra": { patchBudget: 2500, multiplierTenths: 12 },
18
+ "gpt-6.1-sol": { patchBudget: 2500, multiplierTenths: 12 },
19
+ "gpt-6-luna": { patchBudget: 2500, multiplierTenths: 12 },
13
20
  "gpt-5.6-sol": { patchBudget: 2500, multiplierTenths: 12 },
14
21
  "gpt-5.6-terra": { patchBudget: 2500, multiplierTenths: 12 },
15
22
  "gpt-5.6-luna": { patchBudget: 2500, multiplierTenths: 12 },
16
23
  "gpt-5.5": { patchBudget: 2500, multiplierTenths: 12 },
17
24
  });
18
25
  /** 2,500 patches × 1.2: what any image costs on these models when its size is unknown. */
19
- export const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
26
+ const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
27
+ /**
28
+ * Gemini bills an image by its media resolution, not its size. The provider sends every capture at
29
+ * MEDIA_RESOLUTION_HIGH, which the media-resolution guide
30
+ * (ai.google.dev/gemini-api/docs/media-resolution, read 2026-10-21) gives as at most 1,120 tokens
31
+ * for an image on Gemini 3 and Gemini 4 models. A 1x1 PNG at that setting was billed 1,089.
32
+ */
33
+ const GEMINI_HIGH_RESOLUTION_IMAGE_TOKENS = Object.freeze({
34
+ "gemini-3.8-flash": 1120,
35
+ });
36
+ /** The most one image can cost on `model`, whatever its size. */
37
+ export const imageTokenCeiling = (model) => GEMINI_HIGH_RESOLUTION_IMAGE_TOKENS[model] ?? HIGH_DETAIL_IMAGE_TOKEN_CEILING;
20
38
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
21
39
  /** Width and height from a PNG data URL's IHDR chunk; undefined for anything else. */
22
40
  function pngDimensions(dataUrl) {
@@ -31,6 +49,9 @@ function pngDimensions(dataUrl) {
31
49
  return width > 0 && height > 0 ? { width, height } : undefined;
32
50
  }
33
51
  export function highDetailImageTokens(model, dataUrl) {
52
+ const fixed = GEMINI_HIGH_RESOLUTION_IMAGE_TOKENS[model];
53
+ if (fixed !== undefined)
54
+ return fixed;
34
55
  const sizing = HIGH_DETAIL_SIZING[model];
35
56
  const size = sizing === undefined ? undefined : pngDimensions(dataUrl);
36
57
  if (sizing === undefined || size === undefined)
@@ -53,6 +53,7 @@ declare const jobSchema: z.ZodObject<{
53
53
  AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED: "AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED";
54
54
  AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE: "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE";
55
55
  AUTOMATIC_ANALYSIS_FAILED: "AUTOMATIC_ANALYSIS_FAILED";
56
+ AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING: "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING";
56
57
  AUTOMATIC_ANALYSIS_KEY_MISSING: "AUTOMATIC_ANALYSIS_KEY_MISSING";
57
58
  AUTOMATIC_ANALYSIS_LIMITATIONS: "AUTOMATIC_ANALYSIS_LIMITATIONS";
58
59
  AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE: "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE";
@@ -35,6 +35,7 @@ const reasons = [
35
35
  "AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN",
36
36
  "AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE",
37
37
  "AUTOMATIC_ANALYSIS_KEY_MISSING",
38
+ "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING",
38
39
  "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE",
39
40
  "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED",
40
41
  "AUTOMATIC_ANALYSIS_BUSY",
@@ -0,0 +1,12 @@
1
+ import type { ReasoningEffort } from "../actors/reasoning-effort.js";
2
+ import type { AnalysisApiVendor } from "./provider.js";
3
+ import type { AnalysisConfig } from "./types.js";
4
+ export declare const SUPPORTED_ANALYSIS_MODELS: readonly string[];
5
+ export declare const GEMINI_ANALYSIS_MODELS: readonly string[];
6
+ /** Whether the API analyst `provider` (omitted is OpenAI) analyzes with `model`. */
7
+ export declare const isSupportedAnalysisModel: (model: string, provider?: AnalysisApiVendor) => boolean;
8
+ /** The reasoning efforts `model` accepts from `provider`, least first; empty for an unknown model. */
9
+ export declare const analysisEfforts: (model: string, provider?: AnalysisApiVendor) => readonly ReasoningEffort[];
10
+ /** The effort an analysis runs at: the declared one, high for an API analysis that declares none,
11
+ * and the Codex account's fixed effort. */
12
+ export declare function analysisEffort(config: AnalysisConfig): ReasoningEffort;
@@ -0,0 +1,47 @@
1
+ // The models each API analyst runs, and the reasoning efforts each model accepts. Config parsing,
2
+ // admission, the CLI help and the wire formats all read this one table.
3
+ /**
4
+ * `reasoning.effort` values from each model's page under developers.openai.com/api/docs/models
5
+ * (read 2026-10-21). None of them lists `minimal`; gpt-6-astra and gpt-6.1-sol also refuse `none`.
6
+ */
7
+ const OPENAI_MODELS = {
8
+ "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
9
+ "gpt-6.1-sol": ["low", "medium", "high", "xhigh", "max"],
10
+ "gpt-6-luna": ["none", "low", "medium", "high", "xhigh", "max"],
11
+ "gpt-5.5": ["none", "low", "medium", "high", "xhigh"],
12
+ "gpt-5.6": ["none", "low", "medium", "high", "xhigh", "max"],
13
+ "gpt-5.6-sol": ["none", "low", "medium", "high", "xhigh", "max"],
14
+ "gpt-5.6-terra": ["none", "low", "medium", "high", "xhigh", "max"],
15
+ "gpt-5.6-luna": ["none", "low", "medium", "high", "xhigh", "max"],
16
+ };
17
+ /**
18
+ * Gemini models that completed a live analysis, with the `thinkingLevel` values the thinking guide
19
+ * lists for each (ai.google.dev/gemini-api/docs/generate-content/thinking, read 2026-10-21):
20
+ * gemini-3.8-flash answers `minimal` with an error. Each model's page under
21
+ * ai.google.dev/gemini-api/docs/models also lists image input and JSON-schema output for
22
+ * gemini-3.5-flash-lite, gemini-3-flash-preview and gemini-4-argon; they join this table once a
23
+ * live analysis on each completes.
24
+ */
25
+ const GEMINI_MODELS = {
26
+ "gemini-3.8-flash": ["low", "medium", "high"],
27
+ };
28
+ const MODELS = { openai: OPENAI_MODELS, gemini: GEMINI_MODELS };
29
+ export const SUPPORTED_ANALYSIS_MODELS = Object.freeze(Object.keys(OPENAI_MODELS));
30
+ export const GEMINI_ANALYSIS_MODELS = Object.freeze(Object.keys(GEMINI_MODELS));
31
+ /** Whether the API analyst `provider` (omitted is OpenAI) analyzes with `model`. */
32
+ export const isSupportedAnalysisModel = (model, provider = "openai") => Object.hasOwn(MODELS[provider], model);
33
+ /** The reasoning efforts `model` accepts from `provider`, least first; empty for an unknown model. */
34
+ export const analysisEfforts = (model, provider = "openai") => Object.hasOwn(MODELS[provider], model) ? MODELS[provider][model] : [];
35
+ /**
36
+ * The effort an API analysis runs at when its configuration declares none. Every saved analysis
37
+ * without `reasoningEffort` ran at this effort, so it stays high: a different default must be
38
+ * written into the configuration it resolves, where it also changes the configuration's hash.
39
+ */
40
+ const UNDECLARED_EFFORT = "high";
41
+ /** The effort an analysis runs at: the declared one, high for an API analysis that declares none,
42
+ * and the Codex account's fixed effort. */
43
+ export function analysisEffort(config) {
44
+ if (config.provider === "codex")
45
+ return config.identity.reasoningEffort;
46
+ return config.reasoningEffort ?? UNDECLARED_EFFORT;
47
+ }
@@ -0,0 +1,6 @@
1
+ import type { AnalysisProviderResult, AnalysisWire } from "./provider.js";
2
+ /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
3
+ export declare function parseAnalysisResponse(raw: unknown): AnalysisProviderResult;
4
+ /** One stateless request: no stored response, no tools, the analysis's reasoning effort, strict
5
+ * schema. */
6
+ export declare const openaiWire: AnalysisWire;
@@ -0,0 +1,102 @@
1
+ // The OpenAI Responses API wire format of one analysis request: what the request body says and how
2
+ // a response becomes an AnalysisProviderResult. provider.ts owns the transport around it.
3
+ import { OPENAI_RESPONSES_URL } from "../actors/openai-endpoint.js";
4
+ import { isRecord } from "../run/type-guards.js";
5
+ const record = (value) => (isRecord(value) ? value : {});
6
+ const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 1e12;
7
+ function usageOf(raw) {
8
+ const usage = record(record(raw).usage);
9
+ const details = record(usage.input_tokens_details);
10
+ const input = usage.input_tokens;
11
+ const output = usage.output_tokens;
12
+ const cachedInput = details.cached_tokens;
13
+ const cacheWriteInput = details.cache_write_tokens;
14
+ if (!count(input) ||
15
+ !count(output) ||
16
+ (cachedInput !== undefined && !count(cachedInput)) ||
17
+ (cacheWriteInput !== undefined && !count(cacheWriteInput)) ||
18
+ (cachedInput ?? 0) + (cacheWriteInput ?? 0) >
19
+ input)
20
+ return null;
21
+ return {
22
+ input,
23
+ output,
24
+ ...(cachedInput === undefined ? {} : { cachedInput: cachedInput }),
25
+ ...(cacheWriteInput === undefined ? {} : { cacheWriteInput: cacheWriteInput }),
26
+ };
27
+ }
28
+ /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
29
+ export function parseAnalysisResponse(raw) {
30
+ const root = record(raw);
31
+ const usage = usageOf(raw);
32
+ const output = Array.isArray(root.output) ? root.output : [];
33
+ const contents = output
34
+ .filter((item) => record(item).type === "message")
35
+ .flatMap((item) => Array.isArray(record(item).content) ? record(item).content : []);
36
+ const base = { output: null, usage, dispatched: true };
37
+ if (contents.some((item) => record(item).type === "refusal")) {
38
+ return { ...base, status: "refused", errorCode: "refusal" };
39
+ }
40
+ if (root.status === "incomplete")
41
+ return { ...base, status: "incomplete", errorCode: "output_incomplete" };
42
+ if (root.status !== "completed" ||
43
+ output.some((item) => !["message", "reasoning"].includes(String(record(item).type)))) {
44
+ return { ...base, status: "failed", errorCode: "invalid_response" };
45
+ }
46
+ // A single JSON answer is expected. Do not concatenate multiple answer objects or expose a
47
+ // refusal, reasoning item, or incomplete message as the report's prose.
48
+ const messages = output.filter((item) => record(item).type === "message");
49
+ const texts = contents.filter((item) => record(item).type === "output_text");
50
+ if (messages.length !== 1 ||
51
+ record(messages[0]).status !== "completed" ||
52
+ record(messages[0]).role !== "assistant" ||
53
+ texts.length !== 1 ||
54
+ contents.length !== 1 ||
55
+ typeof record(texts[0]).text !== "string") {
56
+ return { ...base, status: "failed", errorCode: "invalid_response" };
57
+ }
58
+ try {
59
+ return {
60
+ ...base,
61
+ status: "completed",
62
+ output: JSON.parse(record(texts[0]).text),
63
+ errorCode: null,
64
+ };
65
+ }
66
+ catch {
67
+ return { ...base, status: "failed", errorCode: "invalid_response" };
68
+ }
69
+ }
70
+ /** One stateless request: no stored response, no tools, the analysis's reasoning effort, strict
71
+ * schema. */
72
+ export const openaiWire = {
73
+ url: () => OPENAI_RESPONSES_URL,
74
+ headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }),
75
+ body: (request) => ({
76
+ model: request.model,
77
+ instructions: request.instructions,
78
+ input: [
79
+ {
80
+ role: "user",
81
+ content: [
82
+ { type: "input_text", text: request.evidence },
83
+ ...request.images.flatMap((image) => [
84
+ { type: "input_text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
85
+ { type: "input_image", image_url: image.dataUrl, detail: "high" },
86
+ ]),
87
+ ],
88
+ },
89
+ ],
90
+ store: false,
91
+ tools: [],
92
+ tool_choice: "none",
93
+ truncation: "disabled",
94
+ service_tier: "default",
95
+ reasoning: { effort: request.reasoningEffort },
96
+ max_output_tokens: request.maxOutputTokens,
97
+ text: {
98
+ format: { type: "json_schema", name: "study_analysis", strict: true, schema: request.schema },
99
+ },
100
+ }),
101
+ parse: parseAnalysisResponse,
102
+ };
@@ -1,4 +1,5 @@
1
1
  import { type Dispatcher } from "undici";
2
+ import type { ReasoningEffort } from "../actors/reasoning-effort.js";
2
3
  /** Deliberately separate from the stateful computer-use actor: one request, no tools or retries. */
3
4
  export interface AnalysisProviderRequest {
4
5
  model: string;
@@ -10,10 +11,13 @@ export interface AnalysisProviderRequest {
10
11
  }[];
11
12
  schema: Record<string, unknown>;
12
13
  maxOutputTokens: number | null;
14
+ /** The effort the analysis runs at (models.ts analysisEffort). The Codex account analyst runs
15
+ * its fixed low effort, which this then names. */
16
+ reasoningEffort: ReasoningEffort;
13
17
  timeoutMs: number;
14
18
  signal?: AbortSignal;
15
19
  }
16
- interface AnalysisTokenUsage {
20
+ export interface AnalysisTokenUsage {
17
21
  input: number;
18
22
  output: number;
19
23
  cachedInput?: number;
@@ -40,10 +44,6 @@ export interface AnalysisProviderResult {
40
44
  protocolAdditions?: readonly string[];
41
45
  }
42
46
  export type AnalysisProvider = (request: AnalysisProviderRequest) => Promise<AnalysisProviderResult>;
43
- /** An input image the provider accepts; the groups are the image type and its base64 bytes. */
44
- export declare const INPUT_IMAGE_DATA_URL: RegExp;
45
- /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
46
- export declare function parseAnalysisResponse(raw: unknown): AnalysisProviderResult;
47
47
  /** The part of fetch this provider uses; undici's fetch and test doubles both satisfy it. */
48
48
  export type AnalysisFetch = (url: string, init: {
49
49
  method: "POST";
@@ -62,9 +62,21 @@ export type AnalysisFetch = (url: string, init: {
62
62
  status: number;
63
63
  body: ReadableStream<Uint8Array> | null;
64
64
  }>;
65
- /** No alternate endpoint or env-derived base URL: evidence and credentials have one destination. */
65
+ /** The analysts humanish calls over HTTP with an API key. Codex runs through its own CLI. */
66
+ export type AnalysisApiVendor = "openai" | "gemini";
67
+ /** One vendor's wire format. The transport sends `body` to `url` with `headers` and gives `parse`
68
+ * the response JSON only after a 2xx status, within the byte cap and the deadline. */
69
+ export interface AnalysisWire {
70
+ /** A fixed endpoint: never derived from env, so evidence and credentials have one destination. */
71
+ url(model: string): string;
72
+ headers(apiKey: string): Record<string, string>;
73
+ body(request: AnalysisProviderRequest): unknown;
74
+ parse(raw: unknown): AnalysisProviderResult;
75
+ }
76
+ /** One request to the vendor's fixed endpoint, never retried and never sent elsewhere. */
66
77
  export declare function createAnalysisProvider(options: {
67
78
  apiKey: string;
79
+ /** Omitted is OpenAI, the analyst humanish has always called. */
80
+ vendor?: AnalysisApiVendor;
68
81
  fetchFn?: AnalysisFetch;
69
82
  }): AnalysisProvider;
70
- export {};
@@ -1,13 +1,17 @@
1
+ // The analysis provider seam and the HTTP transport its API adapters share. Each API vendor owns
2
+ // only its wire format (gemini-wire.ts, openai-wire.ts): the endpoint, the request body, and how a
3
+ // response becomes a result. The transport owns what must hold for every vendor: one fixed endpoint
4
+ // per vendor, request and response byte caps, the caller's wall-clock deadline, no retry, and a
5
+ // closed error vocabulary that never carries provider error prose. The Codex account analyst is
6
+ // the third adapter at the same seam (restricted-codex.ts).
1
7
  import { Agent, fetch as undiciFetch } from "undici";
2
- import { OPENAI_RESPONSES_URL } from "../actors/openai-endpoint.js";
8
+ import { isRecord } from "../run/type-guards.js";
9
+ import { INPUT_IMAGE_DATA_URL } from "./image-tokens.js";
10
+ import { geminiWire } from "./gemini-wire.js";
11
+ import { openaiWire } from "./openai-wire.js";
3
12
  const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
4
13
  const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
5
- /** An input image the provider accepts; the groups are the image type and its base64 bytes. */
6
- export const INPUT_IMAGE_DATA_URL = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/;
7
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
8
- ? value
9
- : {};
10
- const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 1e12;
14
+ const record = (value) => (isRecord(value) ? value : {});
11
15
  /** Undici's fetch has a separate response-header deadline. The caller's wall-clock
12
16
  * deadline must govern this one request, including uploads and body reads, without
13
17
  * changing transport behavior for other requests in the process. The agent is used with
@@ -41,72 +45,14 @@ function networkErrorCode(error) {
41
45
  }
42
46
  return "provider_network_error";
43
47
  }
44
- function usageOf(raw) {
45
- const usage = record(record(raw).usage);
46
- const details = record(usage.input_tokens_details);
47
- const input = usage.input_tokens;
48
- const output = usage.output_tokens;
49
- const cachedInput = details.cached_tokens;
50
- const cacheWriteInput = details.cache_write_tokens;
51
- if (!count(input) ||
52
- !count(output) ||
53
- (cachedInput !== undefined && !count(cachedInput)) ||
54
- (cacheWriteInput !== undefined && !count(cacheWriteInput)) ||
55
- (cachedInput ?? 0) + (cacheWriteInput ?? 0) >
56
- input)
57
- return null;
58
- return {
59
- input,
60
- output,
61
- ...(cachedInput === undefined ? {} : { cachedInput: cachedInput }),
62
- ...(cacheWriteInput === undefined ? {} : { cacheWriteInput: cacheWriteInput }),
63
- };
64
- }
65
- /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
66
- export function parseAnalysisResponse(raw) {
67
- const root = record(raw);
68
- const usage = usageOf(raw);
69
- const output = Array.isArray(root.output) ? root.output : [];
70
- const contents = output
71
- .filter((item) => record(item).type === "message")
72
- .flatMap((item) => Array.isArray(record(item).content) ? record(item).content : []);
73
- const base = { output: null, usage, dispatched: true };
74
- if (contents.some((item) => record(item).type === "refusal")) {
75
- return { ...base, status: "refused", errorCode: "refusal" };
76
- }
77
- if (root.status === "incomplete")
78
- return { ...base, status: "incomplete", errorCode: "output_incomplete" };
79
- if (root.status !== "completed" ||
80
- output.some((item) => !["message", "reasoning"].includes(String(record(item).type)))) {
81
- return { ...base, status: "failed", errorCode: "invalid_response" };
82
- }
83
- // A single JSON answer is expected. Do not concatenate multiple answer objects or expose a
84
- // refusal, reasoning item, or incomplete message as the report's prose.
85
- const messages = output.filter((item) => record(item).type === "message");
86
- const texts = contents.filter((item) => record(item).type === "output_text");
87
- if (messages.length !== 1 ||
88
- record(messages[0]).status !== "completed" ||
89
- record(messages[0]).role !== "assistant" ||
90
- texts.length !== 1 ||
91
- contents.length !== 1 ||
92
- typeof record(texts[0]).text !== "string") {
93
- return { ...base, status: "failed", errorCode: "invalid_response" };
94
- }
95
- try {
96
- return {
97
- ...base,
98
- status: "completed",
99
- output: JSON.parse(record(texts[0]).text),
100
- errorCode: null,
101
- };
102
- }
103
- catch {
104
- return { ...base, status: "failed", errorCode: "invalid_response" };
105
- }
106
- }
107
- /** No alternate endpoint or env-derived base URL: evidence and credentials have one destination. */
48
+ const WIRES = {
49
+ openai: openaiWire,
50
+ gemini: geminiWire,
51
+ };
52
+ /** One request to the vendor's fixed endpoint, never retried and never sent elsewhere. */
108
53
  export function createAnalysisProvider(options) {
109
54
  const fetchFn = options.fetchFn ?? undiciFetch;
55
+ const wire = WIRES[options.vendor ?? "openai"];
110
56
  return async (request) => {
111
57
  const failure = (errorCode, dispatched, status = "failed") => ({
112
58
  status,
@@ -130,33 +76,7 @@ export function createAnalysisProvider(options) {
130
76
  request.images.some((image) => !INPUT_IMAGE_DATA_URL.test(image.dataUrl))) {
131
77
  return failure("invalid_request", false);
132
78
  }
133
- const content = [
134
- { type: "input_text", text: request.evidence },
135
- ...request.images.flatMap((image) => [
136
- { type: "input_text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
137
- { type: "input_image", image_url: image.dataUrl, detail: "high" },
138
- ]),
139
- ];
140
- const body = JSON.stringify({
141
- model: request.model,
142
- instructions: request.instructions,
143
- input: [{ role: "user", content }],
144
- store: false,
145
- tools: [],
146
- tool_choice: "none",
147
- truncation: "disabled",
148
- service_tier: "default",
149
- reasoning: { effort: "high" },
150
- max_output_tokens: request.maxOutputTokens,
151
- text: {
152
- format: {
153
- type: "json_schema",
154
- name: "study_analysis",
155
- strict: true,
156
- schema: request.schema,
157
- },
158
- },
159
- });
79
+ const body = JSON.stringify(wire.body(request));
160
80
  if (Buffer.byteLength(body) > MAX_REQUEST_BYTES)
161
81
  return failure("invalid_request", false);
162
82
  const controller = new AbortController();
@@ -173,11 +93,11 @@ export function createAnalysisProvider(options) {
173
93
  if (request.signal?.aborted)
174
94
  return failure("cancelled", false, "cancelled");
175
95
  dispatched = true;
176
- const response = await fetchFn(OPENAI_RESPONSES_URL, {
96
+ const response = await fetchFn(wire.url(request.model), {
177
97
  method: "POST",
178
98
  redirect: "error",
179
99
  signal: controller.signal,
180
- headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
100
+ headers: wire.headers(options.apiKey),
181
101
  body,
182
102
  dispatcher,
183
103
  timeout: false,
@@ -211,7 +131,7 @@ export function createAnalysisProvider(options) {
211
131
  if (controller.signal.aborted)
212
132
  return failure(timedOut ? "timeout" : "cancelled", true, timedOut ? "timed_out" : "cancelled");
213
133
  try {
214
- return parseAnalysisResponse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
134
+ return wire.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
215
135
  }
216
136
  catch {
217
137
  return failure("invalid_response", true);
@@ -15,5 +15,8 @@ export declare function checkProviderAnalysis(input: AnalysisInput, value: unkno
15
15
  * each response as its own request, since the long-context tier re-prices one request at a time.
16
16
  */
17
17
  export declare function recordProviderUsage(artifact: AnalysisArtifact, config: AnalysisConfig, responses: readonly AnalysisProviderResult[]): void;
18
+ /** The HTTP status an API analyst answered, for the attempt's warnings. The status code is the only
19
+ * part of the answer kept: the provider's error text may echo evidence or credentials. */
20
+ export declare function httpStatusWarning(config: AnalysisConfig, response: AnalysisProviderResult): string | undefined;
18
21
  /** The warnings a Codex response carries about its CLI release and protocol. */
19
22
  export declare function codexWarnings(config: AnalysisConfig, response: AnalysisProviderResult): string[];