@intelligo-dev/chat 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +116 -0
  4. package/dist/artifact-writer.d.ts +45 -0
  5. package/dist/artifact-writer.d.ts.map +1 -0
  6. package/dist/artifact-writer.js +77 -0
  7. package/dist/artifact-writer.js.map +1 -0
  8. package/dist/attachments.d.ts +56 -0
  9. package/dist/attachments.d.ts.map +1 -0
  10. package/dist/attachments.js +204 -0
  11. package/dist/attachments.js.map +1 -0
  12. package/dist/body.d.ts +72 -0
  13. package/dist/body.d.ts.map +1 -0
  14. package/dist/body.js +174 -0
  15. package/dist/body.js.map +1 -0
  16. package/dist/client.d.ts +65 -0
  17. package/dist/client.d.ts.map +1 -0
  18. package/dist/client.js +61 -0
  19. package/dist/client.js.map +1 -0
  20. package/dist/config.d.ts +322 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +11 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/errors.d.ts +23 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +41 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/feedback.d.ts +22 -0
  29. package/dist/feedback.d.ts.map +1 -0
  30. package/dist/feedback.js +46 -0
  31. package/dist/feedback.js.map +1 -0
  32. package/dist/generation.d.ts +104 -0
  33. package/dist/generation.d.ts.map +1 -0
  34. package/dist/generation.js +85 -0
  35. package/dist/generation.js.map +1 -0
  36. package/dist/handler.d.ts +30 -0
  37. package/dist/handler.d.ts.map +1 -0
  38. package/dist/handler.js +913 -0
  39. package/dist/handler.js.map +1 -0
  40. package/dist/index.d.ts +31 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +20 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/messages.d.ts +19 -0
  45. package/dist/messages.d.ts.map +1 -0
  46. package/dist/messages.js +34 -0
  47. package/dist/messages.js.map +1 -0
  48. package/dist/parts.d.ts +117 -0
  49. package/dist/parts.d.ts.map +1 -0
  50. package/dist/parts.js +13 -0
  51. package/dist/parts.js.map +1 -0
  52. package/dist/quota.d.ts +32 -0
  53. package/dist/quota.d.ts.map +1 -0
  54. package/dist/quota.js +81 -0
  55. package/dist/quota.js.map +1 -0
  56. package/dist/share.d.ts +24 -0
  57. package/dist/share.d.ts.map +1 -0
  58. package/dist/share.js +78 -0
  59. package/dist/share.js.map +1 -0
  60. package/dist/testing.d.ts +36 -0
  61. package/dist/testing.d.ts.map +1 -0
  62. package/dist/testing.js +82 -0
  63. package/dist/testing.js.map +1 -0
  64. package/dist/title.d.ts +7 -0
  65. package/dist/title.d.ts.map +1 -0
  66. package/dist/title.js +15 -0
  67. package/dist/title.js.map +1 -0
  68. package/dist/usage.d.ts +21 -0
  69. package/dist/usage.d.ts.map +1 -0
  70. package/dist/usage.js +35 -0
  71. package/dist/usage.js.map +1 -0
  72. package/dist/windowing.d.ts +38 -0
  73. package/dist/windowing.d.ts.map +1 -0
  74. package/dist/windowing.js +82 -0
  75. package/dist/windowing.js.map +1 -0
  76. package/package.json +78 -0
  77. package/src/artifact-writer.ts +114 -0
  78. package/src/attachments.ts +262 -0
  79. package/src/body.ts +236 -0
  80. package/src/client.ts +133 -0
  81. package/src/config.ts +376 -0
  82. package/src/errors.ts +80 -0
  83. package/src/feedback.ts +62 -0
  84. package/src/generation.ts +150 -0
  85. package/src/handler.ts +1164 -0
  86. package/src/index.ts +93 -0
  87. package/src/messages.ts +39 -0
  88. package/src/parts.ts +143 -0
  89. package/src/quota.ts +105 -0
  90. package/src/share.ts +103 -0
  91. package/src/testing.ts +150 -0
  92. package/src/title.ts +15 -0
  93. package/src/usage.ts +46 -0
  94. package/src/windowing.ts +110 -0
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The chat transport: `createChatHandler(config)` returns `{ POST, DELETE,
3
+ * GET }` over Web `Request`/`Response` and runs every turn through auth, rate
4
+ * limit, feature gate, persistence and the execution boundary. The config
5
+ * takes AI SDK models, tools and stop conditions as they are.
6
+ */
7
+
8
+ export { createChatHandler } from "./handler";
9
+ export { truncateTitle } from "./title";
10
+ export type { ChatHandler } from "./handler";
11
+
12
+ export type {
13
+ ChatActor,
14
+ ChatAgentConfig,
15
+ ChatAttachmentPolicy,
16
+ ChatGenerationOptions,
17
+ ChatMessageKey,
18
+ ChatMessageParams,
19
+ ChatMessages,
20
+ ChatModelsConfig,
21
+ ChatServerConfig,
22
+ ChatTurn,
23
+ ChatTurnContext,
24
+ ChatTurnEvents,
25
+ PreparedTurn,
26
+ ProviderOptions,
27
+ RateLimitDecision,
28
+ ResolvedAgent,
29
+ StreamTurn,
30
+ TurnStream,
31
+ } from "./config";
32
+
33
+ export { CHAT_ERROR_STATUS, DEFAULT_CHAT_MESSAGES } from "./errors";
34
+ export { parseChatBody } from "./body";
35
+ export type { ChatBody, ParsedChatBody } from "./body";
36
+
37
+ export { getChatQuotaState, getChatQuotaStates } from "./quota";
38
+ export { lastUserMessage, toUIMessages } from "./messages";
39
+ export { pickUsage, sumStepUsage } from "./usage";
40
+ export type { TokenUsage } from "./usage";
41
+ export {
42
+ applyConversationWindow,
43
+ estimateConversationTokens,
44
+ estimateTokenCount,
45
+ extractText,
46
+ } from "./windowing";
47
+ export type {
48
+ ConversationWindow,
49
+ ConversationWindowOptions,
50
+ } from "./windowing";
51
+
52
+ export { createArtifactWriter } from "./artifact-writer";
53
+ export type { ArtifactWriter, ArtifactWriterOptions } from "./artifact-writer";
54
+ export { sanitizeForShare } from "./share";
55
+ export type { SharePolicy } from "./share";
56
+ export { recordChatFeedback } from "./feedback";
57
+ export type { ChatFeedback, RecordChatFeedbackResult } from "./feedback";
58
+ export {
59
+ createChatAttachmentHandler,
60
+ createChatUploadHandler,
61
+ } from "./attachments";
62
+ export type {
63
+ ChatAttachmentHandler,
64
+ ChatUploadHandler,
65
+ ChatUploadResult,
66
+ } from "./attachments";
67
+
68
+ export { CHAT_ERROR_CODES, parseChatError, isChatDataPart } from "./client";
69
+ export type {
70
+ ChatErrorBody,
71
+ ChatErrorCode,
72
+ ChatModelOption,
73
+ ChatQuotaState,
74
+ } from "./client";
75
+ export type {
76
+ ChatAgentData,
77
+ ChatArtifactData,
78
+ ChatAuthorizationData,
79
+ ChatCompactionData,
80
+ ChatDataChunk,
81
+ ChatDataPart,
82
+ ChatDataPartName,
83
+ ChatDataParts,
84
+ ChatMessageMetadata,
85
+ ChatQuestionData,
86
+ ChatQuestionOption,
87
+ ChatStatusData,
88
+ ChatTaskData,
89
+ ChatTaskItem,
90
+ ChatTaskStatus,
91
+ ChatUIMessage,
92
+ ChatUIMessageChunk,
93
+ } from "./parts";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Between the rows core stores and the transcript the AI SDK renders.
3
+ */
4
+
5
+ import type { UIMessage } from "ai";
6
+
7
+ type MessageRow = { id: string; role: string; parts: string };
8
+
9
+ function isChatRole(role: string): role is UIMessage["role"] {
10
+ return role === "system" || role === "user" || role === "assistant";
11
+ }
12
+
13
+ /**
14
+ * Stored `messages.parts` is a JSON string; a
15
+ * row that fails to parse is dropped rather than failing the whole
16
+ * conversation. Tool-role rows are not part of the UI transcript.
17
+ */
18
+ export function toUIMessages(rows: ReadonlyArray<MessageRow>): UIMessage[] {
19
+ const out: UIMessage[] = [];
20
+ for (const row of rows) {
21
+ if (!isChatRole(row.role)) continue;
22
+ try {
23
+ const parts = JSON.parse(row.parts) as unknown;
24
+ if (!Array.isArray(parts)) continue;
25
+ out.push({ id: row.id, role: row.role, parts });
26
+ } catch {
27
+ // A corrupt row costs one message, not the conversation.
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** The last message when it is the user's turn, else null. */
34
+ export function lastUserMessage(
35
+ messages: ReadonlyArray<UIMessage>
36
+ ): UIMessage | null {
37
+ const last = messages[messages.length - 1];
38
+ return last?.role === "user" ? last : null;
39
+ }
package/src/parts.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The parts vocabulary a chat UI, a tool and a runtime binding share. The
3
+ * AI SDK's own parts render as they arrive; a `data-chat-*` part exists only
4
+ * where the SDK has nothing (a deferred title, a status line, a task plan, a
5
+ * subagent, a canvas document, a question, an authorization). Type-only.
6
+ */
7
+
8
+ import type { DataUIPart, UIMessage, UIMessageChunk } from "ai";
9
+
10
+ import type { TokenUsage } from "./usage";
11
+
12
+ /** A live line under the reply — "Searching the web…". One per `id`; transient. */
13
+ export type ChatStatusData = {
14
+ label: string;
15
+ phase?: "thinking" | "searching" | "tool" | "writing";
16
+ done?: boolean;
17
+ };
18
+
19
+ export type ChatTaskStatus = "pending" | "in_progress" | "done" | "failed";
20
+
21
+ export type ChatTaskItem = {
22
+ id: string;
23
+ title: string;
24
+ status: ChatTaskStatus;
25
+ };
26
+
27
+ /** A plan the agent is working through. Reconciled by `id`; persisted. */
28
+ export type ChatTaskData = {
29
+ id: string;
30
+ title: string;
31
+ status: ChatTaskStatus;
32
+ items?: ChatTaskItem[];
33
+ };
34
+
35
+ /** A delegated agent — eve's `subagent.*`, Mastra's networks and nested agents. */
36
+ export type ChatAgentData = {
37
+ id: string;
38
+ name: string;
39
+ status: "started" | "completed" | "failed";
40
+ parentId?: string;
41
+ summary?: string;
42
+ };
43
+
44
+ /**
45
+ * A document streaming into the canvas. Deltas travel as transient
46
+ * parts under the same `id`; the final `ready` part is persisted so the
47
+ * card reopens the document after a reload.
48
+ */
49
+ export type ChatArtifactData = {
50
+ id: string;
51
+ kind: string;
52
+ title: string;
53
+ status: "streaming" | "ready" | "error";
54
+ documentId?: string;
55
+ version?: number;
56
+ /** The whole content, when the writer was asked to persist it. */
57
+ content?: string;
58
+ /** One streamed increment; only on transient parts. */
59
+ delta?: string;
60
+ error?: string;
61
+ };
62
+
63
+ export type ChatQuestionOption = {
64
+ id: string;
65
+ label: string;
66
+ description?: string;
67
+ };
68
+
69
+ /** A runtime paused for an answer that is not a tool approval. */
70
+ export type ChatQuestionData = {
71
+ id: string;
72
+ prompt: string;
73
+ options?: ChatQuestionOption[];
74
+ allowFreeform?: boolean;
75
+ multiple?: boolean;
76
+ answered?: boolean;
77
+ answer?: string;
78
+ };
79
+
80
+ /** A connection that needs the user to sign in before the run continues. */
81
+ export type ChatAuthorizationData = {
82
+ id: string;
83
+ name: string;
84
+ status: "required" | "completed";
85
+ description?: string;
86
+ instructions?: string;
87
+ url?: string;
88
+ };
89
+
90
+ export type ChatCompactionData = { status: "requested" | "completed" };
91
+
92
+ export type ChatDataParts = {
93
+ /** A title written after the first reply. Transient. */
94
+ "chat-title": string;
95
+ "chat-status": ChatStatusData;
96
+ "chat-task": ChatTaskData;
97
+ "chat-agent": ChatAgentData;
98
+ "chat-artifact": ChatArtifactData;
99
+ "chat-question": ChatQuestionData;
100
+ "chat-authorization": ChatAuthorizationData;
101
+ "chat-compaction": ChatCompactionData;
102
+ /** A structured result the turn was asked for. */
103
+ "chat-result": unknown;
104
+ };
105
+
106
+ export type ChatDataPartName = keyof ChatDataParts;
107
+
108
+ /** What the transport attaches to an assistant message once it finishes. */
109
+ export type ChatMessageMetadata = {
110
+ modelId?: string;
111
+ usage?: TokenUsage;
112
+ /** ISO-8601. */
113
+ finishedAt?: string;
114
+ /** The reader's feedback, folded in by the UI from the votes table. */
115
+ vote?: "up" | "down";
116
+ };
117
+
118
+ export type ChatUIMessage = UIMessage<ChatMessageMetadata, ChatDataParts>;
119
+
120
+ export type ChatDataPart = DataUIPart<ChatDataParts>;
121
+
122
+ export type ChatUIMessageChunk = UIMessageChunk<
123
+ ChatMessageMetadata,
124
+ ChatDataParts
125
+ >;
126
+
127
+ /** The chunks a tool or a binding may write mid-turn through `turn.write`. */
128
+ export type ChatDataChunk = Extract<
129
+ ChatUIMessageChunk,
130
+ { type: `data-${string}` }
131
+ >;
132
+
133
+ /** Narrow an unknown part to one of this vocabulary's data parts. */
134
+ export function isChatDataPart<NAME extends ChatDataPartName>(
135
+ part: unknown,
136
+ name: NAME
137
+ ): part is { type: `data-${NAME}`; id?: string; data: ChatDataParts[NAME] } {
138
+ return (
139
+ typeof part === "object" &&
140
+ part !== null &&
141
+ (part as { type?: unknown }).type === `data-${name}`
142
+ );
143
+ }
package/src/quota.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * What the chat page knows about the caller's credit before they type, so
3
+ * it can say so before a turn is refused with 402. An estimate, never a
4
+ * reservation: rendering a page cannot consume credit.
5
+ */
6
+
7
+ import { requireWorkspace } from "@intelligo-dev/auth";
8
+ import { estimateQuota } from "@intelligo-dev/billing";
9
+ import { createLogger } from "@intelligo-dev/core/logger";
10
+
11
+ import type { ChatQuotaState } from "./client";
12
+
13
+ const log = createLogger("ChatQuota");
14
+
15
+ function errorMessage(error: unknown): string {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+
19
+ async function readState(
20
+ workspaceId: string,
21
+ modelId: string,
22
+ upgradeHref: string
23
+ ): Promise<ChatQuotaState> {
24
+ const quota = await estimateQuota(workspaceId, { modelId });
25
+ if (quota.code === "unknown_model") {
26
+ // A deployment error, not the reader's balance: the page shows
27
+ // neutral copy, and this line is where the operator finds out.
28
+ log.error("Model has no registered price", {
29
+ modelId,
30
+ reason: quota.reason,
31
+ });
32
+ }
33
+ return {
34
+ allowed: quota.allowed,
35
+ reason: quota.reason ?? null,
36
+ code: quota.code ?? null,
37
+ // Micros of the deployment's billing currency.
38
+ remaining: quota.remaining?.amount ?? 0,
39
+ estimated: quota.estimated?.amount ?? 0,
40
+ upgradeHref,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Never throws: the page renders with or without this. A quota read
46
+ * that fails should cost the reader a banner, not the conversation.
47
+ */
48
+ export async function getChatQuotaState(options: {
49
+ /** The model a turn would run on — what the estimate is priced against. */
50
+ modelId: string;
51
+ /** Where "upgrade" and "top up" should go. */
52
+ upgradeHref: string;
53
+ /** Defaults to the request's workspace. */
54
+ workspaceId?: string;
55
+ }): Promise<ChatQuotaState | null> {
56
+ try {
57
+ const workspaceId =
58
+ options.workspaceId ?? (await requireWorkspace()).workspace.id;
59
+ return await readState(workspaceId, options.modelId, options.upgradeHref);
60
+ } catch (error) {
61
+ log.warn("quota state unavailable", { error: errorMessage(error) });
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The same estimate for each model the composer offers, keyed by model
68
+ * id, so the banner can follow the picker: a turn on a dearer model has
69
+ * a larger worst case, and the balance that funds one may not fund the
70
+ * other. Never throws; a model whose read failed is left out.
71
+ */
72
+ export async function getChatQuotaStates(options: {
73
+ modelIds: readonly string[];
74
+ /** Where "upgrade" and "top up" should go. */
75
+ upgradeHref: string;
76
+ /** Defaults to the request's workspace. */
77
+ workspaceId?: string;
78
+ }): Promise<Record<string, ChatQuotaState>> {
79
+ const modelIds = [...new Set(options.modelIds)];
80
+ if (modelIds.length === 0) return {};
81
+ try {
82
+ const workspaceId =
83
+ options.workspaceId ?? (await requireWorkspace()).workspace.id;
84
+ const entries = await Promise.all(
85
+ modelIds.map(async (modelId) => {
86
+ try {
87
+ return [
88
+ modelId,
89
+ await readState(workspaceId, modelId, options.upgradeHref),
90
+ ] as const;
91
+ } catch (error) {
92
+ log.warn("quota state unavailable", {
93
+ modelId,
94
+ error: errorMessage(error),
95
+ });
96
+ return null;
97
+ }
98
+ })
99
+ );
100
+ return Object.fromEntries(entries.filter((entry) => entry !== null));
101
+ } catch (error) {
102
+ log.warn("quota state unavailable", { error: errorMessage(error) });
103
+ return {};
104
+ }
105
+ }
package/src/share.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * What a public, read-only copy of a conversation may show.
3
+ *
4
+ * A shared page renders the same transcript the owner sees, minus what
5
+ * was never meant for a stranger: the model's reasoning, the raw input
6
+ * and output of every tool, provider metadata, token usage, transient
7
+ * runtime parts, and any file URL — a stored attachment's URL is a
8
+ * tenant-scoped, short-lived signed link, and an inline one is the
9
+ * whole file. A product that wants a named tool's output on the shared
10
+ * page (a report card, say) lists it in the policy.
11
+ *
12
+ * Pure: no I/O, no `ai` import at runtime.
13
+ */
14
+
15
+ import type { UIMessage } from "ai";
16
+
17
+ export type SharePolicy = {
18
+ /** Tool names whose `input`/`output` stay on the shared page. */
19
+ keepToolOutput?: readonly string[];
20
+ /** `data-*` part names (without the prefix) that stay. Default: `chat-task`, `chat-artifact`. */
21
+ keepDataParts?: readonly string[];
22
+ /** Keep reasoning parts. Default false. */
23
+ keepReasoning?: boolean;
24
+ };
25
+
26
+ const DEFAULT_DATA_PARTS: readonly string[] = ["chat-task", "chat-artifact"];
27
+
28
+ function toolNameOf(part: Record<string, unknown>): string | null {
29
+ if (part.type === "dynamic-tool" && typeof part.toolName === "string") {
30
+ return part.toolName;
31
+ }
32
+ if (typeof part.type === "string" && part.type.startsWith("tool-")) {
33
+ return part.type.slice("tool-".length);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export function sanitizeForShare(
39
+ messages: ReadonlyArray<UIMessage>,
40
+ policy: SharePolicy = {}
41
+ ): UIMessage[] {
42
+ const keepTools = new Set(policy.keepToolOutput ?? []);
43
+ const keepData = new Set(policy.keepDataParts ?? DEFAULT_DATA_PARTS);
44
+
45
+ return messages.map((message) => {
46
+ const parts: UIMessage["parts"] = [];
47
+ for (const raw of message.parts) {
48
+ const part = raw as unknown as Record<string, unknown>;
49
+ const type = typeof part.type === "string" ? part.type : "";
50
+
51
+ if (type === "reasoning") {
52
+ if (policy.keepReasoning) parts.push(raw);
53
+ continue;
54
+ }
55
+ if (type === "text" || type === "step-start") {
56
+ const { providerMetadata: _pm, ...rest } = part;
57
+ parts.push(rest as unknown as UIMessage["parts"][number]);
58
+ continue;
59
+ }
60
+ if (type === "source-url" || type === "source-document") {
61
+ const { providerMetadata: _pm, ...rest } = part;
62
+ parts.push(rest as unknown as UIMessage["parts"][number]);
63
+ continue;
64
+ }
65
+ if (type === "file") {
66
+ parts.push({
67
+ type: "file",
68
+ mediaType: String(part.mediaType ?? "application/octet-stream"),
69
+ ...(typeof part.filename === "string"
70
+ ? { filename: part.filename }
71
+ : {}),
72
+ url: "",
73
+ });
74
+ continue;
75
+ }
76
+ const toolName = toolNameOf(part);
77
+ if (toolName !== null) {
78
+ const kept = keepTools.has(toolName);
79
+ const {
80
+ input,
81
+ output,
82
+ errorText: _e,
83
+ providerMetadata: _pm,
84
+ callProviderMetadata: _cpm,
85
+ providerExecuted: _pe,
86
+ approval: _a,
87
+ ...rest
88
+ } = part;
89
+ parts.push({
90
+ ...rest,
91
+ ...(kept && input !== undefined ? { input } : {}),
92
+ ...(kept && output !== undefined ? { output } : {}),
93
+ } as unknown as UIMessage["parts"][number]);
94
+ continue;
95
+ }
96
+ if (type.startsWith("data-")) {
97
+ if (keepData.has(type.slice("data-".length))) parts.push(raw);
98
+ continue;
99
+ }
100
+ }
101
+ return { id: message.id, role: message.role, parts };
102
+ });
103
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A deterministic language model (from `ai/test`) that streams end to end
3
+ * before any provider is configured; the transport's own tests use it too.
4
+ * `modelId` is a parameter, never a literal: the boundary bills whatever id
5
+ * the transport settles with, so it must be registered in the pricing table.
6
+ */
7
+
8
+ import type { LanguageModel } from "ai";
9
+ import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
10
+
11
+ /** A model prompt as the stub sees it: the AI SDK's provider-level shape. */
12
+ export type StubPrompt = ReadonlyArray<{ role: string; content: unknown }>;
13
+
14
+ export type StubToolCall = {
15
+ toolName: string;
16
+ /** Must satisfy the tool's input schema. */
17
+ input: Record<string, unknown>;
18
+ };
19
+
20
+ export type StubLanguageModelOptions = {
21
+ /** Reported to the boundary; must be a registered model id. */
22
+ modelId: string;
23
+ /** The reply, from the last user message's text. */
24
+ reply: (lastUserText: string, prompt: StubPrompt) => string | Promise<string>;
25
+ /**
26
+ * A tool call to emit alongside the reply, or null for none. Lets a
27
+ * stub exercise the whole tool path — call, execute, render — with
28
+ * no provider. Called once per step; return null on the follow-up
29
+ * step (the prompt then carries the earlier call) to end the turn.
30
+ */
31
+ toolCall?: (
32
+ lastUserText: string,
33
+ prompt: StubPrompt
34
+ ) => StubToolCall | null | Promise<StubToolCall | null>;
35
+ /** Delay between tokens, in ms. Default 15; 0 in tests. */
36
+ chunkDelayInMs?: number;
37
+ };
38
+
39
+ /**
40
+ * Structurally the provider spec's `LanguageModelV3StreamPart`, for
41
+ * the variants this stub emits, without importing `@ai-sdk/provider`
42
+ * — a transitive dependency of `ai`, not one this package declares.
43
+ */
44
+ type StubStreamPart =
45
+ | { type: "stream-start"; warnings: never[] }
46
+ | { type: "text-start"; id: string }
47
+ | { type: "text-delta"; id: string; delta: string }
48
+ | { type: "text-end"; id: string }
49
+ | {
50
+ type: "tool-call";
51
+ toolCallId: string;
52
+ toolName: string;
53
+ /** Stringified JSON matching the tool's input schema. */
54
+ input: string;
55
+ }
56
+ | {
57
+ type: "finish";
58
+ finishReason: { unified: "stop" | "tool-calls"; raw: undefined };
59
+ usage: {
60
+ inputTokens: {
61
+ total: number;
62
+ noCache: undefined;
63
+ cacheRead: undefined;
64
+ cacheWrite: undefined;
65
+ };
66
+ outputTokens: { total: number; text: number; reasoning: undefined };
67
+ };
68
+ };
69
+
70
+ /** The last user message's text, from a provider-level prompt. */
71
+ export function lastUserTextOf(prompt: StubPrompt): string {
72
+ for (let i = prompt.length - 1; i >= 0; i--) {
73
+ const message = prompt[i];
74
+ if (message?.role !== "user") continue;
75
+ const parts = Array.isArray(message.content) ? message.content : [];
76
+ return parts
77
+ .filter(
78
+ (part): part is { type: "text"; text: string } =>
79
+ typeof part === "object" &&
80
+ part !== null &&
81
+ (part as { type?: unknown }).type === "text"
82
+ )
83
+ .map((part) => part.text)
84
+ .join(" ");
85
+ }
86
+ return "";
87
+ }
88
+
89
+ export function createStubLanguageModel(
90
+ options: StubLanguageModelOptions
91
+ ): LanguageModel {
92
+ const delay = options.chunkDelayInMs ?? 15;
93
+ return new MockLanguageModelV3({
94
+ provider: "stub",
95
+ modelId: options.modelId,
96
+ doStream: async ({ prompt }) => {
97
+ const userText = lastUserTextOf(prompt);
98
+ const reply = await options.reply(userText, prompt);
99
+ const call = options.toolCall
100
+ ? await options.toolCall(userText, prompt)
101
+ : null;
102
+ const words = reply.split(" ");
103
+
104
+ const chunks: StubStreamPart[] = [
105
+ { type: "stream-start", warnings: [] },
106
+ { type: "text-start", id: "1" },
107
+ ...words.map<StubStreamPart>((word, index) => ({
108
+ type: "text-delta",
109
+ id: "1",
110
+ delta: index === 0 ? word : ` ${word}`,
111
+ })),
112
+ { type: "text-end", id: "1" },
113
+ ...(call
114
+ ? [
115
+ {
116
+ type: "tool-call" as const,
117
+ toolCallId: `stub-${Date.now()}`,
118
+ toolName: call.toolName,
119
+ input: JSON.stringify(call.input),
120
+ },
121
+ ]
122
+ : []),
123
+ {
124
+ type: "finish",
125
+ finishReason: {
126
+ unified: call ? "tool-calls" : "stop",
127
+ raw: undefined,
128
+ },
129
+ usage: {
130
+ inputTokens: {
131
+ total: Math.max(1, Math.ceil(JSON.stringify(prompt).length / 4)),
132
+ noCache: undefined,
133
+ cacheRead: undefined,
134
+ cacheWrite: undefined,
135
+ },
136
+ outputTokens: {
137
+ total: words.length,
138
+ text: words.length,
139
+ reasoning: undefined,
140
+ },
141
+ },
142
+ },
143
+ ];
144
+
145
+ return {
146
+ stream: simulateReadableStream({ chunkDelayInMs: delay, chunks }),
147
+ };
148
+ },
149
+ });
150
+ }
package/src/title.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The default conversation title: the first line of the opening
3
+ * message, truncated to something a sidebar can list.
4
+ */
5
+
6
+ const MAX_TITLE_LENGTH = 60;
7
+
8
+ /** First line, trimmed, truncated — enough to tell history rows apart. */
9
+ export function truncateTitle(firstUserText: string): string | null {
10
+ const line = firstUserText.trim().split("\n")[0]?.trim();
11
+ if (!line) return null;
12
+ return line.length <= MAX_TITLE_LENGTH
13
+ ? line
14
+ : `${line.slice(0, MAX_TITLE_LENGTH - 1).trimEnd()}…`;
15
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Token usage, in the one shape the execution boundary settles.
3
+ */
4
+
5
+ export type TokenUsage = {
6
+ inputTokens?: number;
7
+ outputTokens?: number;
8
+ totalTokens?: number;
9
+ };
10
+
11
+ /** The three fields settlement reads, and nothing else a provider adds. */
12
+ export function pickUsage(usage: TokenUsage | undefined): TokenUsage {
13
+ return {
14
+ inputTokens: usage?.inputTokens,
15
+ outputTokens: usage?.outputTokens,
16
+ totalTokens: usage?.totalTokens,
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Whole-run usage from the steps that completed before an abort. A
22
+ * step still in flight is missed — under-counting by at most one step
23
+ * beats charging nothing and leaving the hold to expire.
24
+ */
25
+ export function sumStepUsage(
26
+ steps: ReadonlyArray<{ usage?: TokenUsage }>
27
+ ): TokenUsage {
28
+ let inputTokens = 0;
29
+ let outputTokens = 0;
30
+ for (const step of steps) {
31
+ inputTokens += step.usage?.inputTokens ?? 0;
32
+ outputTokens += step.usage?.outputTokens ?? 0;
33
+ }
34
+ return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
35
+ }
36
+
37
+ /** The sum of two usages; a total a provider omitted is input plus output. */
38
+ export function sumUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
39
+ const total = (u: TokenUsage) =>
40
+ u.totalTokens ?? (u.inputTokens ?? 0) + (u.outputTokens ?? 0);
41
+ return {
42
+ inputTokens: (a.inputTokens ?? 0) + (b.inputTokens ?? 0),
43
+ outputTokens: (a.outputTokens ?? 0) + (b.outputTokens ?? 0),
44
+ totalTokens: total(a) + total(b),
45
+ };
46
+ }