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.
- package/CHANGELOG.md +27 -1
- package/README.md +7 -23
- package/package.json +1 -1
- package/src/agent/context-meter.ts +14 -85
- package/src/agent/loop.ts +5 -14
- package/src/agent/runtime-session.ts +176 -24
- package/src/agent/session-ledger.ts +80 -0
- package/src/cli/config.ts +0 -5
- package/src/cli/model-profiles.ts +0 -98
- package/src/cli/public-config-contract.ts +1 -88
- package/src/cli/runner-dependencies.ts +0 -7
- package/src/cli/tui-memory.ts +0 -3
- package/src/events/observation-text-log.ts +4 -0
- package/src/events/stdout-event-printer.ts +5 -0
- package/src/events/types.ts +5 -1
- package/src/image/image-input-policy.ts +53 -2
- package/src/image/image-probe.ts +8 -2
- package/src/image/provider-image.ts +99 -0
- package/src/model/fake-model-client.ts +98 -69
- package/src/model/model-client.ts +2 -2
- package/src/model/model-request-preflight.ts +0 -1
- package/src/model/openai-chat-model-client.ts +4 -28
- package/src/model/openai-model-utils.ts +69 -31
- package/src/model/openai-responses-model-client.ts +0 -29
- package/src/model/token-estimator.ts +16 -3
- package/src/session/session-store.ts +42 -23
- package/src/tui/app.tsx +72 -7
- package/src/tui/components/footer.tsx +6 -1
- package/src/tui/event-store.ts +15 -0
- package/src/tui/tui-session-controller.ts +7 -0
- package/src/model/input-token-estimator.ts +0 -25
- package/src/model/moonshot-input-token-estimator.ts +0 -111
- package/src/model/openai-responses-token-estimator.ts +0 -155
|
@@ -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?:
|
|
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:
|
|
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
|
-
):
|
|
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:
|
|
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:
|
|
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={
|
|
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={
|
|
955
|
+
importImage={
|
|
956
|
+
promptScheduler.state === "running"
|
|
957
|
+
? undefined
|
|
958
|
+
: binding.importImage
|
|
959
|
+
}
|
|
903
960
|
verifyImageAssets={binding.verifyImageAssets}
|
|
904
961
|
onCycleReasoningEffort={
|
|
905
|
-
hasReasoningEffort
|
|
962
|
+
hasReasoningEffort && promptScheduler.state !== "running"
|
|
963
|
+
? cycleReasoningEffort
|
|
964
|
+
: undefined
|
|
906
965
|
}
|
|
907
966
|
onSubmit={onSubmit}
|
|
908
967
|
onMaintenance={onMaintenance}
|
|
909
|
-
placeholder=
|
|
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
|
-
{
|
|
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
|
-
|
|
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") {
|
package/src/tui/event-store.ts
CHANGED
|
@@ -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
|
-
}
|