tinker-agent 1.10.1 → 2.0.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.
@@ -32,7 +32,6 @@ import {
32
32
  MODEL_MESSAGE_PROTOCOL_ADAPTERS,
33
33
  type ModelMessageProtocol,
34
34
  } from "../model/model-client";
35
- import type { InputTokenEstimatorCompatibility } from "../model/input-token-estimator";
36
35
  import type { ToolDefinition, ToolRawResult } from "../tools/types";
37
36
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
38
37
  import {
@@ -149,7 +148,16 @@ export type SessionImageInputCompatibility = {
149
148
  readonly policyVersion: string;
150
149
  readonly policySha256: string;
151
150
  readonly inputModalities: readonly ("text" | "image")[];
152
- readonly tokenEstimator?: InputTokenEstimatorCompatibility;
151
+ readonly tokenEstimator?: LegacyInputTokenEstimatorCompatibility;
152
+ };
153
+
154
+ type LegacyInputTokenEstimatorCompatibility = {
155
+ readonly kind: "moonshot-estimate-token-count-v1";
156
+ readonly coverageVersion: "full-request-v1";
157
+ readonly model: string;
158
+ readonly endpoint: string;
159
+ readonly timeoutMs: number;
160
+ readonly maxRetries: 0;
153
161
  };
154
162
 
155
163
  export type CompletedTurnMessageSnapshot =
@@ -660,6 +668,9 @@ export class SessionStore implements SessionLedgerCommitter {
660
668
  case "begin_turn":
661
669
  this.commitBeginTurn(mutation, now);
662
670
  break;
671
+ case "append_steering_users":
672
+ this.commitSteeringUsers(mutation, now);
673
+ break;
663
674
  case "append_assistant":
664
675
  this.commitAssistant(mutation, now);
665
676
  break;
@@ -2848,6 +2859,32 @@ export class SessionStore implements SessionLedgerCommitter {
2848
2859
  requireSingleChange(this.database, updated.changes, "advance turn counter");
2849
2860
  }
2850
2861
 
2862
+ private commitSteeringUsers(
2863
+ mutation: Extract<LedgerMutation, { kind: "append_steering_users" }>,
2864
+ now: string,
2865
+ ): void {
2866
+ const turn = this.requireTurnRow(mutation.turn.turnId);
2867
+ if (turn.status !== "open") {
2868
+ throw new Error(`Turn ${mutation.turn.turnId} is not open.`);
2869
+ }
2870
+ if (
2871
+ mutation.frames.length === 0 ||
2872
+ mutation.frames.length !== mutation.messages.length
2873
+ ) {
2874
+ throw new Error(
2875
+ "Steering user mutation must contain matching frames and messages.",
2876
+ );
2877
+ }
2878
+ for (let index = 0; index < mutation.frames.length; index += 1) {
2879
+ insertFrame(this.database, requireItem(mutation.frames, index, "steering frame"));
2880
+ insertMessage(
2881
+ this.database,
2882
+ requireItem(mutation.messages, index, "steering message"),
2883
+ );
2884
+ }
2885
+ this.touch(now);
2886
+ }
2887
+
2851
2888
  private commitAssistant(
2852
2889
  mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
2853
2890
  now: string,
@@ -3675,7 +3712,6 @@ export function createSessionCompatibilityContract(input: {
3675
3712
  contextProfile: ModelContextProfile;
3676
3713
  messageProtocol: ModelMessageProtocol;
3677
3714
  inputModalities?: readonly ("text" | "image")[];
3678
- tokenEstimator?: InputTokenEstimatorCompatibility;
3679
3715
  }): SessionCompatibilityContract {
3680
3716
  if (input.modelName.trim() === "") {
3681
3717
  throw new Error("Session compatibility model name must not be empty.");
@@ -3693,14 +3729,6 @@ export function createSessionCompatibilityContract(input: {
3693
3729
  throw new Error("Session compatibility message protocol is invalid.");
3694
3730
  }
3695
3731
  const inputModalities = normalizeInputModalities(input.inputModalities ?? ["text"]);
3696
- if (inputModalities.includes("image") && input.tokenEstimator === undefined) {
3697
- throw new Error(
3698
- "Session compatibility image input requires a token estimator identity.",
3699
- );
3700
- }
3701
- if (input.tokenEstimator !== undefined) {
3702
- validateTokenEstimatorCompatibility(input.tokenEstimator);
3703
- }
3704
3732
  return Object.freeze({
3705
3733
  modelName: input.modelName,
3706
3734
  ...(input.profileName === undefined ? {} : { profileName: input.profileName }),
@@ -3716,9 +3744,6 @@ export function createSessionCompatibilityContract(input: {
3716
3744
  }),
3717
3745
  ),
3718
3746
  inputModalities,
3719
- ...(input.tokenEstimator === undefined
3720
- ? {}
3721
- : { tokenEstimator: immutableCanonicalClone(input.tokenEstimator) }),
3722
3747
  }),
3723
3748
  });
3724
3749
  }
@@ -3735,9 +3760,6 @@ function normalizeSessionCompatibilityContract(
3735
3760
  contextProfile: contract.contextProfile,
3736
3761
  messageProtocol: contract.messageProtocol,
3737
3762
  inputModalities: contract.imageInput.inputModalities,
3738
- ...(contract.imageInput.tokenEstimator === undefined
3739
- ? {}
3740
- : { tokenEstimator: contract.imageInput.tokenEstimator }),
3741
3763
  });
3742
3764
  }
3743
3765
 
@@ -3758,7 +3780,7 @@ function normalizeInputModalities(
3758
3780
  }
3759
3781
 
3760
3782
  function validateTokenEstimatorCompatibility(
3761
- estimator: InputTokenEstimatorCompatibility,
3783
+ estimator: LegacyInputTokenEstimatorCompatibility,
3762
3784
  ): void {
3763
3785
  if (
3764
3786
  estimator.kind !== "moonshot-estimate-token-count-v1" ||
@@ -5565,9 +5587,6 @@ function decodeSessionCompatibilityContract(
5565
5587
  enumFromSql(value, ["text", "image"] as const, "compatibility input modality"),
5566
5588
  ),
5567
5589
  );
5568
- if (modalities.includes("image") && tokenEstimator === undefined) {
5569
- throw new Error("Stored image input compatibility has no token estimator.");
5570
- }
5571
5590
  const policyVersion = stringFromSql(
5572
5591
  imageInput.policyVersion,
5573
5592
  "compatibility image policyVersion",
@@ -5596,7 +5615,7 @@ function decodeSessionCompatibilityContract(
5596
5615
 
5597
5616
  function decodeTokenEstimatorCompatibility(
5598
5617
  value: unknown,
5599
- ): InputTokenEstimatorCompatibility {
5618
+ ): LegacyInputTokenEstimatorCompatibility {
5600
5619
  const record = recordFromSql(value, "session compatibility token estimator");
5601
5620
  assertObjectKeys(
5602
5621
  record,
@@ -5604,7 +5623,7 @@ function decodeTokenEstimatorCompatibility(
5604
5623
  ["kind", "coverageVersion", "model", "endpoint", "timeoutMs", "maxRetries"],
5605
5624
  "session compatibility token estimator",
5606
5625
  );
5607
- const estimator: InputTokenEstimatorCompatibility = {
5626
+ const estimator: LegacyInputTokenEstimatorCompatibility = {
5608
5627
  kind: enumFromSql(
5609
5628
  record.kind,
5610
5629
  ["moonshot-estimate-token-count-v1"] as const,
package/src/tui/app.tsx CHANGED
@@ -108,6 +108,10 @@ const STATIC_HEADER = Symbol("tui-static-header");
108
108
  const LIVE_TIMELINE_MAX_ROWS = 8;
109
109
  const LIVE_TIMELINE_WITH_TASKS_MAX_ROWS = 3;
110
110
  const BACKGROUND_TASKS_MAX_ROWS = 12;
111
+ const IDLE_PROMPT_SCHEDULER = Object.freeze({
112
+ state: "idle" as const,
113
+ pendingCount: 0,
114
+ });
111
115
 
112
116
  export function App(props: AppProps) {
113
117
  const { exit } = useApp();
@@ -137,11 +141,23 @@ export function App(props: AppProps) {
137
141
  () => binding.bashGuard(),
138
142
  () => binding.bashGuard(),
139
143
  );
144
+ const promptScheduler = useSyncExternalStore(
145
+ (listener) => binding.subscribePromptScheduler?.(listener) ?? (() => undefined),
146
+ () => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
147
+ () => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
148
+ );
140
149
  const [isRunning, setIsRunning] = useState(false);
150
+ const executionRunning = isRunning || promptScheduler.state === "running";
141
151
  const [isSessionOperation, setIsSessionOperation] = useState(false);
142
152
  const [isCopying, setIsCopying] = useState(false);
143
153
  const [isCancelling, setIsCancelling] = useState(false);
144
154
  const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
155
+ const currentQueuedFollowUpNotice = `Follow-up queued for the active turn (${promptScheduler.pendingCount} pending).`;
156
+ const visibleNotice =
157
+ notice?.startsWith("Follow-up queued for the active turn (") === true &&
158
+ notice !== currentQueuedFollowUpNotice
159
+ ? undefined
160
+ : notice;
145
161
  const [showStatus, setShowStatus] = useState(false);
146
162
  const [showSkills, setShowSkills] = useState(false);
147
163
  const [showMcp, setShowMcp] = useState(false);
@@ -259,7 +275,7 @@ export function App(props: AppProps) {
259
275
  setIsCancelling(true);
260
276
  setNotice("Cancelling current turn...");
261
277
  },
262
- { isActive: isRunning },
278
+ { isActive: executionRunning },
263
279
  );
264
280
 
265
281
  const closeResumePicker = () => {
@@ -449,6 +465,32 @@ export function App(props: AppProps) {
449
465
  submission: PromptSubmission,
450
466
  admissionSignal: AbortSignal,
451
467
  ): Promise<PromptSubmissionOutcome> => {
468
+ if (promptScheduler.state === "running") {
469
+ if (submission.userMessage.attachments !== undefined) {
470
+ setNotice("Active-turn follow-ups do not support image attachments.");
471
+ return false;
472
+ }
473
+ if (submission.userMessage.content.trimStart().startsWith("/")) {
474
+ setNotice("Slash commands cannot be queued while a turn is running.");
475
+ return false;
476
+ }
477
+ try {
478
+ const queued = binding.queueFollowUp?.(submission.userMessage);
479
+ if (queued === undefined) {
480
+ throw new Error("TUI session binding does not support follow-up queuing.");
481
+ }
482
+ void props.history?.append(submission.draft).catch((error: unknown) => {
483
+ setNotice(`Prompt history write failed: ${errorMessage(error)}`);
484
+ });
485
+ setNotice(
486
+ `Follow-up queued for the active turn (${queued.pendingCount} pending).`,
487
+ );
488
+ return true;
489
+ } catch (error) {
490
+ setNotice(`Follow-up was not queued: ${errorMessage(error)}`);
491
+ return false;
492
+ }
493
+ }
452
494
  if (isRunning) return false;
453
495
  setNotice(undefined);
454
496
  setIsCancelling(false);
@@ -557,6 +599,10 @@ export function App(props: AppProps) {
557
599
  restoreStaticViewport();
558
600
  }
559
601
 
602
+ if (promptScheduler.state === "running") {
603
+ return submitAgentPrompt(submission, signal);
604
+ }
605
+
560
606
  if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
561
607
  try {
562
608
  const projectCommand = resolveProjectSlashCommand(
@@ -856,9 +902,16 @@ export function App(props: AppProps) {
856
902
  </Box>
857
903
  <Box marginTop={1} flexShrink={0}>
858
904
  <Footer
859
- status={isCancelling ? "cancelling" : state.status}
905
+ status={
906
+ isCancelling
907
+ ? "cancelling"
908
+ : executionRunning
909
+ ? "running"
910
+ : state.status
911
+ }
860
912
  workedForMs={state.workedForMs}
861
913
  yolo={bashGuard.mode === "yolo"}
914
+ pendingFollowUps={promptScheduler.pendingCount}
862
915
  />
863
916
  </Box>
864
917
  <Box marginTop={1} flexDirection="column" flexShrink={0}>
@@ -891,26 +944,38 @@ export function App(props: AppProps) {
891
944
  gitBranch={gitBranch}
892
945
  contextUsage={state.contextUsage}
893
946
  isDisabled={
894
- isRunning ||
895
947
  isSessionOperation ||
896
948
  isCopying ||
949
+ isCancelling ||
897
950
  bashGuard.pending !== undefined
898
951
  }
899
952
  history={props.history}
900
953
  commands={availableCommands}
901
954
  fileLister={props.fileLister}
902
- importImage={binding.importImage}
955
+ importImage={
956
+ promptScheduler.state === "running"
957
+ ? undefined
958
+ : binding.importImage
959
+ }
903
960
  verifyImageAssets={binding.verifyImageAssets}
904
961
  onCycleReasoningEffort={
905
- hasReasoningEffort ? cycleReasoningEffort : undefined
962
+ hasReasoningEffort && promptScheduler.state !== "running"
963
+ ? cycleReasoningEffort
964
+ : undefined
906
965
  }
907
966
  onSubmit={onSubmit}
908
967
  onMaintenance={onMaintenance}
909
- placeholder='Enter a coding request, or "/" for commands'
968
+ placeholder={
969
+ promptScheduler.state === "running"
970
+ ? "Send a follow-up for the active turn…"
971
+ : 'Enter a coding request, or "/" for commands'
972
+ }
910
973
  />
911
974
  )}
912
975
  {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
913
- {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
976
+ {visibleNotice === undefined ? null : (
977
+ <Text color="yellow">{visibleNotice}</Text>
978
+ )}
914
979
  </Box>
915
980
  </Box>
916
981
  )}
@@ -4,6 +4,7 @@ export type FooterProps = {
4
4
  status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
5
5
  workedForMs?: number;
6
6
  yolo?: boolean;
7
+ pendingFollowUps?: number;
7
8
  };
8
9
 
9
10
  export function Footer(props: FooterProps) {
@@ -26,7 +27,11 @@ export function Footer(props: FooterProps) {
26
27
  }
27
28
 
28
29
  if (props.status === "running") {
29
- return <Spinner label={`Running${suffix}`} />;
30
+ const queued =
31
+ props.pendingFollowUps === undefined || props.pendingFollowUps === 0
32
+ ? ""
33
+ : ` · ${props.pendingFollowUps} follow-up${props.pendingFollowUps === 1 ? "" : "s"} queued`;
34
+ return <Spinner label={`Running${queued}${suffix}`} />;
30
35
  }
31
36
 
32
37
  if (props.status === "cancelling") {
@@ -155,6 +155,21 @@ export function reduceTuiProjection(
155
155
  },
156
156
  };
157
157
  }
158
+ case "turn.steering.applied": {
159
+ const userPrompt = truncateUserPromptProjection(
160
+ event.data.userPrompt,
161
+ MAX_TIMELINE_PROMPT_CODE_POINTS,
162
+ );
163
+ return updateActiveTurn(state, event, policy, (turn) =>
164
+ appendTurnItem(turn, {
165
+ id: `turn-${event.turnId}-steering-${event.eventSequence}`,
166
+ label: "follow-up",
167
+ text: userPrompt.text,
168
+ userPrompt,
169
+ status: "text",
170
+ }),
171
+ );
172
+ }
158
173
  case "model.request.started":
159
174
  return updateActiveTurn(state, event, policy, (turn) =>
160
175
  event.data.attemptNumber === 1
@@ -45,6 +45,9 @@ export type TuiSessionBinding = {
45
45
  ) => Promise<void>;
46
46
  admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
47
47
  executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
48
+ promptScheduler?: RuntimeSession["promptScheduler"];
49
+ subscribePromptScheduler?: RuntimeSession["subscribePromptScheduler"];
50
+ queueFollowUp?: RuntimeSession["queueFollowUp"];
48
51
  bashGuard(): BashGuardSnapshot;
49
52
  subscribeBashGuard(listener: () => void): () => void;
50
53
  setYoloMode(enabled: boolean): void;
@@ -250,6 +253,10 @@ export function managedTuiBinding(input: {
250
253
  userMessage,
251
254
  signal,
252
255
  } satisfies ExecuteTurnInput),
256
+ promptScheduler: () => input.runtimeSession.promptScheduler(),
257
+ subscribePromptScheduler: (listener) =>
258
+ input.runtimeSession.subscribePromptScheduler(listener),
259
+ queueFollowUp: (userMessage) => input.runtimeSession.queueFollowUp(userMessage),
253
260
  bashGuard: () => input.runtimeSession.bashGuard(),
254
261
  subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
255
262
  setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
@@ -1,25 +0,0 @@
1
- import type { MaterializedModelRequest } from "./model-client";
2
-
3
- export type InputTokenEstimate = {
4
- readonly inputTokens: number;
5
- readonly source: "provider_estimated";
6
- readonly coverage: "messages" | "full_request";
7
- };
8
-
9
- export type InputTokenEstimatorCompatibility = {
10
- readonly kind: "moonshot-estimate-token-count-v1";
11
- readonly coverageVersion: "full-request-v1";
12
- readonly model: string;
13
- readonly endpoint: string;
14
- readonly timeoutMs: number;
15
- readonly maxRetries: 0;
16
- };
17
-
18
- export interface InputTokenEstimator {
19
- readonly kind: string;
20
- readonly compatibility: InputTokenEstimatorCompatibility;
21
- estimate(
22
- request: MaterializedModelRequest,
23
- options: { signal: AbortSignal },
24
- ): Promise<InputTokenEstimate>;
25
- }
@@ -1,111 +0,0 @@
1
- import type { InputTokenEstimate, InputTokenEstimator } from "./input-token-estimator";
2
- import type { MaterializedModelRequest } from "./model-client";
3
- import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
4
-
5
- export class MoonshotInputTokenEstimator implements InputTokenEstimator {
6
- readonly kind = "moonshot-estimate-token-count-v1";
7
- readonly compatibility: InputTokenEstimator["compatibility"];
8
- private readonly endpoint: string;
9
-
10
- constructor(
11
- private readonly options: {
12
- apiKey: string;
13
- baseURL: string;
14
- model: string;
15
- timeoutMs: number;
16
- fetch?: typeof fetch;
17
- payloadMapper?: (payload: unknown) => unknown;
18
- },
19
- ) {
20
- const base = new URL(
21
- options.baseURL.endsWith("/") ? options.baseURL : `${options.baseURL}/`,
22
- );
23
- base.username = "";
24
- base.password = "";
25
- base.search = "";
26
- base.hash = "";
27
- const endpoint = new URL("tokenizers/estimate-token-count", base);
28
- this.endpoint = endpoint.toString();
29
- this.compatibility = Object.freeze({
30
- kind: "moonshot-estimate-token-count-v1",
31
- coverageVersion: "full-request-v1",
32
- model: options.model,
33
- endpoint: this.endpoint,
34
- timeoutMs: options.timeoutMs,
35
- maxRetries: 0,
36
- });
37
- }
38
-
39
- async estimate(
40
- request: MaterializedModelRequest,
41
- options: { signal: AbortSignal },
42
- ): Promise<InputTokenEstimate> {
43
- const chatPayload = requireRecord(
44
- this.options.payloadMapper?.(request.payload) ?? request.payload,
45
- "materialized chat payload",
46
- );
47
- if (!Array.isArray(chatPayload.messages)) {
48
- throw new Error("Materialized request has no token estimator messages.");
49
- }
50
- const payload = {
51
- model: this.options.model,
52
- messages: chatPayload.messages,
53
- ...(Array.isArray(chatPayload.tools) ? { tools: chatPayload.tools } : {}),
54
- };
55
- const body = JSON.stringify(payload);
56
- const bodyBytes = Buffer.byteLength(body, "utf8");
57
- if (bodyBytes > IMAGE_INPUT_POLICY.maxRequestBodyBytes) {
58
- throw new Error(
59
- `Token estimate request is ${bodyBytes} bytes; maximum is ${IMAGE_INPUT_POLICY.maxRequestBodyBytes}.`,
60
- );
61
- }
62
-
63
- const controller = new AbortController();
64
- const timeout = setTimeout(
65
- () => controller.abort(new Error("Token estimate timed out.")),
66
- this.options.timeoutMs,
67
- );
68
- const onAbort = () => controller.abort(options.signal.reason);
69
- options.signal.addEventListener("abort", onAbort, { once: true });
70
- try {
71
- options.signal.throwIfAborted();
72
- const response = await (this.options.fetch ?? fetch)(this.endpoint, {
73
- method: "POST",
74
- headers: {
75
- authorization: `Bearer ${this.options.apiKey}`,
76
- "content-type": "application/json",
77
- },
78
- body,
79
- signal: controller.signal,
80
- });
81
- if (!response.ok) {
82
- throw new Error(`Token estimate endpoint returned HTTP ${response.status}.`);
83
- }
84
- const decoded: unknown = await response.json();
85
- const root = requireRecord(decoded, "token estimate response");
86
- if (root.error !== undefined && root.error !== null) {
87
- throw new Error("Token estimate endpoint returned an error response.");
88
- }
89
- const data = requireRecord(root.data, "token estimate response data");
90
- const inputTokens = data.total_tokens;
91
- if (!Number.isSafeInteger(inputTokens) || (inputTokens as number) < 0) {
92
- throw new Error("Token estimate response total_tokens is invalid.");
93
- }
94
- return Object.freeze({
95
- inputTokens: inputTokens as number,
96
- source: "provider_estimated",
97
- coverage: "full_request",
98
- });
99
- } finally {
100
- clearTimeout(timeout);
101
- options.signal.removeEventListener("abort", onAbort);
102
- }
103
- }
104
- }
105
-
106
- function requireRecord(value: unknown, name: string): Record<string, unknown> {
107
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
108
- throw new Error(`${name} must be an object.`);
109
- }
110
- return value as Record<string, unknown>;
111
- }
@@ -1,155 +0,0 @@
1
- type ChatEstimatorToolCall = {
2
- id: string;
3
- type: "function";
4
- function: {
5
- name: string;
6
- arguments: string;
7
- };
8
- };
9
-
10
- type ChatEstimatorMessage = {
11
- role: "system" | "user" | "assistant" | "tool";
12
- content: unknown;
13
- tool_calls?: ChatEstimatorToolCall[];
14
- tool_call_id?: string;
15
- };
16
-
17
- export function responsesPayloadForChatTokenEstimator(
18
- payload: unknown,
19
- ): Record<string, unknown> {
20
- const root = requireRecord(payload, "Responses token estimator payload");
21
- if (!Array.isArray(root.input)) {
22
- throw new Error("Responses token estimator payload input must be an array.");
23
- }
24
-
25
- const messages: ChatEstimatorMessage[] = [];
26
- for (const [index, rawItem] of root.input.entries()) {
27
- const path = `Responses token estimator input[${index}]`;
28
- const item = requireRecord(rawItem, path);
29
- const type = requireString(item.type, `${path}.type`);
30
- if (type === "message") {
31
- messages.push(toChatMessage(item, path));
32
- continue;
33
- }
34
- if (type === "function_call") {
35
- appendFunctionCall(messages, item, path);
36
- continue;
37
- }
38
- if (type === "function_call_output") {
39
- messages.push({
40
- role: "tool",
41
- tool_call_id: requireString(item.call_id, `${path}.call_id`),
42
- content: requireString(item.output, `${path}.output`),
43
- });
44
- continue;
45
- }
46
- throw new Error(`${path}.type is unsupported: ${JSON.stringify(type)}.`);
47
- }
48
-
49
- return {
50
- messages,
51
- ...(root.tools === undefined ? {} : { tools: toChatTools(root.tools) }),
52
- };
53
- }
54
-
55
- function toChatMessage(
56
- item: Record<string, unknown>,
57
- path: string,
58
- ): ChatEstimatorMessage {
59
- const role = requireString(item.role, `${path}.role`);
60
- if (role !== "system" && role !== "user" && role !== "assistant") {
61
- throw new Error(`${path}.role is unsupported: ${JSON.stringify(role)}.`);
62
- }
63
- return {
64
- role,
65
- content: toChatContent(item.content, `${path}.content`),
66
- };
67
- }
68
-
69
- function toChatContent(value: unknown, path: string): unknown {
70
- if (typeof value === "string") {
71
- return value;
72
- }
73
- if (!Array.isArray(value)) {
74
- throw new Error(`${path} must be a string or an array.`);
75
- }
76
- return value.map((rawPart, index) => {
77
- const partPath = `${path}[${index}]`;
78
- const part = requireRecord(rawPart, partPath);
79
- const type = requireString(part.type, `${partPath}.type`);
80
- if (type === "input_text") {
81
- return {
82
- type: "text",
83
- text: requireString(part.text, `${partPath}.text`),
84
- };
85
- }
86
- if (type === "input_image") {
87
- return {
88
- type: "image_url",
89
- image_url: {
90
- url: requireString(part.image_url, `${partPath}.image_url`),
91
- ...(part.detail === undefined
92
- ? {}
93
- : { detail: requireString(part.detail, `${partPath}.detail`) }),
94
- },
95
- };
96
- }
97
- throw new Error(`${partPath}.type is unsupported: ${JSON.stringify(type)}.`);
98
- });
99
- }
100
-
101
- function appendFunctionCall(
102
- messages: ChatEstimatorMessage[],
103
- item: Record<string, unknown>,
104
- path: string,
105
- ): void {
106
- const call: ChatEstimatorToolCall = {
107
- id: requireString(item.call_id, `${path}.call_id`),
108
- type: "function",
109
- function: {
110
- name: requireString(item.name, `${path}.name`),
111
- arguments: requireString(item.arguments, `${path}.arguments`),
112
- },
113
- };
114
- const previous = messages[messages.length - 1];
115
- if (previous?.role === "assistant") {
116
- (previous.tool_calls ??= []).push(call);
117
- return;
118
- }
119
- messages.push({ role: "assistant", content: null, tool_calls: [call] });
120
- }
121
-
122
- function toChatTools(value: unknown): Record<string, unknown>[] {
123
- if (!Array.isArray(value)) {
124
- throw new Error("Responses token estimator payload tools must be an array.");
125
- }
126
- return value.map((rawTool, index) => {
127
- const path = `Responses token estimator tools[${index}]`;
128
- const tool = requireRecord(rawTool, path);
129
- if (tool.type !== "function") {
130
- throw new Error(`${path}.type must be "function".`);
131
- }
132
- return {
133
- type: "function",
134
- function: {
135
- name: requireString(tool.name, `${path}.name`),
136
- description: requireString(tool.description, `${path}.description`),
137
- parameters: requireRecord(tool.parameters, `${path}.parameters`),
138
- },
139
- };
140
- });
141
- }
142
-
143
- function requireRecord(value: unknown, path: string): Record<string, unknown> {
144
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
145
- throw new Error(`${path} must be an object.`);
146
- }
147
- return value as Record<string, unknown>;
148
- }
149
-
150
- function requireString(value: unknown, path: string): string {
151
- if (typeof value !== "string") {
152
- throw new Error(`${path} must be a string.`);
153
- }
154
- return value;
155
- }