1
14
15export { AnthropicAdapter, type AnthropicOptions } from "./anthropic.js";
16export { GoogleAdapter, type GoogleOptions } from "./google.js";
17export {
18 CustomAdapter,
19 OpenAIAdapter,
20 OpenAICompatibleAdapter,
21 OssAdapter,
22 type OpenAICompatibleOptions,
23} from "./openai-compatible.js";
24export { abortError };
25import { abortError } from "./http.js";
26import {
27 ProviderHttpError,
28 httpJson,
29 isRetryableStatus,
30 sseData,
31 type HttpOptions,
32} from "./http.js";
33import { mapColumnsToProperties, parseMarkdownTable } from "../schemas/database.js";
34
35
41export class MockProvider {
42 readonly id: "openai" | "anthropic" | "google" | "oss" | "custom";
43
44 failTimes = 0;
45
46 failStatus = 429;
47 calls = 0;
48
53 deltaDelayMs = 0;
54
62 failAfterDeltas = 0;
63
64 readonly dimension: number;
65 private callsSeen: { model: string }[] = [];
66
67 constructor(id: "openai" | "anthropic" | "google" | "oss" | "custom" = "openai", dimension = 8) {
68 this.id = id;
69 this.dimension = dimension;
70 }
71
72 async models(): Promise<
73 Array<{ id: string; provider: "openai" | "anthropic" | "google" | "oss" | "custom"; kind: "chat" | "embed" | "chat+embed"; contextWindow: number; maxOutput: number; toolCalls: boolean; vision: boolean; pricePer1kIn: number; pricePer1kOut: number; dimension: number }>
74 > {
75 return [
76 {
77 id: "mock-chat",
78 provider: this.id,
79 kind: "chat",
80 contextWindow: 32_000,
81 maxOutput: 4_096,
82 toolCalls: true,
83 vision: false,
84 pricePer1kIn: 0,
85 pricePer1kOut: 0,
86 dimension: 0,
87 },
88 ];
89 }
90
91 async *chat(req: {
92 model: string;
93 messages: Array<{ role: string; content: unknown }>;
94 tools?: Array<{ name: string; description: string; parameters: Record<string, unknown> }>;
95 maxTokens?: number;
96 metadata?: Record<string, string>;
97 signal?: AbortSignal;
98 }): AsyncIterable<{
99 delta?: string;
100 toolCallDelta?: { id: string; name: string; arguments: Record<string, unknown> };
101 usage?: { in: number; out: number; total: number };
102 finishReason?: "stop" | "tool_calls" | "length" | "error";
103 done: boolean;
104 }> {
105 this.calls += 1;
106 this.callsSeen.push({ model: req.model });
107 if (this.failTimes > 0) {
108 this.failTimes -= 1;
109 const { ProviderHttpError } = await import("./http.js");
110 throw new ProviderHttpError(this.failStatus, "mock provider failure", this.failStatus === 429 || this.failStatus >= 500);
111 }
112
113
114
115
116
117
118 if (req.metadata?.mode === "writing") {
119 yield* this.writingChat(req);
120 return;
121 }
122
123
124
125
126
127
128
129
130 if (req.metadata?.mode === "database") {
131 yield* this.databaseChat(req);
132 return;
133 }
134
135
136
137
138
139
140
141 if (req.metadata?.mode === "rag") {
142 yield* this.ragChat(req);
143 return;
144 }
145
146
147
148
149
150
151
152 if (req.metadata?.mode === "generate") {
153 yield* this.generateChat(req);
154 return;
155 }
156
157
158
159
160
161
162
163 if (req.metadata?.mode === "assistant") {
164 yield* this.assistantChat(req);
165 return;
166 }
167
168
169
170
171
172 const lastUser = [...req.messages].reverse().find((m) => m.role === "user");
173 const text = typeof lastUser?.content === "string" ? lastUser.content.slice(0, 40) : "ok";
174
175
176
177 const sysMsg = [...req.messages].filter((m) => m.role === "system").pop();
178 const sysTail =
179 sysMsg && typeof sysMsg.content === "string" ? ` [SYS:${sysMsg.content.slice(-200)}]` : "";
180 const words = text.length === 0 ? ["ok"] : text.split(" ");
181 const pace = async (): Promise<void> => {
182 if (this.deltaDelayMs <= 0) return;
183 if (req.signal?.aborted) throw abortError();
184 await new Promise<void>((resolve) => {
185 const timer = setTimeout(resolve, this.deltaDelayMs);
186 const onAbort = (): void => {
187 clearTimeout(timer);
188 resolve();
189 };
190 req.signal?.addEventListener("abort", onAbort, { once: true });
191
192
193 });
194 if (req.signal?.aborted) throw abortError();
195 };
196 for (let i = 0; i < words.length; i++) {
197 await pace();
198 yield { delta: (i > 0 ? " " : "") + words[i]!, done: false };
199
200
201
202
203 if (this.failAfterDeltas > 0) {
204 this.failAfterDeltas -= 1;
205 if (this.failAfterDeltas === 0) {
206 throw new ProviderHttpError(
207 this.failStatus,
208 "mock provider mid-stream failure",
209 this.failStatus === 429 || this.failStatus >= 500,
210 );
211 }
212 }
213 }
214
215
216
217 if (this.failAfterDeltas > 0) {
218 this.failAfterDeltas = 0;
219 throw new ProviderHttpError(
220 this.failStatus,
221 "mock provider mid-stream failure",
222 this.failStatus === 429 || this.failStatus >= 500,
223 );
224 }
225 if (sysTail) {
226 await pace();
227 yield { delta: sysTail, done: false };
228 }
229 if (req.tools && req.tools.length > 0) {
230 const first = req.tools[0]!;
231 yield {
232 toolCallDelta: { id: "mock_call_0", name: first.name, arguments: { echoed: text } },
233 done: false,
234 };
235 yield {
236 usage: { in: Math.ceil(text.length / 4), out: Math.ceil(text.length / 4), total: Math.ceil(text.length / 2) },
237 finishReason: "tool_calls",
238 done: true,
239 };
240 return;
241 }
242 yield {
243 usage: { in: Math.ceil(text.length / 4), out: Math.ceil(text.length / 4), total: Math.ceil(text.length / 2) },
244 finishReason: "stop",
245 done: true,
246 };
247 }
248
249
272 private async *writingChat(req: {
273 messages: Array<{ role: string; content: unknown }>;
274 tools?: Array<{ name: string; description: string; parameters: Record<string, unknown> }>;
275 metadata?: Record<string, string>;
276 signal?: AbortSignal;
277 }): AsyncIterable<{
278 delta?: string;
279 toolCallDelta?: { id: string; name: string; arguments: Record<string, unknown> };
280 usage?: { in: number; out: number; total: number };
281 finishReason?: "stop" | "tool_calls" | "length" | "error";
282 done: boolean;
283 }> {
284 const action = req.metadata?.action ?? "rewrite";
285 const language = req.metadata?.language;
286 const blocks = parseWritingBlocks(req.messages);
287 const totalChars = blocks.reduce((n, b) => n + b.text.length, 0);
288 const usage = { in: Math.ceil(totalChars / 4), out: Math.ceil(totalChars / 4), total: Math.ceil(totalChars / 2) };
289
290 if (this.deltaDelayMs > 0) {
291 await new Promise<void>((resolve) => setTimeout(resolve, this.deltaDelayMs));
292 }
293 if (req.signal?.aborted) throw abortError();
294 if (this.failAfterDeltas > 0) {
295 this.failAfterDeltas = 0;
296 throw new ProviderHttpError(
297 this.failStatus,
298 "mock provider mid-stream failure",
299 this.failStatus === 429 || this.failStatus >= 500,
300 );
301 }
302
303 const toolName = req.tools?.[0]?.name ?? "generateBlocks";
304 const out: Array<{ blockId: string; newText: string }> = blocks.map((b) => ({
305 blockId: b.id,
306 newText: writingTransform(action, b.text, language),
307 }));
308 const forceUnknown = req.metadata?.forceUnknownId;
309 if (forceUnknown) out.push({ blockId: forceUnknown, newText: "Injected unknown block id (mock seam)." });
310
311 yield {
312 toolCallDelta: { id: "mock_writing_0", name: toolName, arguments: { blocks: out } },
313 done: false,
314 };
315 yield { usage, finishReason: "tool_calls", done: true };
316 }
317
318
332 private async *ragChat(req: {
333 messages: Array<{ role: string; content: unknown }>;
334 metadata?: Record<string, string>;
335 signal?: AbortSignal;
336 }): AsyncIterable<{
337 delta?: string;
338 usage?: { in: number; out: number; total: number };
339 finishReason?: "stop" | "tool_calls" | "length" | "error";
340 done: boolean;
341 }> {
342 const lastUser = [...req.messages].reverse().find((m) => m.role === "user");
343 const userText = typeof lastUser?.content === "string" ? lastUser.content : "";
344 const first = parseRagSource(userText);
345 const answer = first
346 ? `According to [${first.n}] (${first.label}): ${first.text.slice(0, 200)}`
347 : "I could not find any workspace content that answers this question. Try different wording.";
348 const usage = {
349 in: Math.ceil(userText.length / 4),
350 out: Math.ceil(answer.length / 4),
351 total: Math.ceil((userText.length + answer.length) / 4),
352 };
353 const words = answer.split(" ");
354 for (let i = 0; i < words.length; i++) {
355 if (this.deltaDelayMs > 0) {
356 await new Promise<void>((resolve) => setTimeout(resolve, this.deltaDelayMs));
357 }
358 if (req.signal?.aborted) throw abortError();
359 yield { delta: (i > 0 ? " " : "") + words[i]!, done: false };
360 }
361 yield { usage, finishReason: "stop", done: true };
362 }
363
364 async embed(req: { model: string; texts: string[]; metadata?: Record<string, string> }): Promise<{
365 model: string;
366 dimension: number;
367 vectors: number[][];
368 usage: { tokens: number };
369 latencyMs: number;
370 provider: "openai" | "anthropic" | "google" | "oss" | "custom";
371 }> {
372 this.calls += 1;
373 if (this.id === "anthropic") {
374 const { ProviderHttpError } = await import("./http.js");
375 throw new ProviderHttpError(501, "Anthropic has no embedding API", false);
376 }
377 const vectors = req.texts.map((t) => {
378 const v = new Array<number>(this.dimension).fill(0);
379 v[0] = Math.min(1, t.length / 100);
380 if (this.dimension > 1) v[1] = 0.5;
381 return v;
382 });
383 return {
384 model: req.model,
385 dimension: this.dimension,
386 vectors,
387 usage: { tokens: Math.ceil(req.texts.join(" ").length / 4) },
388 latencyMs: 0,
389 provider: this.id,
390 };
391 }
392
393 async health(): Promise<{ ok: boolean; latencyMs: number }> {
394 return { ok: true, latencyMs: 0 };
395 }
396
397 get callsSeenModels(): string[] {
398 return this.callsSeen.map((c) => c.model);
399 }
400
401
414 private async *databaseChat(req: {
415 messages: Array<{ role: string; content: unknown }>;
416 metadata?: Record<string, string>;
417 signal?: AbortSignal;
418 }): AsyncIterable<{
419 toolCallDelta?: { id: string; name: string; arguments: Record<string, unknown> };
420 usage?: { in: number; out: number; total: number };
421 finishReason?: "stop" | "tool_calls" | "length" | "error";
422 done: boolean;
423 }> {
424 if (this.deltaDelayMs > 0) await new Promise<void>((r) => setTimeout(r, this.deltaDelayMs));
425 if (req.signal?.aborted) throw abortError();
426
427 const userMsg = [...req.messages].reverse().find((m) => m.role === "user");
428 const text = typeof userMsg?.content === "string" ? userMsg.content : "";
429 const action = req.metadata?.action ?? "generate";
430
431
432
433
434 const armedAction = req.metadata?.hostile;
435 const hostile = armedAction === action;
436
437 let toolName = "createDatabase";
438 let args: Record<string, unknown> = {};
439 if (action === "extract") {
440 toolName = "insertRecords";
441 args = mockDatabaseExtract(text, hostile);
442 } else if (action === "formula") {
443 toolName = "generateFormula";
444 args = mockDatabaseFormula(text, hostile);
445 } else {
446 toolName = "createDatabase";
447 args = mockDatabaseGenerate(text, hostile);
448 }
449
450 yield { toolCallDelta: { id: "mock_db_0", name: toolName, arguments: args }, done: false };
451 const chars = JSON.stringify(args).length;
452 yield { usage: { in: Math.ceil(chars / 4), out: Math.ceil(chars / 4), total: Math.ceil(chars / 2) }, finishReason: "tool_calls", done: true };
453 }
454
455
470 private async *generateChat(req: {
471 messages: Array<{ role: string; content: unknown }>;
472 metadata?: Record<string, string>;
473 signal?: AbortSignal;
474 }): AsyncIterable<{
475 toolCallDelta?: { id: string; name: string; arguments: Record<string, unknown> };
476 usage?: { in: number; out: number; total: number };
477 finishReason?: "stop" | "tool_calls" | "length" | "error";
478 done: boolean;
479 }> {
480
481
482
483
484 const slowMs = req.metadata?.generateSlowMs ? Number.parseInt(req.metadata.generateSlowMs, 10) : this.deltaDelayMs;
485 if (slowMs > 0) {
486 const sig = req.signal;
487 if (sig?.aborted) throw abortError();
488 await new Promise<void>((resolve) => {
489 const timer = setTimeout(resolve, slowMs);
490 sig?.addEventListener("abort", () => {
491 clearTimeout(timer);
492 resolve();
493 }, { once: true });
494 });
495 if (sig?.aborted) throw abortError();
496 }
497
498 const userMsg = [...req.messages].reverse().find((m) => m.role === "user");
499 const text = typeof userMsg?.content === "string" ? userMsg.content : "";
500 const failStep = req.metadata?.generateFailStep ? Number.parseInt(req.metadata.generateFailStep, 10) : 0;
501
502 const plan = mockGeneratePlan(text, failStep);
503 const args = JSON.parse(JSON.stringify(plan)) as Record<string, unknown>;
504 yield { toolCallDelta: { id: "mock_gen_0", name: "generatePlan", arguments: args }, done: false };
505 const chars = JSON.stringify(args).length;
506 yield { usage: { in: Math.ceil(chars / 4), out: Math.ceil(chars / 4), total: Math.ceil(chars / 2) }, finishReason: "tool_calls", done: true };
507 }
508
509
521 private async *assistantChat(req: {
522 messages: Array<{ role: string; content: unknown }>;
523 tools?: Array<{ name: string; description: string; parameters: Record<string, unknown> }>;
524 metadata?: Record<string, string>;
525 signal?: AbortSignal;
526 }): AsyncIterable<{
527 toolCallDelta?: { id: string; name: string; arguments: Record<string, unknown> };
528 usage?: { in: number; out: number; total: number };
529 finishReason?: "stop" | "tool_calls" | "length" | "error";
530 done: boolean;
531 }> {
532 if (req.signal?.aborted) throw abortError();
533 const user = [...req.messages].reverse().find((m) => m.role === "user");
534 const text = typeof user?.content === "string" ? user.content : "";
535 const action = req.metadata?.action ?? "extract-actions";
536 const toolName = req.tools?.[0]?.name ?? "extractActionItems";
537
538
539 let args: Record<string, unknown>;
540 if (req.metadata?.hostile) {
541
542
543 args = { invalid: "hostile mock tool call" };
544 } else if (action === "extract-actions") {
545
546
547 const lines = text
548 .split("\n")
549 .map((l) => l.match(/^\s*(?:[-*]|\d+[.)])\s+(.+)$/)?.[1]?.trim())
550 .filter((l): l is string => Boolean(l && l.length > 1 && l.length <= 200));
551 args = { actions: lines.slice(0, 10).map((l) => ({ title: l.replace(/\.$/, "") })) };
552 } else if (action === "generate-faqs") {
553
554
555 const sources = [...text.matchAll(/^\[(\d+)\]\s+(.+)$/gm)];
556 const faqs = sources.slice(0, 15).map((m) => ({
557 question: `About ${m[2]!.trim()}?`,
558 answer: `Deterministic answer (per [${m[1]}]).`,
559 }));
560 args = {
561 title: `FAQ: ${sources.length || 1} source${sources.length === 1 ? "" : "s"}`,
562 faqs: faqs.length ? faqs : [{ question: "Anything?", answer: "Deterministic answer (per [1])." }],
563 };
564 } else if (action === "find-duplicates") {
565
566
567 const ids = [...text.matchAll(/^\[([^\]\s]+)\]\s+(.+)$/gm)].map((m) => m[1]!);
568 const groups: Array<{ pageIds: string[]; similarity: number; reason: string }> = [];
569 for (let i = 0; i + 1 < ids.length; i += 2) {
570 groups.push({ pageIds: [ids[i]!, ids[i + 1]!], similarity: 0.9, reason: "Overlapping content (mock)." });
571 }
572
573
574 if (req.metadata?.forgeId) groups.push({ pageIds: [ids[0] ?? "p1", "forged-id"], similarity: 0.8, reason: "Forged id (mock seam)." });
575 args = { groups };
576 } else if (action === "find-stale") {
577
578 const ids = [...text.matchAll(/^\[([^\]\s]+)\]\s+(.+)$/gm)].map((m) => m[1]!);
579 args = { pages: ids.slice(0, 2).map((id) => ({ pageId: id, reason: "Contains outdated dates (mock)." })) };
580 } else {
581
582 const titleLine = text.match(/^Page title:\n(.+)$/m)?.[1]?.trim() ?? "Untitled";
583 args = { title: titleLine ? `Revised: ${titleLine}`.slice(0, 200) : "Revised page", description: "Deterministic description grounded in the page (mock)." };
584 }
585
586 const serialized = JSON.stringify(args);
587 yield { toolCallDelta: { id: "mock_assistant_0", name: toolName, arguments: JSON.parse(serialized) }, done: false };
588 const chars = serialized.length;
589 yield { usage: { in: Math.ceil(chars / 4), out: Math.ceil(chars / 4), total: Math.ceil(chars / 2) }, finishReason: "tool_calls", done: true };
590 }
591}
592
593
594
595
603export function mockGeneratePlan(prompt: string, failStep = 0): { tools: Array<Record<string, unknown>> } {
604 const title = "Product Launch Plan";
605 const sections = ["Objectives", "Requirements", "Timeline", "Risks", "Team", "Meeting Notes"];
606 const tools: Array<Record<string, unknown>> = [
607 { name: "createPage", args: { title, blocks: [] } },
608 ];
609
610 const broken: Record<string, unknown> = {
611 name: "insertBlocks",
612 args: { pageRef: "no_such_ref", blocks: [{ type: "PARAGRAPH", content: { text: "boom" } }] },
613 };
614 if (failStep > 0 && failStep <= tools.length + 1) tools.splice(failStep - 1, 0, broken);
615
616
617 tools.push({
618 name: "insertBlocks",
619 args: {
620 pageRef: undefined,
621 blocks: sections.map((s) => ({ type: "HEADING_2", content: { text: s } })),
622 },
623 });
624
625
626
627
628 tools.push({
629 name: "createDatabase",
630 args: {
631 ref: "tasks",
632 pageRef: undefined,
633 title: "Tasks",
634 properties: [{ name: "Due", type: "date" }],
635 sampleRecords: [
636 { properties: { Title: "Draft launch announcement", Due: "2026-01-15" } },
637 { properties: { Title: "Schedule launch review", Due: "2026-01-20" } },
638 ],
639 },
640 });
641
642
643 void prompt;
644 return { tools };
645}
646
647
648
649
650export function mockDatabaseGenerate(prompt: string, hostile: boolean): Record<string, unknown> {
651 const title = (prompt.trim().slice(0, 60) || "Generated database").replace(/[^\p{L}\p{N}\s:—-]/gu, "").trim() || "Generated database";
652 if (hostile) {
653
654
655 return {
656 title,
657 description: "hostile (mock)",
658 properties: [{ name: "Broken", type: "relation" }],
659 sampleRecords: [],
660 };
661 }
662 return {
663 title,
664 description: `Generated from: ${prompt.trim().slice(0, 80)}`,
665
666
667
668 properties: [
669 { name: "Name", type: "text" },
670 { name: "State", type: "select", options: [{ name: "To Do" }, { name: "Done" }] },
671 { name: "Due", type: "date" },
672 { name: "Done?", type: "checkbox" },
673 ],
674
675
676 sampleRecords: [
677 { properties: { Title: "First item", Name: "First item", State: "To Do", Due: "2026-01-01", "Done?": false } },
678 { properties: { Title: "Second item", Name: "Second item", State: "Done", Due: "2026-01-02", "Done?": true } },
679 ],
680 };
681}
682
683
692export function mockDatabaseExtract(text: string, hostile: boolean): Record<string, unknown> {
693 const props: Array<{ id: string; name: string; type: string }> = [];
694 {
695 const re = /\[PROP ([A-Za-z0-9_-]+)\]\s+([^\n<]+?)\s*\(([^)\n]+)\)/g;
696 let m: RegExpExecArray | null;
697 while ((m = re.exec(text)) !== null) props.push({ id: m[1]!, name: m[2]!.trim(), type: m[3]!.trim() });
698 }
699
700
701 const paste = /<<<PASTE\n([\s\S]*?)\nPASTE>>>/.exec(text);
702 const pasted = paste ? paste[1]! : text;
703 const table = parseMarkdownTable(pasted);
704 if (hostile) {
705
706
707 return { records: [{ properties: { does_not_exist: "x" } }], ignoredColumns: [] };
708 }
709 if (!table.isTable || table.rows.length === 0) {
710 return { records: [], ignoredColumns: table.columns };
711 }
712 const mapping = mapColumnsToProperties(table.columns, props.map((p) => ({ id: p.id, name: p.name })));
713 const records = table.rows.map((row) => {
714 const properties: Record<string, unknown> = {};
715 for (const pair of mapping.mapped) {
716 const cell = row[pair.column] ?? "";
717 if (cell !== "") properties[pair.propertyId] = cell;
718 }
719 return { properties };
720 });
721 return { records, ignoredColumns: mapping.ignored };
722}
723
724
731export function mockDatabaseFormula(text: string, hostile: boolean): Record<string, unknown> {
732 const props: Array<{ name: string; type: string }> = [];
733 const re = /\[PROP ([^\n\]]+?)\]\s+([a-z_]+)/g;
734 let m: RegExpExecArray | null;
735 while ((m = re.exec(text)) !== null) props.push({ name: m[1]!.trim(), type: m[2]! });
736 if (hostile) {
737
738
739 return { expression: "explode(prop(\"x\"))", explanation: "hostile (unknown function)" };
740 }
741 const [a] = props;
742 if (a) {
743 return { expression: `if(prop("${a.name}") == "Done", 100, 0)`, explanation: `Deterministic formula over ${a.name}.` };
744 }
745 return { expression: "now()", explanation: "No properties; a now() expression." };
746}
747
748
749
750
755export function parseWritingBlocks(messages: Array<{ role: string; content: unknown }>): Array<{ id: string; text: string }> {
756 const userMsg = [...messages].reverse().find((m) => m.role === "user");
757 if (!userMsg) return [];
758 const text = typeof userMsg.content === "string" ? userMsg.content : "";
759 const out: Array<{ id: string; text: string }> = [];
760 const re = /\[SELECTED ([A-Za-z0-9_-]+)\]\n?([\s\S]*?)\n?\[\/SELECTED \1\]/g;
761 let m: RegExpExecArray | null;
762 while ((m = re.exec(text)) !== null) {
763 out.push({ id: m[1]!, text: (m[2] ?? "").trim() });
764 }
765 return out;
766}
767
768
775export function parseRagSource(
776 userText: string,
777): { n: number; label: string; text: string } | null {
778
779
780
781
782
783
784
785 const m = /^\[(\d+)\] Source: ([^\n]+)\n([\s\S]*?)(?=\n\[\d+\] Source: |\nQuestion: |$)/.exec(userText);
786 if (!m) return null;
787 return { n: Number(m[1]!), label: m[2]!.trim(), text: (m[3] ?? "").trim() };
788}
789
790
791export function writingTransform(action: string, text: string, language?: string): string {
792 const t = text.trim();
793 switch (action) {
794 case "summarize":
795 return `Summarized: ${t.slice(0, 80)}`;
796 case "improve":
797 return `Improved: ${t}`;
798 case "translate":
799 return `Translated to ${language ?? "the target language"}: ${t}`;
800 case "rewrite":
801 default:
802 return `Rewritten: ${t}`;
803 }
804}
805