1import type {
2 ModelSelection,
3 OrchestrationV2ModelSwitch,
4 OrchestrationV2Run,
5 OrchestrationV2TurnItem,
6 ProviderInstanceId,
7 ServerProvider,
8 ServerProviderModel,
9} from "@t3tools/contracts";
10import {
11 getModelSelectionStringOptionValue,
12 getProviderOptionCurrentValue,
13} from "@t3tools/shared/model";
14
15import { isReasoningSelect } from "./modelTraits.ts";
16import type { ThreadStructure } from "./state/models.ts";
17import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts";
18
19export type HandoffTimelineRun = Pick<
20 OrchestrationV2Run,
21 "id" | "ordinal" | "providerInstanceId" | "modelSelection"
22>;
23
24export type HandoffEndpoints = ReturnType<typeof resolveHandoffEndpoints>;
25
26export function resolvePendingHandoff(
27 projection: Pick<ThreadStructure, "runs" | "visibleTurnItems"> | null | undefined,
28 selection: ModelSelection | null | undefined,
29 providers: ReadonlyArray<ServerProvider>,
30): HandoffEndpoints | null {
31 // A thread with an empty timeline has nothing to hand off. Any loaded row
32 // counts: paging can leave a window of only tool rows.
33 if (!projection?.visibleTurnItems.length) return null;
34 // The handoff row is committed before the provider starts. Use its destination
35 // during startup so the footer does not duplicate the committed handoff.
36 const from = projection.runs.findLast(
37 (run) =>
38 run.status !== "rolled_back" &&
39 (run.startedAt !== null || (run.status === "starting" && run.contextHandoffId !== null)),
40 )?.modelSelection;
41 if (from === undefined || selection == null) return null;
42 const source = providers.find((provider) => provider.instanceId === from.instanceId);
43 const target = providers.find((provider) => provider.instanceId === selection.instanceId);
44 const needsHandoff =
45 from.instanceId !== selection.instanceId &&
46 (source?.continuation?.groupKey === undefined ||
47 source.driver !== target?.driver ||
48 source.continuation.groupKey !== target.continuation?.groupKey);
49 return needsHandoff ? { from: [from], to: selection } : null;
50}
51
52export function resolveHandoffEndpoints(
53 item: Pick<
54 Extract<OrchestrationV2TurnItem, { type: "handoff" }>,
55 "runId" | "fromModelSelections" | "fromProviderInstanceIds" | "toModel" | "toProviderInstanceId"
56 >,
57 runs: ReadonlyArray<HandoffTimelineRun>,
58) {
59 const handoffRun = item.runId === null ? undefined : runs.find((run) => run.id === item.runId);
60 const toModel =
61 item.toModel ??
62 (handoffRun !== undefined && handoffRun.providerInstanceId === item.toProviderInstanceId
63 ? handoffRun.modelSelection.model
64 : undefined);
65 const fromEndpoints: ReadonlyArray<{
66 readonly instanceId: ProviderInstanceId;
67 readonly model?: string | undefined;
68 }> =
69 item.fromModelSelections !== undefined && item.fromModelSelections.length > 0
70 ? item.fromModelSelections
71 : item.fromProviderInstanceIds.map((instanceId) => ({
72 instanceId,
73 model: latestRunModelBefore(runs, instanceId, handoffRun?.ordinal),
74 }));
75 return { from: fromEndpoints, to: { instanceId: item.toProviderInstanceId, model: toModel } };
76}
77
78function latestRunModelBefore(
79 runs: ReadonlyArray<HandoffTimelineRun>,
80 instanceId: ProviderInstanceId,
81 beforeOrdinal: number | undefined,
82): string | undefined {
83 let latest: HandoffTimelineRun | undefined;
84 for (const run of runs) {
85 if (run.providerInstanceId !== instanceId) continue;
86 if (beforeOrdinal !== undefined && run.ordinal >= beforeOrdinal) continue;
87 if (latest === undefined || run.ordinal > latest.ordinal) latest = run;
88 }
89 return latest?.modelSelection.model;
90}
91
92export interface ModelSwitchChange {
93 readonly modelChanged: boolean;
94 /** Effort labels, set only when the effort changed. */
95 readonly effort: { readonly from: string; readonly to: string } | null;
96}
97
98/**
99 * What a recorded model switch visibly changed, or null when it changed neither the model nor the
100 * effort. The composer omits an untouched effort, so an unset effort reads as the model's default.
101 */
102export function resolveModelSwitchChange(
103 { from, to }: OrchestrationV2ModelSwitch,
104 providers: ReadonlyArray<ServerProvider>,
105): ModelSwitchChange | null {
106 const modelsOf = (selection: ModelSelection) =>
107 providers.find((provider) => provider.instanceId === selection.instanceId)?.models;
108 const fromEffort = resolveEffort(from, modelsOf(from));
109 const toEffort = resolveEffort(to, modelsOf(to));
110 const modelChanged = from.model !== to.model;
111 const effort =
112 fromEffort && toEffort && fromEffort.value !== toEffort.value
113 ? { from: fromEffort.label, to: toEffort.label }
114 : null;
115 return modelChanged || effort ? { modelChanged, effort } : null;
116}
117
118/** The composer's reasoning control for the model; stated values only when the catalog lacks it. */
119function resolveEffort(
120 selection: ModelSelection,
121 models: ReadonlyArray<ServerProviderModel> | undefined,
122): { readonly value: string; readonly label: string } | undefined {
123 const model = models?.find((candidate) => candidate.slug === selection.model);
124 if (model === undefined) {
125 // Pi states its effort as `thinking`; the catalog would otherwise mark it a reasoning control.
126 const value = [...REASONING_EFFORT_OPTION_IDS, "thinking"]
127 .map((id) => getModelSelectionStringOptionValue(selection, id))
128 .find((candidate) => candidate !== undefined);
129 return value === undefined ? undefined : { value, label: value };
130 }
131 const descriptor = model.capabilities?.optionDescriptors?.find(isReasoningSelect);
132 if (descriptor === undefined) return undefined;
133 const value =
134 getModelSelectionStringOptionValue(selection, descriptor.id) ??
135 getProviderOptionCurrentValue(descriptor, selection);
136 if (typeof value !== "string") return undefined;
137 return { value, label: descriptor.options.find((option) => option.id === value)?.label ?? value };
138}