tinker-agent 1.0.65 → 1.2.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 (208) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +90 -9
  5. package/bin/tinker.js +39 -0
  6. package/node_modules/ansi-regex/index.d.ts +33 -0
  7. package/node_modules/ansi-regex/index.js +14 -0
  8. package/node_modules/ansi-regex/license +9 -0
  9. package/node_modules/ansi-regex/package.json +61 -0
  10. package/node_modules/ansi-regex/readme.md +66 -0
  11. package/node_modules/ansi-styles/index.d.ts +236 -0
  12. package/node_modules/ansi-styles/index.js +223 -0
  13. package/node_modules/ansi-styles/license +9 -0
  14. package/node_modules/ansi-styles/package.json +54 -0
  15. package/node_modules/ansi-styles/readme.md +173 -0
  16. package/node_modules/chalk/license +9 -0
  17. package/node_modules/chalk/package.json +83 -0
  18. package/node_modules/chalk/readme.md +297 -0
  19. package/node_modules/chalk/source/index.d.ts +325 -0
  20. package/node_modules/chalk/source/index.js +225 -0
  21. package/node_modules/chalk/source/utilities.js +33 -0
  22. package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  23. package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  24. package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  25. package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  26. package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  27. package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  28. package/node_modules/character-entities/index.d.ts +6 -0
  29. package/node_modules/character-entities/index.js +2132 -0
  30. package/node_modules/character-entities/license +22 -0
  31. package/node_modules/character-entities/package.json +78 -0
  32. package/node_modules/character-entities/readme.md +152 -0
  33. package/node_modules/decode-named-character-reference/index.d.ts +13 -0
  34. package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
  35. package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
  36. package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
  37. package/node_modules/decode-named-character-reference/index.dom.js +32 -0
  38. package/node_modules/decode-named-character-reference/index.js +19 -0
  39. package/node_modules/decode-named-character-reference/license +22 -0
  40. package/node_modules/decode-named-character-reference/package.json +90 -0
  41. package/node_modules/decode-named-character-reference/readme.md +136 -0
  42. package/node_modules/get-east-asian-width/index.d.ts +60 -0
  43. package/node_modules/get-east-asian-width/index.js +30 -0
  44. package/node_modules/get-east-asian-width/license +9 -0
  45. package/node_modules/get-east-asian-width/lookup-data.js +21 -0
  46. package/node_modules/get-east-asian-width/lookup.js +138 -0
  47. package/node_modules/get-east-asian-width/package.json +71 -0
  48. package/node_modules/get-east-asian-width/readme.md +65 -0
  49. package/node_modules/get-east-asian-width/utilities.js +24 -0
  50. package/node_modules/has-flag/index.d.ts +39 -0
  51. package/node_modules/has-flag/index.js +8 -0
  52. package/node_modules/has-flag/license +9 -0
  53. package/node_modules/has-flag/package.json +49 -0
  54. package/node_modules/has-flag/readme.md +74 -0
  55. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  56. package/node_modules/is-fullwidth-code-point/index.js +12 -0
  57. package/node_modules/is-fullwidth-code-point/license +9 -0
  58. package/node_modules/is-fullwidth-code-point/package.json +53 -0
  59. package/node_modules/is-fullwidth-code-point/readme.md +31 -0
  60. package/node_modules/markdansi/LICENSE +21 -0
  61. package/node_modules/markdansi/README.md +167 -0
  62. package/node_modules/markdansi/dist/ast.d.ts +99 -0
  63. package/node_modules/markdansi/dist/ast.js +1 -0
  64. package/node_modules/markdansi/dist/cli.d.ts +17 -0
  65. package/node_modules/markdansi/dist/cli.js +208 -0
  66. package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
  67. package/node_modules/markdansi/dist/hyperlink.js +26 -0
  68. package/node_modules/markdansi/dist/index.d.ts +10 -0
  69. package/node_modules/markdansi/dist/index.js +16 -0
  70. package/node_modules/markdansi/dist/parser.d.ts +2 -0
  71. package/node_modules/markdansi/dist/parser.js +205 -0
  72. package/node_modules/markdansi/dist/render.d.ts +9 -0
  73. package/node_modules/markdansi/dist/render.js +680 -0
  74. package/node_modules/markdansi/dist/stream.d.ts +39 -0
  75. package/node_modules/markdansi/dist/stream.js +192 -0
  76. package/node_modules/markdansi/dist/theme.d.ts +18 -0
  77. package/node_modules/markdansi/dist/theme.js +105 -0
  78. package/node_modules/markdansi/dist/types.d.ts +58 -0
  79. package/node_modules/markdansi/dist/types.js +1 -0
  80. package/node_modules/markdansi/dist/wrap.d.ts +10 -0
  81. package/node_modules/markdansi/dist/wrap.js +73 -0
  82. package/node_modules/markdansi/docs/spec.md +110 -0
  83. package/node_modules/markdansi/package.json +78 -0
  84. package/node_modules/markdansi/tsconfig.json +18 -0
  85. package/node_modules/marked/LICENSE +44 -0
  86. package/node_modules/marked/README.md +115 -0
  87. package/node_modules/marked/bin/main.js +284 -0
  88. package/node_modules/marked/bin/marked.js +16 -0
  89. package/node_modules/marked/lib/marked.d.ts +760 -0
  90. package/node_modules/marked/lib/marked.esm.js +77 -0
  91. package/node_modules/marked/lib/marked.esm.js.map +7 -0
  92. package/node_modules/marked/lib/marked.umd.js +79 -0
  93. package/node_modules/marked/lib/marked.umd.js.map +7 -0
  94. package/node_modules/marked/man/marked.1 +113 -0
  95. package/node_modules/marked/man/marked.1.md +93 -0
  96. package/node_modules/marked/package.json +103 -0
  97. package/node_modules/slice-ansi/index.d.ts +19 -0
  98. package/node_modules/slice-ansi/index.js +317 -0
  99. package/node_modules/slice-ansi/license +10 -0
  100. package/node_modules/slice-ansi/package.json +59 -0
  101. package/node_modules/slice-ansi/readme.md +55 -0
  102. package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
  103. package/node_modules/string-width/index.d.ts +39 -0
  104. package/node_modules/string-width/index.js +203 -0
  105. package/node_modules/string-width/license +9 -0
  106. package/node_modules/string-width/package.json +65 -0
  107. package/node_modules/string-width/readme.md +66 -0
  108. package/node_modules/strip-ansi/index.d.ts +15 -0
  109. package/node_modules/strip-ansi/index.js +19 -0
  110. package/node_modules/strip-ansi/license +9 -0
  111. package/node_modules/strip-ansi/package.json +59 -0
  112. package/node_modules/strip-ansi/readme.md +37 -0
  113. package/node_modules/supports-color/browser.d.ts +1 -0
  114. package/node_modules/supports-color/browser.js +35 -0
  115. package/node_modules/supports-color/index.d.ts +55 -0
  116. package/node_modules/supports-color/index.js +202 -0
  117. package/node_modules/supports-color/license +9 -0
  118. package/node_modules/supports-color/package.json +64 -0
  119. package/node_modules/supports-color/readme.md +75 -0
  120. package/node_modules/supports-hyperlinks/browser.js +10 -0
  121. package/node_modules/supports-hyperlinks/index.d.ts +25 -0
  122. package/node_modules/supports-hyperlinks/index.js +163 -0
  123. package/node_modules/supports-hyperlinks/license +10 -0
  124. package/node_modules/supports-hyperlinks/package.json +47 -0
  125. package/node_modules/supports-hyperlinks/readme.md +45 -0
  126. package/package.json +41 -10
  127. package/patches/markdansi@0.3.2.patch +22 -2
  128. package/src/agent/context-builder.ts +24 -10
  129. package/src/agent/context-meter.ts +89 -5
  130. package/src/agent/loop.ts +107 -10
  131. package/src/agent/runtime-session.ts +1437 -82
  132. package/src/agent/session-ledger.ts +169 -29
  133. package/src/agent/types.ts +12 -1
  134. package/src/agent/user-prompt-projection.ts +62 -0
  135. package/src/cli/config.ts +57 -14
  136. package/src/cli/model-profiles.ts +124 -0
  137. package/src/cli/run-runner.ts +4 -1
  138. package/src/cli/tui-runner.tsx +68 -30
  139. package/src/context/compiled-context-hash.ts +1 -1
  140. package/src/context/compiled-context-validator.ts +92 -15
  141. package/src/context/context-automation-policy.ts +121 -0
  142. package/src/context/context-manager.ts +390 -11
  143. package/src/context/context-policy.ts +8 -0
  144. package/src/context/context-protocol-validator.ts +11 -1
  145. package/src/context/context-revision-compiler.ts +190 -27
  146. package/src/context/context-revision.ts +105 -19
  147. package/src/context/context-surface.ts +329 -0
  148. package/src/context/prefix-retirement-planner.ts +570 -0
  149. package/src/context/protocol-frame.ts +30 -3
  150. package/src/context/recall-retirement-contract.ts +27 -0
  151. package/src/context/swap-planner.ts +34 -10
  152. package/src/events/observation-text-log.ts +3 -3
  153. package/src/events/stdout-event-printer.ts +39 -3
  154. package/src/events/types.ts +172 -35
  155. package/src/ids/runtime-id.ts +6 -0
  156. package/src/ids/uuid-v7.ts +7 -0
  157. package/src/image/image-asset-store.ts +319 -0
  158. package/src/image/image-input-policy.ts +14 -0
  159. package/src/image/image-probe.ts +233 -0
  160. package/src/image/image-types.ts +262 -0
  161. package/src/instructions/project-instructions.ts +4 -1
  162. package/src/mcp/mcp-manager.ts +83 -19
  163. package/src/model/fake-model-client.ts +9 -1
  164. package/src/model/input-token-estimator.ts +25 -0
  165. package/src/model/model-client.ts +60 -0
  166. package/src/model/model-request-preflight.ts +1 -0
  167. package/src/model/moonshot-input-token-estimator.ts +107 -0
  168. package/src/model/openai-chat-mapping.ts +77 -1
  169. package/src/model/openai-chat-model-client.ts +389 -59
  170. package/src/model/openai-chat-stream.ts +272 -0
  171. package/src/model/prompt-prefix-hash.ts +2 -4
  172. package/src/model/token-estimator.ts +4 -0
  173. package/src/observation/observation-builder.ts +29 -0
  174. package/src/session/resume-projection.ts +94 -34
  175. package/src/session/session-catalog.ts +4 -4
  176. package/src/session/session-errors.ts +1 -1
  177. package/src/session/session-history-reader.ts +151 -4
  178. package/src/session/session-last-response-reader.ts +189 -0
  179. package/src/session/session-schema.ts +349 -35
  180. package/src/session/session-store.ts +3266 -255
  181. package/src/session/sqlite-session-ledger.ts +9 -4
  182. package/src/skills/skill-catalog.ts +182 -0
  183. package/src/skills/skill-context.ts +364 -0
  184. package/src/skills/skill-loader.ts +779 -0
  185. package/src/skills/skill-tool.ts +297 -0
  186. package/src/tools/edit.ts +51 -64
  187. package/src/tools/ensure-parent-directory.ts +6 -0
  188. package/src/tools/read.ts +2 -2
  189. package/src/tools/recall.ts +43 -40
  190. package/src/tools/registry.ts +24 -3
  191. package/src/tools/ripgrep.ts +3 -2
  192. package/src/tools/types.ts +43 -4
  193. package/src/tools/write.ts +20 -31
  194. package/src/tui/app.tsx +333 -96
  195. package/src/tui/clipboard.ts +5 -0
  196. package/src/tui/components/mcp-panel.tsx +28 -0
  197. package/src/tui/components/prompt-input.tsx +790 -257
  198. package/src/tui/components/skills-panel.tsx +31 -0
  199. package/src/tui/components/timeline.tsx +30 -1
  200. package/src/tui/event-store.ts +85 -2
  201. package/src/tui/file-mention.ts +234 -0
  202. package/src/tui/line-editor.ts +1 -0
  203. package/src/tui/project-slash-commands.ts +229 -0
  204. package/src/tui/prompt-draft.ts +510 -0
  205. package/src/tui/prompt-history.ts +294 -22
  206. package/src/tui/slash-commands.ts +46 -2
  207. package/src/tui/tui-session-controller.ts +110 -48
  208. package/src/tui/workspace-file-search.ts +94 -0
@@ -1,32 +1,54 @@
1
1
  import OpenAI from "openai";
2
- import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions";
3
- import type { AssistantMessage } from "../agent/types";
2
+ import type {
3
+ ChatCompletionCreateParamsNonStreaming,
4
+ ChatCompletionCreateParamsStreaming,
5
+ } from "openai/resources/chat/completions";
6
+ import type { AssistantMessage, UserMessage } from "../agent/types";
7
+ import {
8
+ IMAGE_INPUT_POLICY,
9
+ IMAGE_INPUT_POLICY_VERSION,
10
+ } from "../image/image-input-policy";
11
+ import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
4
12
  import type { ModelContextBudget } from "./model-context-profile";
13
+ import type { InputTokenEstimator } from "./input-token-estimator";
14
+ import { ModelRequestMediaAggregateError } from "./model-client";
5
15
  import type {
16
+ MaterializedModelRequest,
6
17
  ModelClient,
18
+ ModelMaterializeOptions,
19
+ ModelMessageProtocol,
7
20
  ModelRequestInput,
8
21
  ModelRequestOptions,
9
22
  ModelRequestOutput,
23
+ PreparedMediaDescriptor,
10
24
  PreparedModelRequest,
11
25
  PreparedPromptSegment,
12
26
  } from "./model-client";
13
27
  import {
14
28
  fromOpenAIChatCompletion,
29
+ parseImageAssetUrlMarker,
15
30
  toOpenAIChatMessages,
16
31
  toOpenAIChatTools,
17
32
  } from "./openai-chat-mapping";
18
- import {
19
- canonicalJsonValue,
20
- sha256,
21
- stableJsonStringify,
22
- } from "./model-request-preflight";
33
+ import { accumulateOpenAIChatCompletionChunks } from "./openai-chat-stream";
34
+ import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
35
+ import { sha256, stableJsonStringify } from "./model-request-preflight";
23
36
 
24
- const OPENAI_CHAT_SERIALIZATION_VERSION = "openai-chat-v1";
37
+ const OPENAI_CHAT_SERIALIZATION_VERSION = "openai-chat-v2";
38
+ const OPENAI_CHAT_TIMEOUT_MS = 30 * 60 * 1_000;
25
39
 
26
40
  export class OpenAIChatModelClient implements ModelClient {
41
+ readonly messageProtocol: ModelMessageProtocol = Object.freeze({
42
+ adapter: "openai-chat",
43
+ serializationVersion: OPENAI_CHAT_SERIALIZATION_VERSION,
44
+ });
45
+ readonly inputTokenEstimator?: InputTokenEstimator;
27
46
  private readonly client: OpenAI;
28
47
  private readonly preparedRequests = new WeakSet<object>();
48
+ private readonly materializedRequests = new WeakSet<object>();
29
49
  private readonly provider: string;
50
+ private readonly stream: boolean;
51
+ readonly inputModalities: readonly ("text" | "image")[];
30
52
 
31
53
  constructor(
32
54
  private readonly options: {
@@ -34,19 +56,48 @@ export class OpenAIChatModelClient implements ModelClient {
34
56
  contextBudget: ModelContextBudget;
35
57
  baseURL?: string;
36
58
  includeReasoningContent?: boolean;
59
+ inputModalities?: readonly ("text" | "image")[];
60
+ tokenEstimator?: {
61
+ kind: "moonshot-estimate-token-count-v1";
62
+ model: string;
63
+ apiBase: string;
64
+ apiKey: string;
65
+ timeoutMs: number;
66
+ maxRetries: 0;
67
+ };
37
68
  model: string;
38
69
  providerName?: string;
70
+ stream?: boolean;
39
71
  timeoutMs?: number;
40
72
  fetch?: typeof fetch;
41
73
  },
42
74
  ) {
43
75
  this.provider = options.providerName ?? "openai-compatible";
76
+ this.stream = options.stream ?? true;
77
+ this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
78
+ const supportsImages = this.inputModalities.includes("image");
79
+ if (!this.inputModalities.includes("text")) {
80
+ throw new Error('OpenAI chat input modalities must include "text".');
81
+ }
82
+ if (supportsImages && options.tokenEstimator === undefined) {
83
+ throw new Error("Image-capable OpenAI chat requires a token estimator.");
84
+ }
44
85
  this.client = new OpenAI({
45
86
  apiKey: options.apiKey,
46
87
  baseURL: options.baseURL,
47
- timeout: options.timeoutMs,
88
+ timeout: options.timeoutMs ?? OPENAI_CHAT_TIMEOUT_MS,
89
+ ...(supportsImages ? { maxRetries: 0 } : {}),
48
90
  fetch: options.fetch,
49
91
  });
92
+ if (options.tokenEstimator !== undefined) {
93
+ this.inputTokenEstimator = new MoonshotInputTokenEstimator({
94
+ apiKey: options.tokenEstimator.apiKey,
95
+ baseURL: options.tokenEstimator.apiBase,
96
+ model: options.tokenEstimator.model,
97
+ timeoutMs: options.tokenEstimator.timeoutMs,
98
+ fetch: options.fetch,
99
+ });
100
+ }
50
101
  }
51
102
 
52
103
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -54,35 +105,55 @@ export class OpenAIChatModelClient implements ModelClient {
54
105
  includeReasoningContent: this.options.includeReasoningContent,
55
106
  });
56
107
  const tools = input.tools.length > 0 ? toOpenAIChatTools(input.tools) : undefined;
57
- const payload = deepFreeze(
58
- canonicalJsonValue({
59
- model: this.options.model,
60
- messages,
61
- tools,
62
- tool_choice: tools === undefined ? undefined : "auto",
63
- max_tokens: this.options.contextBudget.requestMaxOutputTokens,
64
- }),
65
- ) as ChatCompletionCreateParamsNonStreaming;
66
- const toolSegments = (payload.tools ?? []).map(
108
+ const payload = deepFreeze({
109
+ model: this.options.model,
110
+ messages,
111
+ ...(tools === undefined ? {} : { tools, tool_choice: "auto" as const }),
112
+ max_completion_tokens: this.options.contextBudget.requestMaxOutputTokens,
113
+ ...(this.stream
114
+ ? {
115
+ stream: true as const,
116
+ stream_options: { include_usage: true as const },
117
+ }
118
+ : {}),
119
+ });
120
+ const toolSegments = (tools ?? []).map(
67
121
  (tool): PreparedPromptSegment => ({
68
122
  kind: "tool_schema",
69
123
  normalizedText: stableJsonStringify(tool),
70
124
  }),
71
125
  );
72
- const messageSegments = payload.messages.map(
73
- (message, index): PreparedPromptSegment => ({
74
- kind: segmentKind(input.messages[index]?.role),
75
- normalizedText: stableJsonStringify(message),
76
- }),
126
+ const messageSegments = input.messages.map(
127
+ (message, index): PreparedPromptSegment =>
128
+ message.role === "user" && message.attachments !== undefined
129
+ ? imageUserSegment(message)
130
+ : {
131
+ kind: segmentKind(message.role),
132
+ normalizedText: stableJsonStringify(messages[index]),
133
+ },
134
+ );
135
+ const mediaOccurrenceCount = messageSegments.reduce(
136
+ (total, segment) => total + (segment.media?.length ?? 0),
137
+ 0,
77
138
  );
78
139
  const requestConfigHash = sha256(
79
140
  stableJsonStringify({
80
- provider: this.provider,
81
- baseURL: nonSecretBaseUrl(this.options.baseURL),
141
+ adapter: this.messageProtocol.adapter,
142
+ serializationVersion: this.messageProtocol.serializationVersion,
143
+ endpoint: normalizedEndpointPolicy(this.options.baseURL),
82
144
  model: this.options.model,
83
- serializationVersion: OPENAI_CHAT_SERIALIZATION_VERSION,
84
145
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
85
146
  includeReasoningContent: this.options.includeReasoningContent === true,
147
+ stream: this.stream,
148
+ inputModalities: this.inputModalities,
149
+ requestPolicy: { toolChoice: "auto" },
150
+ imagePolicy: {
151
+ version: IMAGE_INPUT_POLICY_VERSION,
152
+ ...IMAGE_INPUT_POLICY,
153
+ },
154
+ ...(this.inputTokenEstimator === undefined
155
+ ? {}
156
+ : { tokenEstimator: this.inputTokenEstimator.compatibility }),
86
157
  }),
87
158
  );
88
159
  const prepared: PreparedModelRequest = {
@@ -95,6 +166,7 @@ export class OpenAIChatModelClient implements ModelClient {
95
166
  toolSegments.map((segment) => segment.normalizedText).join("\n"),
96
167
  ),
97
168
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
169
+ mediaOccurrenceCount,
98
170
  assistantReplaySegments: (message) => this.assistantReplaySegments(message),
99
171
  };
100
172
  Object.freeze(prepared);
@@ -102,30 +174,77 @@ export class OpenAIChatModelClient implements ModelClient {
102
174
  return prepared;
103
175
  }
104
176
 
105
- async request(
177
+ async materialize(
106
178
  prepared: PreparedModelRequest,
107
- options: ModelRequestOptions,
108
- ): Promise<ModelRequestOutput> {
109
- if (!this.preparedRequests.has(prepared)) {
110
- throw new Error(
111
- `OpenAI chat request was not prepared by this client (provider=${this.provider}, model=${this.options.model}).`,
179
+ options: ModelMaterializeOptions,
180
+ ): Promise<MaterializedModelRequest> {
181
+ this.assertPrepared(prepared);
182
+ options.signal.throwIfAborted();
183
+ if (prepared.mediaOccurrenceCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
184
+ throw new ModelRequestMediaAggregateError(
185
+ `Model request has ${prepared.mediaOccurrenceCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
112
186
  );
113
187
  }
114
- if (
115
- prepared.provider !== this.provider ||
116
- prepared.model !== this.options.model ||
117
- prepared.requestMaxOutputTokens !==
118
- this.options.contextBudget.requestMaxOutputTokens
119
- ) {
120
- throw new Error(
121
- "Prepared OpenAI chat request configuration does not match client.",
122
- );
188
+ if (prepared.mediaOccurrenceCount > 0 && !this.inputModalities.includes("image")) {
189
+ throw new Error("Current model profile does not support image input.");
190
+ }
191
+
192
+ const assets = distinctPreparedAssets(prepared.promptSegments);
193
+ const lowerLengths = new Map<ImageAssetId, number>();
194
+ for (const asset of assets.values()) {
195
+ lowerLengths.set(asset.assetId, dataUrlLength(asset));
123
196
  }
197
+ const markerCount = countImageMarkers(prepared.payload);
198
+ if (markerCount !== prepared.mediaOccurrenceCount) {
199
+ throw new Error("Prepared image marker count does not match media descriptors.");
200
+ }
201
+ const lowerBodyBytes = exactJsonBodyBytes(prepared.payload, lowerLengths);
202
+ assertBodyLimit(lowerBodyBytes, prepared.mediaOccurrenceCount);
124
203
 
125
- const response = await this.client.chat.completions.create(
126
- prepared.payload as ChatCompletionCreateParamsNonStreaming,
127
- { signal: options.signal },
204
+ const dataUrls = new Map<ImageAssetId, string>();
205
+ for (const asset of assets.values()) {
206
+ options.signal.throwIfAborted();
207
+ const bytes = await options.assetStore.readVerified(asset, {
208
+ signal: options.signal,
209
+ });
210
+ dataUrls.set(
211
+ asset.assetId,
212
+ `data:${asset.mimeType};base64,${bytes.toString("base64")}`,
213
+ );
214
+ await yieldToEventLoop();
215
+ }
216
+ options.signal.throwIfAborted();
217
+ const exactLengths = new Map(
218
+ [...dataUrls].map(([assetId, value]) => [assetId, value.length] as const),
128
219
  );
220
+ const bodyBytes = exactJsonBodyBytes(prepared.payload, exactLengths);
221
+ assertBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
222
+ const payload = deepFreeze(materializePayload(prepared.payload, dataUrls));
223
+ const materialized = Object.freeze({ ...prepared, payload, bodyBytes });
224
+ this.materializedRequests.add(materialized);
225
+ return materialized;
226
+ }
227
+
228
+ async request(
229
+ prepared: PreparedModelRequest,
230
+ options: ModelRequestOptions,
231
+ ): Promise<ModelRequestOutput> {
232
+ this.assertPrepared(prepared);
233
+ if (prepared.mediaOccurrenceCount > 0 && !this.materializedRequests.has(prepared)) {
234
+ throw new Error("Image request must be materialized before provider dispatch.");
235
+ }
236
+
237
+ let response;
238
+ try {
239
+ response = this.stream
240
+ ? await this.requestStreaming(prepared, options.signal)
241
+ : await this.client.chat.completions.create(
242
+ prepared.payload as ChatCompletionCreateParamsNonStreaming,
243
+ { signal: options.signal },
244
+ );
245
+ } catch (error) {
246
+ throw sanitizedProviderError(error);
247
+ }
129
248
 
130
249
  return fromOpenAIChatCompletion(response, {
131
250
  identity: options.identity,
@@ -134,6 +253,24 @@ export class OpenAIChatModelClient implements ModelClient {
134
253
  });
135
254
  }
136
255
 
256
+ private async requestStreaming(
257
+ prepared: PreparedModelRequest,
258
+ signal: AbortSignal,
259
+ ): Promise<Record<string, unknown>> {
260
+ const stream = await this.client.chat.completions.create(
261
+ prepared.payload as ChatCompletionCreateParamsStreaming,
262
+ { signal },
263
+ );
264
+ const chunks: unknown[] = [];
265
+ for await (const chunk of stream) {
266
+ chunks.push(chunk);
267
+ }
268
+ return accumulateOpenAIChatCompletionChunks(chunks, {
269
+ provider: this.provider,
270
+ model: this.options.model,
271
+ });
272
+ }
273
+
137
274
  private assistantReplaySegments(message: AssistantMessage): PreparedPromptSegment[] {
138
275
  const [mapped] = toOpenAIChatMessages([message], {
139
276
  includeReasoningContent: this.options.includeReasoningContent,
@@ -144,14 +281,206 @@ export class OpenAIChatModelClient implements ModelClient {
144
281
  return [
145
282
  {
146
283
  kind: "assistant",
147
- normalizedText: stableJsonStringify(canonicalJsonValue(mapped)),
284
+ normalizedText: stableJsonStringify(mapped),
148
285
  },
149
286
  ];
150
287
  }
288
+
289
+ private assertPrepared(prepared: PreparedModelRequest): void {
290
+ if (
291
+ !this.preparedRequests.has(prepared) &&
292
+ !this.materializedRequests.has(prepared)
293
+ ) {
294
+ throw new Error(
295
+ `OpenAI chat request was not prepared by this client (provider=${this.provider}, model=${this.options.model}).`,
296
+ );
297
+ }
298
+ if (
299
+ prepared.provider !== this.provider ||
300
+ prepared.model !== this.options.model ||
301
+ prepared.requestMaxOutputTokens !==
302
+ this.options.contextBudget.requestMaxOutputTokens
303
+ ) {
304
+ throw new Error(
305
+ "Prepared OpenAI chat request configuration does not match client.",
306
+ );
307
+ }
308
+ }
309
+ }
310
+
311
+ export function exactJsonBodyBytes(
312
+ value: unknown,
313
+ imageDataUrlLengths: ReadonlyMap<ImageAssetId, number>,
314
+ ): number {
315
+ const assetId = parseImageAssetUrlMarker(value);
316
+ if (assetId !== undefined) {
317
+ const length = imageDataUrlLengths.get(assetId);
318
+ if (!Number.isSafeInteger(length) || length === undefined || length < 1) {
319
+ throw new Error(
320
+ `Missing materialized length for image ${assetId.slice(0, 12)}….`,
321
+ );
322
+ }
323
+ return length + 2;
324
+ }
325
+ if (
326
+ value === null ||
327
+ typeof value === "string" ||
328
+ typeof value === "number" ||
329
+ typeof value === "boolean"
330
+ ) {
331
+ const serialized: unknown = JSON.stringify(value);
332
+ if (typeof serialized !== "string") {
333
+ throw new Error("JSON primitive did not serialize to a string.");
334
+ }
335
+ return Buffer.byteLength(serialized, "utf8");
336
+ }
337
+ if (Array.isArray(value)) {
338
+ const entries: readonly unknown[] = value;
339
+ return (
340
+ 2 +
341
+ Math.max(0, entries.length - 1) +
342
+ entries.reduce<number>(
343
+ (total, entry) => total + exactJsonBodyBytes(entry, imageDataUrlLengths),
344
+ 0,
345
+ )
346
+ );
347
+ }
348
+ if (typeof value !== "object" || value === undefined) {
349
+ throw new Error(`Cannot size non-JSON value of type ${typeof value}.`);
350
+ }
351
+ const entries = Object.entries(value as Record<string, unknown>).filter(
352
+ ([, entry]) => entry !== undefined,
353
+ );
354
+ return (
355
+ 2 +
356
+ Math.max(0, entries.length - 1) +
357
+ entries.reduce(
358
+ (total, [key, entry]) =>
359
+ total +
360
+ Buffer.byteLength(JSON.stringify(key), "utf8") +
361
+ 1 +
362
+ exactJsonBodyBytes(entry, imageDataUrlLengths),
363
+ 0,
364
+ )
365
+ );
366
+ }
367
+
368
+ function imageUserSegment(message: UserMessage): PreparedPromptSegment {
369
+ const media = message.attachments!.map(
370
+ (attachment): PreparedMediaDescriptor =>
371
+ Object.freeze({
372
+ assetId: attachment.assetId,
373
+ label: attachment.label,
374
+ range: Object.freeze({ ...attachment.range }),
375
+ mimeType: attachment.mimeType,
376
+ byteLength: attachment.byteLength,
377
+ width: attachment.width,
378
+ height: attachment.height,
379
+ planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
380
+ }),
381
+ );
382
+ return Object.freeze({
383
+ kind: "user",
384
+ normalizedText: message.content,
385
+ media: Object.freeze(media),
386
+ });
387
+ }
388
+
389
+ function distinctPreparedAssets(
390
+ segments: readonly PreparedPromptSegment[],
391
+ ): Map<ImageAssetId, ImageAssetRef> {
392
+ const assets = new Map<ImageAssetId, ImageAssetRef>();
393
+ for (const segment of segments) {
394
+ for (const media of segment.media ?? []) {
395
+ const asset = Object.freeze({
396
+ assetId: media.assetId,
397
+ mimeType: media.mimeType,
398
+ byteLength: media.byteLength,
399
+ width: media.width,
400
+ height: media.height,
401
+ });
402
+ const existing = assets.get(media.assetId);
403
+ if (
404
+ existing !== undefined &&
405
+ stableJsonStringify(existing) !== stableJsonStringify(asset)
406
+ ) {
407
+ throw new Error(`Conflicting descriptors for image ${media.assetId}.`);
408
+ }
409
+ assets.set(media.assetId, asset);
410
+ }
411
+ }
412
+ return assets;
413
+ }
414
+
415
+ function materializePayload(
416
+ value: unknown,
417
+ dataUrls: ReadonlyMap<ImageAssetId, string>,
418
+ ): unknown {
419
+ const assetId = parseImageAssetUrlMarker(value);
420
+ if (assetId !== undefined) {
421
+ const dataUrl = dataUrls.get(assetId);
422
+ if (dataUrl === undefined) {
423
+ throw new Error(`Image ${assetId.slice(0, 12)}… was not materialized.`);
424
+ }
425
+ return dataUrl;
426
+ }
427
+ if (Array.isArray(value)) {
428
+ return value.map((entry) => materializePayload(entry, dataUrls));
429
+ }
430
+ if (typeof value === "object" && value !== null) {
431
+ return Object.fromEntries(
432
+ Object.entries(value)
433
+ .filter(([, entry]) => entry !== undefined)
434
+ .map(([key, entry]) => [key, materializePayload(entry, dataUrls)]),
435
+ );
436
+ }
437
+ return value;
438
+ }
439
+
440
+ function countImageMarkers(value: unknown): number {
441
+ if (parseImageAssetUrlMarker(value) !== undefined) {
442
+ return 1;
443
+ }
444
+ if (Array.isArray(value)) {
445
+ const entries: readonly unknown[] = value;
446
+ return entries.reduce<number>(
447
+ (total, entry) => total + countImageMarkers(entry),
448
+ 0,
449
+ );
450
+ }
451
+ if (typeof value === "object" && value !== null) {
452
+ return Object.values(value as Record<string, unknown>).reduce<number>(
453
+ (total, entry) => total + countImageMarkers(entry),
454
+ 0,
455
+ );
456
+ }
457
+ return 0;
458
+ }
459
+
460
+ function dataUrlLength(asset: ImageAssetRef): number {
461
+ return `data:${asset.mimeType};base64,`.length + 4 * Math.ceil(asset.byteLength / 3);
462
+ }
463
+
464
+ function assertBodyLimit(bodyBytes: number, imageCount: number): void {
465
+ if (bodyBytes > IMAGE_INPUT_POLICY.maxRequestBodyBytes) {
466
+ throw new ModelRequestMediaAggregateError(
467
+ `Model request is ${bodyBytes} UTF-8 bytes with ${imageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxRequestBodyBytes}.`,
468
+ );
469
+ }
470
+ }
471
+
472
+ function normalizedEndpointPolicy(baseURL: string | undefined): string {
473
+ const url = new URL(baseURL ?? "https://api.openai.com/v1");
474
+ url.username = "";
475
+ url.password = "";
476
+ url.search = "";
477
+ url.hash = "";
478
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
479
+ return `${url.protocol}//${url.host}${pathname}`;
151
480
  }
152
481
 
153
482
  function segmentKind(
154
- role: ModelRequestInput["messages"][number]["role"] | undefined,
483
+ role: ModelRequestInput["messages"][number]["role"],
155
484
  ): PreparedPromptSegment["kind"] {
156
485
  switch (role) {
157
486
  case "system":
@@ -162,21 +491,11 @@ function segmentKind(
162
491
  return "assistant";
163
492
  case "tool":
164
493
  return "tool";
165
- case undefined:
166
- throw new Error("OpenAI message mapping changed the message count.");
167
494
  }
168
495
  }
169
496
 
170
- function nonSecretBaseUrl(value: string | undefined): string {
171
- if (value === undefined) {
172
- return "https://api.openai.com/v1";
173
- }
174
- try {
175
- const url = new URL(value);
176
- return `${url.origin}${url.pathname}`;
177
- } catch {
178
- return value.split(/[?#]/, 1)[0] ?? value;
179
- }
497
+ function yieldToEventLoop(): Promise<void> {
498
+ return new Promise((resolve) => setTimeout(resolve, 0));
180
499
  }
181
500
 
182
501
  function deepFreeze<T>(value: T): T {
@@ -188,3 +507,14 @@ function deepFreeze<T>(value: T): T {
188
507
  }
189
508
  return Object.freeze(value);
190
509
  }
510
+
511
+ function sanitizedProviderError(error: unknown): Error {
512
+ const message = error instanceof Error ? error.message : String(error);
513
+ const sanitized = message
514
+ .replace(
515
+ /data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/gu,
516
+ "[redacted image data]",
517
+ )
518
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
519
+ return new Error(sanitized, { cause: error });
520
+ }