@ryanfw/prompt-orchestration-pipeline 0.16.3 → 0.17.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.
@@ -24,11 +24,75 @@ import {
24
24
  const sections = [
25
25
  { id: "environment", label: "Environment", icon: Key },
26
26
  { id: "getting-started", label: "Getting Started", icon: FileText },
27
+ { id: "pipeline-config", label: "Pipeline Config", icon: Folder },
27
28
  { id: "io-api", label: "IO API", icon: Database },
28
29
  { id: "llm-api", label: "LLM API", icon: Cpu },
29
30
  { id: "validation", label: "Validation", icon: Shield },
30
31
  ];
31
32
 
33
+ // Sample pipeline.json for documentation
34
+ const samplePipelineJson = {
35
+ name: "content-generation",
36
+ version: "1.0.0",
37
+ description: "Demo pipeline showcasing multi-stage LLM workflows",
38
+ tasks: ["research", "analysis", "synthesis", "formatting"],
39
+ taskConfig: {
40
+ research: {
41
+ maxRetries: 3,
42
+ },
43
+ },
44
+ llm: {
45
+ provider: "anthropic",
46
+ model: "claude-sonnet-4-20250514",
47
+ },
48
+ };
49
+
50
+ // Pipeline.json field definitions
51
+ const pipelineFields = [
52
+ {
53
+ name: "name",
54
+ required: true,
55
+ type: "string",
56
+ description:
57
+ "Unique identifier for the pipeline. Used to reference this pipeline from seed files.",
58
+ },
59
+ {
60
+ name: "version",
61
+ required: false,
62
+ type: "string",
63
+ description:
64
+ 'Semantic version of the pipeline (e.g., "1.0.0"). Useful for tracking changes.',
65
+ },
66
+ {
67
+ name: "description",
68
+ required: false,
69
+ type: "string",
70
+ description: "Human-readable description of what this pipeline does.",
71
+ },
72
+ {
73
+ name: "tasks",
74
+ required: true,
75
+ type: "string[]",
76
+ description:
77
+ "Ordered array of task names to execute. Each task must be registered in the task index.",
78
+ },
79
+ {
80
+ name: "taskConfig",
81
+ required: false,
82
+ type: "object",
83
+ description:
84
+ "Per-task configuration overrides. Keys are task names, values are config objects passed to stages.",
85
+ },
86
+ {
87
+ name: "llm",
88
+ required: false,
89
+ type: "{ provider, model }",
90
+ description:
91
+ "Pipeline-level LLM override. When set, ALL task LLM calls are routed to this provider/model.",
92
+ isNew: true,
93
+ },
94
+ ];
95
+
32
96
  // IO Functions organized by category
33
97
  const writeFunctions = [
34
98
  {
@@ -125,6 +189,7 @@ const envVars = [
125
189
  { name: "GEMINI_API_KEY", provider: "Google Gemini" },
126
190
  { name: "DEEPSEEK_API_KEY", provider: "DeepSeek" },
127
191
  { name: "ZHIPU_API_KEY", provider: "Zhipu" },
192
+ { name: "MOONSHOT_API_KEY", provider: "Moonshot" },
128
193
  ];
129
194
 
130
195
  // Collapsible Section Component
@@ -231,7 +296,7 @@ export default function CodePage() {
231
296
  }
232
297
  });
233
298
  },
234
- { rootMargin: "-100px 0px -66% 0px" }
299
+ { rootMargin: "-100px 0px -66% 0px" },
235
300
  );
236
301
 
237
302
  sections.forEach(({ id }) => {
@@ -405,6 +470,140 @@ export default function CodePage() {
405
470
  </div>
406
471
  </CollapsibleSection>
407
472
 
473
+ {/* Pipeline Config Section */}
474
+ <CollapsibleSection
475
+ id="pipeline-config"
476
+ title="Pipeline Configuration (pipeline.json)"
477
+ icon={Folder}
478
+ defaultOpen={true}
479
+ >
480
+ <Text as="p" size="3" className="text-gray-600 mb-4">
481
+ Each pipeline is defined by a <Code size="2">pipeline.json</Code>{" "}
482
+ file in its directory. This file specifies which tasks to run and
483
+ optional configuration overrides.
484
+ </Text>
485
+
486
+ <div className="space-y-6">
487
+ {/* Fields Table */}
488
+ <div>
489
+ <Text size="2" weight="medium" className="mb-3 block">
490
+ Fields
491
+ </Text>
492
+ <div className="border border-gray-200 rounded-lg overflow-hidden">
493
+ <Table.Root>
494
+ <Table.Header>
495
+ <Table.Row>
496
+ <Table.ColumnHeaderCell className="bg-gray-50">
497
+ Field
498
+ </Table.ColumnHeaderCell>
499
+ <Table.ColumnHeaderCell className="bg-gray-50">
500
+ Type
501
+ </Table.ColumnHeaderCell>
502
+ <Table.ColumnHeaderCell className="bg-gray-50">
503
+ Required
504
+ </Table.ColumnHeaderCell>
505
+ <Table.ColumnHeaderCell className="bg-gray-50">
506
+ Description
507
+ </Table.ColumnHeaderCell>
508
+ </Table.Row>
509
+ </Table.Header>
510
+ <Table.Body>
511
+ {pipelineFields.map((field) => (
512
+ <Table.Row key={field.name}>
513
+ <Table.RowHeaderCell>
514
+ <Flex align="center" gap="2">
515
+ <Code size="2">{field.name}</Code>
516
+ {field.isNew && (
517
+ <span className="px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded">
518
+ NEW
519
+ </span>
520
+ )}
521
+ </Flex>
522
+ </Table.RowHeaderCell>
523
+ <Table.Cell>
524
+ <Code size="1" className="text-gray-600">
525
+ {field.type}
526
+ </Code>
527
+ </Table.Cell>
528
+ <Table.Cell>
529
+ {field.required ? (
530
+ <span className="text-red-600 font-medium">
531
+ Yes
532
+ </span>
533
+ ) : (
534
+ <span className="text-gray-400">No</span>
535
+ )}
536
+ </Table.Cell>
537
+ <Table.Cell className="text-gray-600 text-sm">
538
+ {field.description}
539
+ </Table.Cell>
540
+ </Table.Row>
541
+ ))}
542
+ </Table.Body>
543
+ </Table.Root>
544
+ </div>
545
+ </div>
546
+
547
+ {/* Example */}
548
+ <div>
549
+ <Text size="2" weight="medium" className="mb-2 block">
550
+ Example
551
+ </Text>
552
+ <CopyableCodeBlock maxHeight="280px">
553
+ {JSON.stringify(samplePipelineJson, null, 2)}
554
+ </CopyableCodeBlock>
555
+ </div>
556
+
557
+ {/* LLM Override Details */}
558
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
559
+ <Flex align="center" gap="2" className="mb-2">
560
+ <Cpu className="h-4 w-4 text-blue-600" />
561
+ <Text size="2" weight="medium" className="text-blue-800">
562
+ Pipeline-Level LLM Override
563
+ </Text>
564
+ <span className="px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded">
565
+ NEW
566
+ </span>
567
+ </Flex>
568
+ <Text as="p" size="2" className="text-blue-700 mb-3">
569
+ When the <Code size="2">llm</Code> field is set in
570
+ pipeline.json, ALL LLM calls from task stages are
571
+ automatically routed to the specified provider and model —
572
+ regardless of what the task code requests.
573
+ </Text>
574
+ <ul className="space-y-1 text-sm text-blue-700">
575
+ <li className="flex items-start gap-2">
576
+ <span className="text-blue-500 mt-0.5">•</span>
577
+ <span>
578
+ Tasks calling <Code size="1">llm.deepseek.chat()</Code>{" "}
579
+ will use the override provider/model
580
+ </span>
581
+ </li>
582
+ <li className="flex items-start gap-2">
583
+ <span className="text-blue-500 mt-0.5">•</span>
584
+ <span>
585
+ Original provider/model is preserved in{" "}
586
+ <Code size="1">metadata.originalProvider</Code>
587
+ </span>
588
+ </li>
589
+ <li className="flex items-start gap-2">
590
+ <span className="text-blue-500 mt-0.5">•</span>
591
+ <span>
592
+ Useful for A/B testing, cost control, or switching
593
+ providers during outages
594
+ </span>
595
+ </li>
596
+ </ul>
597
+ </div>
598
+
599
+ {/* File Location */}
600
+ <div className="text-sm text-gray-500">
601
+ <span>Location: </span>
602
+ <Code size="2">{"{pipelineDir}"}/pipeline.json</Code>
603
+ </div>
604
+ </div>
605
+ </CollapsibleSection>
606
+
408
607
  {/* IO API Section */}
409
608
  <CollapsibleSection
410
609
  id="io-api"
@@ -529,7 +728,7 @@ export default function CodePage() {
529
728
  </Code>
530
729
  </Table.Cell>
531
730
  </Table.Row>
532
- ))
731
+ )),
533
732
  )}
534
733
  </Table.Body>
535
734
  </Table.Root>
@@ -6,6 +6,7 @@ import {
6
6
  tryParseJSON,
7
7
  ensureJsonResponseFormat,
8
8
  ProviderJsonParseError,
9
+ createProviderError,
9
10
  } from "./base.js";
10
11
  import { createLogger } from "../core/logger.js";
11
12
 
@@ -70,10 +71,10 @@ export async function anthropicChat({
70
71
  });
71
72
 
72
73
  if (!response.ok) {
73
- const error = await response
74
+ const errorBody = await response
74
75
  .json()
75
76
  .catch(() => ({ error: response.statusText }));
76
- throw { status: response.status, ...error };
77
+ throw createProviderError(response.status, errorBody, response.statusText);
77
78
  }
78
79
 
79
80
  const data = await response.json();
@@ -89,6 +89,25 @@ export function tryParseJSON(text) {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Creates a proper Error instance from an HTTP error response.
94
+ * This ensures errors have proper stack traces and don't cause
95
+ * "UnhandledPromiseRejection: #<Object>" crashes.
96
+ *
97
+ * @param {number} status - HTTP status code
98
+ * @param {Object} errorBody - Parsed error response body
99
+ * @param {string} fallbackMessage - Fallback message if none in errorBody
100
+ * @returns {Error} Error instance with status and details attached
101
+ */
102
+ export function createProviderError(status, errorBody, fallbackMessage = "Request failed") {
103
+ const message = errorBody?.error?.message || errorBody?.message || fallbackMessage;
104
+ const err = new Error(`[${status}] ${message}`);
105
+ err.status = status;
106
+ err.code = errorBody?.error?.code || errorBody?.code;
107
+ err.details = errorBody;
108
+ return err;
109
+ }
110
+
92
111
  /**
93
112
  * Error thrown when JSON response format is required but not provided
94
113
  */
@@ -0,0 +1,156 @@
1
+ import { spawn, spawnSync } from "child_process";
2
+ import {
3
+ extractMessages,
4
+ isRetryableError,
5
+ sleep,
6
+ stripMarkdownFences,
7
+ tryParseJSON,
8
+ ensureJsonResponseFormat,
9
+ ProviderJsonParseError,
10
+ } from "./base.js";
11
+ import { createLogger } from "../core/logger.js";
12
+
13
+ const logger = createLogger("ClaudeCode");
14
+
15
+ /**
16
+ * Check if Claude Code CLI is available
17
+ * @returns {boolean}
18
+ */
19
+ export function isClaudeCodeAvailable() {
20
+ try {
21
+ const result = spawnSync("claude", ["--version"], {
22
+ encoding: "utf8",
23
+ timeout: 5000,
24
+ });
25
+ return result.status === 0;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Chat with Claude via the Claude Code CLI
33
+ * @param {Object} options
34
+ * @param {Array} options.messages - Array of message objects with role and content
35
+ * @param {string} [options.model="sonnet"] - Model name: sonnet, opus, or haiku
36
+ * @param {number} [options.maxTokens] - Maximum tokens in response
37
+ * @param {number} [options.maxTurns=1] - Maximum conversation turns
38
+ * @param {string} [options.responseFormat="json"] - Response format
39
+ * @param {number} [options.maxRetries=3] - Maximum retry attempts
40
+ * @returns {Promise<{content: any, text: string, usage: Object, raw: any}>}
41
+ */
42
+ export async function claudeCodeChat({
43
+ messages,
44
+ model = "sonnet",
45
+ maxTokens,
46
+ maxTurns = 1,
47
+ responseFormat = "json",
48
+ maxRetries = 3,
49
+ }) {
50
+ ensureJsonResponseFormat(responseFormat, "ClaudeCode");
51
+
52
+ const { systemMsg, userMsg } = extractMessages(messages);
53
+
54
+ const args = [
55
+ "-p",
56
+ userMsg,
57
+ "--output-format",
58
+ "json",
59
+ "--model",
60
+ model,
61
+ "--max-turns",
62
+ String(maxTurns),
63
+ ];
64
+
65
+ if (systemMsg) {
66
+ args.push("--system-prompt", systemMsg);
67
+ }
68
+
69
+ if (maxTokens) {
70
+ args.push("--max-tokens", String(maxTokens));
71
+ }
72
+
73
+ logger.log("Spawning claude CLI", { model, argsCount: args.length });
74
+
75
+ let lastError;
76
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
77
+ try {
78
+ const result = await spawnClaude(args);
79
+ return parseClaudeResponse(result, model);
80
+ } catch (err) {
81
+ lastError = err;
82
+ if (attempt < maxRetries && isRetryableError(err)) {
83
+ const delay = Math.pow(2, attempt) * 1000;
84
+ logger.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
85
+ await sleep(delay);
86
+ continue;
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+ throw lastError;
92
+ }
93
+
94
+ /**
95
+ * Spawn the claude CLI and collect output
96
+ * @param {string[]} args - CLI arguments
97
+ * @returns {Promise<string>} - stdout content
98
+ */
99
+ function spawnClaude(args) {
100
+ return new Promise((resolve, reject) => {
101
+ const proc = spawn("claude", args, { stdio: ["ignore", "pipe", "pipe"] });
102
+
103
+ let stdout = "";
104
+ let stderr = "";
105
+
106
+ proc.stdout.on("data", (data) => {
107
+ stdout += data.toString();
108
+ });
109
+
110
+ proc.stderr.on("data", (data) => {
111
+ stderr += data.toString();
112
+ });
113
+
114
+ proc.on("error", (err) => {
115
+ reject(new Error(`Failed to spawn claude CLI: ${err.message}`));
116
+ });
117
+
118
+ proc.on("close", (code) => {
119
+ if (code === 0) {
120
+ resolve(stdout);
121
+ } else {
122
+ reject(new Error(`claude CLI exited with code ${code}: ${stderr}`));
123
+ }
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Parse the JSON response from Claude CLI
130
+ * @param {string} stdout - Raw stdout from CLI
131
+ * @param {string} model - Model name for error reporting
132
+ * @returns {{content: any, text: string, usage: Object, raw: any}}
133
+ */
134
+ function parseClaudeResponse(stdout, model) {
135
+ const jsonResponse = tryParseJSON(stdout);
136
+ if (!jsonResponse) {
137
+ throw new ProviderJsonParseError(
138
+ "claudecode",
139
+ model,
140
+ stdout.slice(0, 200),
141
+ "Failed to parse Claude CLI JSON response"
142
+ );
143
+ }
144
+
145
+ // Extract text content from response
146
+ const rawText = jsonResponse.result ?? jsonResponse.text ?? "";
147
+ const cleanedText = stripMarkdownFences(rawText);
148
+ const parsed = tryParseJSON(cleanedText) ?? cleanedText;
149
+
150
+ return {
151
+ content: parsed,
152
+ text: rawText,
153
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
154
+ raw: jsonResponse,
155
+ };
156
+ }
@@ -6,6 +6,7 @@ import {
6
6
  tryParseJSON,
7
7
  ensureJsonResponseFormat,
8
8
  ProviderJsonParseError,
9
+ createProviderError,
9
10
  } from "./base.js";
10
11
  import { createLogger } from "../core/logger.js";
11
12
 
@@ -77,10 +78,10 @@ export async function deepseekChat({
77
78
  );
78
79
 
79
80
  if (!response.ok) {
80
- const error = await response
81
+ const errorBody = await response
81
82
  .json()
82
83
  .catch(() => ({ error: response.statusText }));
83
- throw { status: response.status, ...error };
84
+ throw createProviderError(response.status, errorBody, response.statusText);
84
85
  }
85
86
 
86
87
  // Streaming mode - return async generator for real-time chunks
@@ -0,0 +1,218 @@
1
+ import {
2
+ extractMessages,
3
+ isRetryableError,
4
+ sleep,
5
+ stripMarkdownFences,
6
+ tryParseJSON,
7
+ ProviderJsonParseError,
8
+ createProviderError,
9
+ } from "./base.js";
10
+ import { createLogger } from "../core/logger.js";
11
+
12
+ const logger = createLogger("Moonshot");
13
+
14
+ export async function moonshotChat({
15
+ messages,
16
+ model = "moonshot-v1-128k",
17
+ temperature = 0.7,
18
+ maxTokens,
19
+ responseFormat = "json_object",
20
+ topP,
21
+ frequencyPenalty,
22
+ presencePenalty,
23
+ stop,
24
+ stream = false,
25
+ maxRetries = 3,
26
+ }) {
27
+ const isJsonMode =
28
+ responseFormat?.type === "json_object" ||
29
+ responseFormat?.type === "json_schema" ||
30
+ responseFormat === "json" ||
31
+ responseFormat === "json_object";
32
+
33
+ logger.log("moonshotChat called", { model, stream, maxRetries, isJsonMode });
34
+
35
+ if (!process.env.MOONSHOT_API_KEY) {
36
+ throw new Error("Moonshot API key not configured");
37
+ }
38
+
39
+ const { systemMsg, userMsg } = extractMessages(messages);
40
+
41
+ logger.log("Messages extracted", {
42
+ systemMsgLength: systemMsg?.length,
43
+ userMsgLength: userMsg?.length,
44
+ });
45
+
46
+ let lastError;
47
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
48
+ if (attempt > 0) {
49
+ const sleepMs = Math.pow(2, attempt) * 1000;
50
+ logger.log("Retry attempt", { attempt, sleepMs });
51
+ await sleep(sleepMs);
52
+ }
53
+
54
+ try {
55
+ logger.log("Sending request to Moonshot API", { attempt, model });
56
+ // Thinking models only accept temperature=1
57
+ //const isThinkingModel = model.includes("thinking");
58
+ //const effectiveTemperature = isThinkingModel ? 1 : temperature;
59
+
60
+ const requestBody = {
61
+ model,
62
+ messages: [
63
+ { role: "system", content: systemMsg },
64
+ { role: "user", content: userMsg },
65
+ ],
66
+ temperature: 1,
67
+ max_tokens: maxTokens,
68
+ top_p: topP,
69
+ frequency_penalty: frequencyPenalty,
70
+ presence_penalty: presencePenalty,
71
+ stop,
72
+ stream,
73
+ };
74
+
75
+ if (isJsonMode && !stream) {
76
+ requestBody.response_format = { type: "json_object" };
77
+ }
78
+
79
+ logger.log("About to call fetch...");
80
+ const response = await fetch(
81
+ "https://api.moonshot.ai/v1/chat/completions",
82
+ {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ Authorization: `Bearer ${process.env.MOONSHOT_API_KEY}`,
87
+ },
88
+ body: JSON.stringify(requestBody),
89
+ },
90
+ );
91
+ logger.log("Fetch returned", {
92
+ status: response.status,
93
+ ok: response.ok,
94
+ });
95
+
96
+ if (!response.ok) {
97
+ const errorBody = await response
98
+ .json()
99
+ .catch(() => ({ error: response.statusText }));
100
+
101
+ // Provide more helpful error message for authentication failures
102
+ if (response.status === 401) {
103
+ const enhancedError = createProviderError(
104
+ response.status,
105
+ errorBody,
106
+ "Invalid Moonshot API key. Please verify your MOONSHOT_API_KEY environment variable is correct and has not expired. Get your API key at https://platform.moonshot.ai/",
107
+ );
108
+ throw enhancedError;
109
+ }
110
+
111
+ throw createProviderError(
112
+ response.status,
113
+ errorBody,
114
+ response.statusText,
115
+ );
116
+ }
117
+
118
+ // Step 6: Handle streaming response path
119
+ if (stream) {
120
+ logger.log("Handling streaming response");
121
+ return createStreamGenerator(response.body);
122
+ }
123
+
124
+ // Step 7: Handle non-streaming response parsing
125
+ logger.log("Parsing JSON response...");
126
+ const data = await response.json();
127
+ logger.log("JSON parsed successfully", {
128
+ hasChoices: !!data.choices,
129
+ choicesCount: data.choices?.length,
130
+ });
131
+ const rawContent = data.choices[0].message.content;
132
+
133
+ const content = stripMarkdownFences(rawContent);
134
+
135
+ if (isJsonMode) {
136
+ const parsed = tryParseJSON(content);
137
+ if (!parsed) {
138
+ throw new ProviderJsonParseError(
139
+ "Moonshot",
140
+ model,
141
+ content.substring(0, 200),
142
+ "Failed to parse JSON response from Moonshot API",
143
+ );
144
+ }
145
+ return {
146
+ content: parsed,
147
+ usage: data.usage,
148
+ raw: data,
149
+ };
150
+ }
151
+
152
+ return {
153
+ content,
154
+ usage: data.usage,
155
+ raw: data,
156
+ };
157
+ } catch (error) {
158
+ lastError = error;
159
+ logger.warn("Attempt failed", {
160
+ attempt,
161
+ errorMessage: error.message || error,
162
+ errorStatus: error.status,
163
+ });
164
+
165
+ if (error.status === 401) throw error;
166
+
167
+ if (isRetryableError(error) && attempt < maxRetries) {
168
+ continue;
169
+ }
170
+
171
+ if (attempt === maxRetries) throw error;
172
+ }
173
+ }
174
+
175
+ throw lastError || new Error(`Failed after ${maxRetries + 1} attempts`);
176
+ }
177
+
178
+ /**
179
+ * Create async generator for streaming Moonshot responses.
180
+ * Moonshot uses Server-Sent Events format with "data:" prefix.
181
+ */
182
+ async function* createStreamGenerator(stream) {
183
+ const decoder = new TextDecoder();
184
+ const reader = stream.getReader();
185
+ let buffer = "";
186
+
187
+ try {
188
+ while (true) {
189
+ const { done, value } = await reader.read();
190
+ if (done) break;
191
+
192
+ buffer += decoder.decode(value, { stream: true });
193
+ const lines = buffer.split("\n");
194
+ buffer = lines.pop(); // Keep incomplete line
195
+
196
+ for (const line of lines) {
197
+ if (line.startsWith("data: ")) {
198
+ const data = line.slice(6);
199
+ if (data === "[DONE]") continue;
200
+
201
+ try {
202
+ const parsed = JSON.parse(data);
203
+ const content = parsed.choices?.[0]?.delta?.content;
204
+ // Skip only truly empty chunks; preserve whitespace-only content
205
+ if (content !== undefined && content !== null && content !== "") {
206
+ yield { content };
207
+ }
208
+ } catch (e) {
209
+ // Skip malformed JSON
210
+ logger.warn("Failed to parse stream chunk:", e);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ } finally {
216
+ reader.releaseLock();
217
+ }
218
+ }