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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +90 -9
- package/bin/tinker.js +39 -0
- package/node_modules/ansi-regex/index.d.ts +33 -0
- package/node_modules/ansi-regex/index.js +14 -0
- package/node_modules/ansi-regex/license +9 -0
- package/node_modules/ansi-regex/package.json +61 -0
- package/node_modules/ansi-regex/readme.md +66 -0
- package/node_modules/ansi-styles/index.d.ts +236 -0
- package/node_modules/ansi-styles/index.js +223 -0
- package/node_modules/ansi-styles/license +9 -0
- package/node_modules/ansi-styles/package.json +54 -0
- package/node_modules/ansi-styles/readme.md +173 -0
- package/node_modules/chalk/license +9 -0
- package/node_modules/chalk/package.json +83 -0
- package/node_modules/chalk/readme.md +297 -0
- package/node_modules/chalk/source/index.d.ts +325 -0
- package/node_modules/chalk/source/index.js +225 -0
- package/node_modules/chalk/source/utilities.js +33 -0
- package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
- package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
- package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
- package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
- package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
- package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
- package/node_modules/character-entities/index.d.ts +6 -0
- package/node_modules/character-entities/index.js +2132 -0
- package/node_modules/character-entities/license +22 -0
- package/node_modules/character-entities/package.json +78 -0
- package/node_modules/character-entities/readme.md +152 -0
- package/node_modules/decode-named-character-reference/index.d.ts +13 -0
- package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
- package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
- package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
- package/node_modules/decode-named-character-reference/index.dom.js +32 -0
- package/node_modules/decode-named-character-reference/index.js +19 -0
- package/node_modules/decode-named-character-reference/license +22 -0
- package/node_modules/decode-named-character-reference/package.json +90 -0
- package/node_modules/decode-named-character-reference/readme.md +136 -0
- package/node_modules/get-east-asian-width/index.d.ts +60 -0
- package/node_modules/get-east-asian-width/index.js +30 -0
- package/node_modules/get-east-asian-width/license +9 -0
- package/node_modules/get-east-asian-width/lookup-data.js +21 -0
- package/node_modules/get-east-asian-width/lookup.js +138 -0
- package/node_modules/get-east-asian-width/package.json +71 -0
- package/node_modules/get-east-asian-width/readme.md +65 -0
- package/node_modules/get-east-asian-width/utilities.js +24 -0
- package/node_modules/has-flag/index.d.ts +39 -0
- package/node_modules/has-flag/index.js +8 -0
- package/node_modules/has-flag/license +9 -0
- package/node_modules/has-flag/package.json +49 -0
- package/node_modules/has-flag/readme.md +74 -0
- package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
- package/node_modules/is-fullwidth-code-point/index.js +12 -0
- package/node_modules/is-fullwidth-code-point/license +9 -0
- package/node_modules/is-fullwidth-code-point/package.json +53 -0
- package/node_modules/is-fullwidth-code-point/readme.md +31 -0
- package/node_modules/markdansi/LICENSE +21 -0
- package/node_modules/markdansi/README.md +167 -0
- package/node_modules/markdansi/dist/ast.d.ts +99 -0
- package/node_modules/markdansi/dist/ast.js +1 -0
- package/node_modules/markdansi/dist/cli.d.ts +17 -0
- package/node_modules/markdansi/dist/cli.js +208 -0
- package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
- package/node_modules/markdansi/dist/hyperlink.js +26 -0
- package/node_modules/markdansi/dist/index.d.ts +10 -0
- package/node_modules/markdansi/dist/index.js +16 -0
- package/node_modules/markdansi/dist/parser.d.ts +2 -0
- package/node_modules/markdansi/dist/parser.js +205 -0
- package/node_modules/markdansi/dist/render.d.ts +9 -0
- package/node_modules/markdansi/dist/render.js +680 -0
- package/node_modules/markdansi/dist/stream.d.ts +39 -0
- package/node_modules/markdansi/dist/stream.js +192 -0
- package/node_modules/markdansi/dist/theme.d.ts +18 -0
- package/node_modules/markdansi/dist/theme.js +105 -0
- package/node_modules/markdansi/dist/types.d.ts +58 -0
- package/node_modules/markdansi/dist/types.js +1 -0
- package/node_modules/markdansi/dist/wrap.d.ts +10 -0
- package/node_modules/markdansi/dist/wrap.js +73 -0
- package/node_modules/markdansi/docs/spec.md +110 -0
- package/node_modules/markdansi/package.json +78 -0
- package/node_modules/markdansi/tsconfig.json +18 -0
- package/node_modules/marked/LICENSE +44 -0
- package/node_modules/marked/README.md +115 -0
- package/node_modules/marked/bin/main.js +284 -0
- package/node_modules/marked/bin/marked.js +16 -0
- package/node_modules/marked/lib/marked.d.ts +760 -0
- package/node_modules/marked/lib/marked.esm.js +77 -0
- package/node_modules/marked/lib/marked.esm.js.map +7 -0
- package/node_modules/marked/lib/marked.umd.js +79 -0
- package/node_modules/marked/lib/marked.umd.js.map +7 -0
- package/node_modules/marked/man/marked.1 +113 -0
- package/node_modules/marked/man/marked.1.md +93 -0
- package/node_modules/marked/package.json +103 -0
- package/node_modules/slice-ansi/index.d.ts +19 -0
- package/node_modules/slice-ansi/index.js +317 -0
- package/node_modules/slice-ansi/license +10 -0
- package/node_modules/slice-ansi/package.json +59 -0
- package/node_modules/slice-ansi/readme.md +55 -0
- package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
- package/node_modules/string-width/index.d.ts +39 -0
- package/node_modules/string-width/index.js +203 -0
- package/node_modules/string-width/license +9 -0
- package/node_modules/string-width/package.json +65 -0
- package/node_modules/string-width/readme.md +66 -0
- package/node_modules/strip-ansi/index.d.ts +15 -0
- package/node_modules/strip-ansi/index.js +19 -0
- package/node_modules/strip-ansi/license +9 -0
- package/node_modules/strip-ansi/package.json +59 -0
- package/node_modules/strip-ansi/readme.md +37 -0
- package/node_modules/supports-color/browser.d.ts +1 -0
- package/node_modules/supports-color/browser.js +35 -0
- package/node_modules/supports-color/index.d.ts +55 -0
- package/node_modules/supports-color/index.js +202 -0
- package/node_modules/supports-color/license +9 -0
- package/node_modules/supports-color/package.json +64 -0
- package/node_modules/supports-color/readme.md +75 -0
- package/node_modules/supports-hyperlinks/browser.js +10 -0
- package/node_modules/supports-hyperlinks/index.d.ts +25 -0
- package/node_modules/supports-hyperlinks/index.js +163 -0
- package/node_modules/supports-hyperlinks/license +10 -0
- package/node_modules/supports-hyperlinks/package.json +47 -0
- package/node_modules/supports-hyperlinks/readme.md +45 -0
- package/package.json +41 -10
- package/patches/markdansi@0.3.2.patch +22 -2
- package/src/agent/context-builder.ts +24 -10
- package/src/agent/context-meter.ts +89 -5
- package/src/agent/loop.ts +107 -10
- package/src/agent/runtime-session.ts +1437 -82
- package/src/agent/session-ledger.ts +169 -29
- package/src/agent/types.ts +12 -1
- package/src/agent/user-prompt-projection.ts +62 -0
- package/src/cli/config.ts +57 -14
- package/src/cli/model-profiles.ts +124 -0
- package/src/cli/run-runner.ts +4 -1
- package/src/cli/tui-runner.tsx +68 -30
- package/src/context/compiled-context-hash.ts +1 -1
- package/src/context/compiled-context-validator.ts +92 -15
- package/src/context/context-automation-policy.ts +121 -0
- package/src/context/context-manager.ts +390 -11
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +11 -1
- package/src/context/context-revision-compiler.ts +190 -27
- package/src/context/context-revision.ts +105 -19
- package/src/context/context-surface.ts +329 -0
- package/src/context/prefix-retirement-planner.ts +570 -0
- package/src/context/protocol-frame.ts +30 -3
- package/src/context/recall-retirement-contract.ts +27 -0
- package/src/context/swap-planner.ts +34 -10
- package/src/events/observation-text-log.ts +3 -3
- package/src/events/stdout-event-printer.ts +39 -3
- package/src/events/types.ts +172 -35
- package/src/ids/runtime-id.ts +6 -0
- package/src/ids/uuid-v7.ts +7 -0
- package/src/image/image-asset-store.ts +319 -0
- package/src/image/image-input-policy.ts +14 -0
- package/src/image/image-probe.ts +233 -0
- package/src/image/image-types.ts +262 -0
- package/src/instructions/project-instructions.ts +4 -1
- package/src/mcp/mcp-manager.ts +83 -19
- package/src/model/fake-model-client.ts +9 -1
- package/src/model/input-token-estimator.ts +25 -0
- package/src/model/model-client.ts +60 -0
- package/src/model/model-request-preflight.ts +1 -0
- package/src/model/moonshot-input-token-estimator.ts +107 -0
- package/src/model/openai-chat-mapping.ts +77 -1
- package/src/model/openai-chat-model-client.ts +389 -59
- package/src/model/openai-chat-stream.ts +272 -0
- package/src/model/prompt-prefix-hash.ts +2 -4
- package/src/model/token-estimator.ts +4 -0
- package/src/observation/observation-builder.ts +29 -0
- package/src/session/resume-projection.ts +94 -34
- package/src/session/session-catalog.ts +4 -4
- package/src/session/session-errors.ts +1 -1
- package/src/session/session-history-reader.ts +151 -4
- package/src/session/session-last-response-reader.ts +189 -0
- package/src/session/session-schema.ts +349 -35
- package/src/session/session-store.ts +3266 -255
- package/src/session/sqlite-session-ledger.ts +9 -4
- package/src/skills/skill-catalog.ts +182 -0
- package/src/skills/skill-context.ts +364 -0
- package/src/skills/skill-loader.ts +779 -0
- package/src/skills/skill-tool.ts +297 -0
- package/src/tools/edit.ts +51 -64
- package/src/tools/ensure-parent-directory.ts +6 -0
- package/src/tools/read.ts +2 -2
- package/src/tools/recall.ts +43 -40
- package/src/tools/registry.ts +24 -3
- package/src/tools/ripgrep.ts +3 -2
- package/src/tools/types.ts +43 -4
- package/src/tools/write.ts +20 -31
- package/src/tui/app.tsx +333 -96
- package/src/tui/clipboard.ts +5 -0
- package/src/tui/components/mcp-panel.tsx +28 -0
- package/src/tui/components/prompt-input.tsx +790 -257
- package/src/tui/components/skills-panel.tsx +31 -0
- package/src/tui/components/timeline.tsx +30 -1
- package/src/tui/event-store.ts +85 -2
- package/src/tui/file-mention.ts +234 -0
- package/src/tui/line-editor.ts +1 -0
- package/src/tui/project-slash-commands.ts +229 -0
- package/src/tui/prompt-draft.ts +510 -0
- package/src/tui/prompt-history.ts +294 -22
- package/src/tui/slash-commands.ts +46 -2
- package/src/tui/tui-session-controller.ts +110 -48
- package/src/tui/workspace-file-search.ts +94 -0
|
@@ -1,32 +1,54 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
73
|
-
(message, index): PreparedPromptSegment =>
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
177
|
+
async materialize(
|
|
106
178
|
prepared: PreparedModelRequest,
|
|
107
|
-
options:
|
|
108
|
-
): Promise<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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"]
|
|
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
|
|
171
|
-
|
|
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
|
+
}
|