@ryanfw/prompt-orchestration-pipeline 0.6.0 → 0.8.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.
Files changed (62) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -2
  3. package/src/api/validators/json.js +39 -0
  4. package/src/components/DAGGrid.jsx +392 -303
  5. package/src/components/JobCard.jsx +13 -11
  6. package/src/components/JobDetail.jsx +41 -71
  7. package/src/components/JobTable.jsx +32 -22
  8. package/src/components/Layout.jsx +0 -21
  9. package/src/components/LiveText.jsx +47 -0
  10. package/src/components/TaskDetailSidebar.jsx +216 -0
  11. package/src/components/TimerText.jsx +82 -0
  12. package/src/components/ui/RestartJobModal.jsx +140 -0
  13. package/src/components/ui/toast.jsx +138 -0
  14. package/src/config/models.js +322 -0
  15. package/src/config/statuses.js +119 -0
  16. package/src/core/config.js +2 -164
  17. package/src/core/file-io.js +1 -1
  18. package/src/core/module-loader.js +54 -40
  19. package/src/core/pipeline-runner.js +52 -26
  20. package/src/core/status-writer.js +147 -3
  21. package/src/core/symlink-bridge.js +55 -0
  22. package/src/core/symlink-utils.js +94 -0
  23. package/src/core/task-runner.js +267 -443
  24. package/src/llm/index.js +167 -52
  25. package/src/pages/Code.jsx +57 -3
  26. package/src/pages/PipelineDetail.jsx +92 -22
  27. package/src/pages/PromptPipelineDashboard.jsx +15 -36
  28. package/src/providers/anthropic.js +83 -69
  29. package/src/providers/base.js +52 -0
  30. package/src/providers/deepseek.js +17 -34
  31. package/src/providers/gemini.js +226 -0
  32. package/src/providers/openai.js +36 -106
  33. package/src/providers/zhipu.js +136 -0
  34. package/src/ui/client/adapters/job-adapter.js +16 -26
  35. package/src/ui/client/api.js +134 -0
  36. package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -178
  37. package/src/ui/client/index.css +9 -0
  38. package/src/ui/client/index.html +1 -0
  39. package/src/ui/client/main.jsx +18 -15
  40. package/src/ui/client/time-store.js +161 -0
  41. package/src/ui/config-bridge.js +15 -24
  42. package/src/ui/config-bridge.node.js +15 -24
  43. package/src/ui/dist/assets/{index-WgJUlSmE.js → index-DqkbzXZ1.js} +1408 -771
  44. package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
  45. package/src/ui/dist/index.html +3 -2
  46. package/src/ui/public/favicon.svg +12 -0
  47. package/src/ui/server.js +231 -38
  48. package/src/ui/transformers/status-transformer.js +18 -31
  49. package/src/ui/watcher.js +5 -1
  50. package/src/utils/dag.js +8 -4
  51. package/src/utils/duration.js +13 -19
  52. package/src/utils/formatters.js +27 -0
  53. package/src/utils/geometry-equality.js +83 -0
  54. package/src/utils/pipelines.js +5 -1
  55. package/src/utils/time-utils.js +40 -0
  56. package/src/utils/token-cost-calculator.js +4 -7
  57. package/src/utils/ui.jsx +14 -16
  58. package/src/components/ui/select.jsx +0 -27
  59. package/src/lib/utils.js +0 -6
  60. package/src/ui/client/hooks/useTicker.js +0 -26
  61. package/src/ui/config-bridge.browser.js +0 -149
  62. package/src/ui/dist/assets/style-x0V-5m8e.css +0 -62
@@ -0,0 +1,82 @@
1
+ import React, { useSyncExternalStore, useId, useState, useEffect } from "react";
2
+ import {
3
+ subscribe,
4
+ getSnapshot,
5
+ getServerSnapshot,
6
+ addCadenceHint,
7
+ removeCadenceHint,
8
+ } from "../ui/client/time-store.js";
9
+ import { fmtDuration } from "../utils/duration.js";
10
+
11
+ /**
12
+ * TimerText component for displaying live-updating durations without parent re-renders
13
+ *
14
+ * @param {Object} props
15
+ * @param {number} props.startMs - Start timestamp in milliseconds
16
+ * @param {number|null} props.endMs - End timestamp in milliseconds (null for ongoing timers)
17
+ * @param {"second"|"minute"} props.granularity - Update granularity
18
+ * @param {Function} props.format - Duration formatting function (defaults to fmtDuration)
19
+ * @param {string} props.className - CSS className for styling
20
+ */
21
+ export default function TimerText({
22
+ startMs,
23
+ endMs = null,
24
+ granularity = "second",
25
+ format = fmtDuration,
26
+ className,
27
+ }) {
28
+ const id = useId();
29
+
30
+ // Get current time from the global time store
31
+ const now = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
32
+
33
+ // Local state for the formatted text to avoid re-renders of parent
34
+ const [displayText, setDisplayText] = useState(() => {
35
+ // Initial text for SSR safety
36
+ if (!startMs) return "—";
37
+ const initialEnd = endMs ?? Date.now();
38
+ const elapsed = Math.max(0, initialEnd - startMs);
39
+ return format(elapsed);
40
+ });
41
+
42
+ // Register cadence hint and handle subscription
43
+ useEffect(() => {
44
+ if (!startMs) return;
45
+
46
+ // Register cadence hint based on granularity
47
+ const cadenceMs = granularity === "second" ? 1000 : 60_000;
48
+ addCadenceHint(id, cadenceMs);
49
+
50
+ // Cleanup function
51
+ return () => {
52
+ removeCadenceHint(id);
53
+ };
54
+ }, [id, granularity, startMs]);
55
+
56
+ // Update display text when time changes (only for ongoing timers)
57
+ useEffect(() => {
58
+ if (!startMs) return;
59
+
60
+ // If endMs is present, this is a completed timer - no further updates needed
61
+ if (endMs !== null) return;
62
+
63
+ // For ongoing timers, update the display text
64
+ const elapsed = Math.max(0, now - startMs);
65
+ setDisplayText(format(elapsed));
66
+ }, [now, startMs, endMs, format]);
67
+
68
+ // For completed timers, ensure the final duration is displayed
69
+ useEffect(() => {
70
+ if (!startMs || endMs === null) return;
71
+
72
+ const elapsed = Math.max(0, endMs - startMs);
73
+ setDisplayText(format(elapsed));
74
+ }, [startMs, endMs, format]);
75
+
76
+ // If no start time, show placeholder
77
+ if (!startMs) {
78
+ return <span className={className}>—</span>;
79
+ }
80
+
81
+ return <span className={className}>{displayText}</span>;
82
+ }
@@ -0,0 +1,140 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Box, Flex, Text, Heading } from "@radix-ui/themes";
3
+ import { Button } from "./button.jsx";
4
+
5
+ /**
6
+ * RestartJobModal component for confirming job restart from clean slate
7
+ * @param {Object} props
8
+ * @param {boolean} props.open - Whether the modal is open
9
+ * @param {Function} props.onClose - Function to call when modal is closed
10
+ * @param {Function} props.onConfirm - Function to call when restart is confirmed
11
+ * @param {string} props.jobId - The ID of the job to restart
12
+ * @param {string} props.taskId - The ID of the task that triggered the restart (optional)
13
+ * @param {boolean} props.isSubmitting - Whether the restart action is in progress
14
+ */
15
+ export function RestartJobModal({
16
+ open,
17
+ onClose,
18
+ onConfirm,
19
+ jobId,
20
+ taskId,
21
+ isSubmitting = false,
22
+ }) {
23
+ const modalRef = useRef(null);
24
+
25
+ // Handle Escape key to close modal
26
+ useEffect(() => {
27
+ const handleKeyDown = (e) => {
28
+ if (e.key === "Escape" && open) {
29
+ e.preventDefault();
30
+ onClose();
31
+ }
32
+ };
33
+
34
+ if (open) {
35
+ document.addEventListener("keydown", handleKeyDown);
36
+ // Focus the modal for accessibility
37
+ if (modalRef.current) {
38
+ modalRef.current.focus();
39
+ }
40
+ return () => {
41
+ document.removeEventListener("keydown", handleKeyDown);
42
+ };
43
+ }
44
+ }, [open, onClose]);
45
+
46
+ // Handle Enter key to confirm when modal is focused
47
+ const handleKeyDown = (e) => {
48
+ if (e.key === "Enter" && !isSubmitting && open) {
49
+ e.preventDefault();
50
+ onConfirm();
51
+ }
52
+ };
53
+
54
+ if (!open) return null;
55
+
56
+ return (
57
+ <>
58
+ <div
59
+ className="fixed inset-0 z-50 flex items-center justify-center"
60
+ aria-hidden={!open}
61
+ >
62
+ {/* Backdrop */}
63
+ <div
64
+ className="absolute inset-0 bg-black/50"
65
+ onClick={onClose}
66
+ aria-hidden="true"
67
+ />
68
+
69
+ {/* Modal */}
70
+ <div
71
+ ref={modalRef}
72
+ role="dialog"
73
+ aria-modal="true"
74
+ aria-labelledby="restart-modal-title"
75
+ aria-describedby="restart-modal-description"
76
+ className="relative bg-white rounded-lg shadow-2xl border border-gray-200 max-w-lg w-full mx-4 outline-none"
77
+ style={{ minWidth: "320px", maxWidth: "560px" }}
78
+ tabIndex={-1}
79
+ onKeyDown={handleKeyDown}
80
+ >
81
+ <div className="p-6">
82
+ {/* Header */}
83
+ <Heading
84
+ id="restart-modal-title"
85
+ as="h2"
86
+ size="5"
87
+ className="mb-4 text-gray-900"
88
+ >
89
+ {taskId
90
+ ? `Restart from ${taskId}`
91
+ : "Restart job (reset progress)"}
92
+ </Heading>
93
+
94
+ {/* Body */}
95
+ <Box id="restart-modal-description" className="mb-6">
96
+ <Text as="p" className="text-gray-700 mb-4">
97
+ {taskId
98
+ ? `This will restart the job from the "${taskId}" task. Tasks before ${taskId} will remain completed, while ${taskId} and all subsequent tasks will be reset to pending. Files and artifacts are preserved. A new background run will start automatically. This cannot be undone.`
99
+ : "This will clear the job's progress and active stage and reset all tasks to pending. Files and artifacts are preserved. A new background run will start automatically. This cannot be undone."}
100
+ </Text>
101
+
102
+ {taskId && (
103
+ <Text as="p" className="text-sm text-gray-600 mb-3">
104
+ <strong>Triggered from task:</strong> {taskId}
105
+ </Text>
106
+ )}
107
+
108
+ <Text as="p" className="text-sm text-gray-500 italic">
109
+ Note: Job must be in current lifecycle and not running.
110
+ </Text>
111
+ </Box>
112
+
113
+ {/* Actions */}
114
+ <Flex gap="3" justify="end">
115
+ <Button
116
+ variant="outline"
117
+ onClick={onClose}
118
+ disabled={isSubmitting}
119
+ className="min-w-[80px]"
120
+ >
121
+ Cancel
122
+ </Button>
123
+
124
+ <Button
125
+ variant="destructive"
126
+ onClick={onConfirm}
127
+ disabled={isSubmitting}
128
+ className="min-w-[80px]"
129
+ >
130
+ {isSubmitting ? "Restarting..." : "Restart"}
131
+ </Button>
132
+ </Flex>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </>
137
+ );
138
+ }
139
+
140
+ export default RestartJobModal;
@@ -0,0 +1,138 @@
1
+ import React, { createContext, useContext, useState, useCallback } from "react";
2
+ import { Box, Flex, Text } from "@radix-ui/themes";
3
+
4
+ // Toast context for managing toast notifications
5
+ const ToastContext = createContext();
6
+
7
+ /**
8
+ * Toast Provider component that manages toast state
9
+ */
10
+ export function ToastProvider({ children }) {
11
+ const [toasts, setToasts] = useState([]);
12
+
13
+ const addToast = useCallback((message, options = {}) => {
14
+ const toast = {
15
+ id: Date.now() + Math.random(),
16
+ message,
17
+ type: options.type || "info",
18
+ duration: options.duration || 5000,
19
+ };
20
+
21
+ setToasts((prev) => [...prev, toast]);
22
+
23
+ // Auto-remove toast after duration
24
+ if (toast.duration > 0) {
25
+ setTimeout(() => {
26
+ removeToast(toast.id);
27
+ }, toast.duration);
28
+ }
29
+
30
+ return toast.id;
31
+ }, []);
32
+
33
+ const removeToast = useCallback((id) => {
34
+ setToasts((prev) => prev.filter((toast) => toast.id !== id));
35
+ }, []);
36
+
37
+ const value = {
38
+ addToast,
39
+ removeToast,
40
+ success: (message, options) =>
41
+ addToast(message, { ...options, type: "success" }),
42
+ error: (message, options) =>
43
+ addToast(message, { ...options, type: "error" }),
44
+ warning: (message, options) =>
45
+ addToast(message, { ...options, type: "warning" }),
46
+ info: (message, options) => addToast(message, { ...options, type: "info" }),
47
+ };
48
+
49
+ return (
50
+ <ToastContext.Provider value={value}>
51
+ {children}
52
+ <ToastContainer toasts={toasts} onRemove={removeToast} />
53
+ </ToastContext.Provider>
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Hook to use toast functionality
59
+ */
60
+ export function useToast() {
61
+ const context = useContext(ToastContext);
62
+ if (!context) {
63
+ throw new Error("useToast must be used within a ToastProvider");
64
+ }
65
+ return context;
66
+ }
67
+
68
+ /**
69
+ * Toast container component that renders all active toasts
70
+ */
71
+ function ToastContainer({ toasts, onRemove }) {
72
+ if (toasts.length === 0) return null;
73
+
74
+ return (
75
+ <div className="fixed top-4 right-4 z-50 space-y-2">
76
+ {toasts.map((toast) => (
77
+ <ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
78
+ ))}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Individual toast item component
85
+ */
86
+ function ToastItem({ toast, onRemove }) {
87
+ const getToastStyles = (type) => {
88
+ switch (type) {
89
+ case "success":
90
+ return "bg-green-50 border-green-200 text-green-800";
91
+ case "error":
92
+ return "bg-red-50 border-red-200 text-red-800";
93
+ case "warning":
94
+ return "bg-yellow-50 border-yellow-200 text-yellow-800";
95
+ default:
96
+ return "bg-blue-50 border-blue-200 text-blue-800";
97
+ }
98
+ };
99
+
100
+ const getIcon = (type) => {
101
+ switch (type) {
102
+ case "success":
103
+ return "✓";
104
+ case "error":
105
+ return "✕";
106
+ case "warning":
107
+ return "⚠";
108
+ default:
109
+ return "ℹ";
110
+ }
111
+ };
112
+
113
+ return (
114
+ <Box
115
+ className={`relative flex items-start p-4 border rounded-lg shadow-lg max-w-sm ${getToastStyles(
116
+ toast.type
117
+ )}`}
118
+ role="alert"
119
+ aria-live="polite"
120
+ >
121
+ <Flex gap="3" align="start">
122
+ <Text className="flex-shrink-0 text-lg font-semibold">
123
+ {getIcon(toast.type)}
124
+ </Text>
125
+ <Text className="text-sm font-medium flex-1">{toast.message}</Text>
126
+ <button
127
+ onClick={() => onRemove(toast.id)}
128
+ className="flex-shrink-0 ml-4 text-sm opacity-60 hover:opacity-100 focus:outline-none focus:opacity-100"
129
+ aria-label="Dismiss notification"
130
+ >
131
+
132
+ </button>
133
+ </Flex>
134
+ </Box>
135
+ );
136
+ }
137
+
138
+ export default ToastProvider;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Canonical model configuration for prompt orchestration pipeline.
3
+ * This module serves as single source of truth for all model metadata.
4
+ */
5
+
6
+ // Model alias constants grouped by provider
7
+ export const ModelAlias = Object.freeze({
8
+ // DeepSeek
9
+ DEEPSEEK_CHAT: "deepseek:chat",
10
+ DEEPSEEK_REASONER: "deepseek:reasoner",
11
+
12
+ // OpenAI
13
+ OPENAI_GPT_5: "openai:gpt-5",
14
+ OPENAI_GPT_5_CORE: "openai:gpt-5-core",
15
+ OPENAI_GPT_5_CHAT: "openai:gpt-5-chat",
16
+ OPENAI_GPT_5_PRO: "openai:gpt-5-pro",
17
+ OPENAI_GPT_5_MINI: "openai:gpt-5-mini",
18
+ OPENAI_GPT_5_NANO: "openai:gpt-5-nano",
19
+
20
+ // Legacy aliases for backward compatibility (tests)
21
+ OPENAI_GPT_4: "openai:gpt-4",
22
+ OPENAI_GPT_4_TURBO: "openai:gpt-4-turbo",
23
+
24
+ // Google Gemini
25
+ GEMINI_2_5_PRO: "gemini:pro-2.5",
26
+ GEMINI_2_5_FLASH: "gemini:flash-2.5",
27
+ GEMINI_2_5_FLASH_LITE: "gemini:flash-2.5-lite",
28
+ GEMINI_2_5_FLASH_IMAGE: "gemini:flash-2.5-image",
29
+
30
+ // Z.ai (formerly Zhipu) - standardized to "zhipu" provider
31
+ ZAI_GLM_4_6: "zhipu:glm-4.6",
32
+ ZAI_GLM_4_5: "zhipu:glm-4.5",
33
+ ZAI_GLM_4_5_AIR: "zhipu:glm-4.5-air",
34
+
35
+ // Anthropic
36
+ ANTHROPIC_SONNET_4_5: "anthropic:sonnet-4-5",
37
+ ANTHROPIC_HAIKU_4_5: "anthropic:haiku-4-5",
38
+ ANTHROPIC_OPUS_4_1: "anthropic:opus-4-1",
39
+ });
40
+
41
+ // Consolidated model configuration with pricing metadata
42
+ export const MODEL_CONFIG = Object.freeze({
43
+ // DeepSeek (2025)
44
+ [ModelAlias.DEEPSEEK_CHAT]: {
45
+ provider: "deepseek",
46
+ model: "deepseek-chat", // V3.2 Exp (non-thinking) under the hood
47
+ tokenCostInPerMillion: 0.27,
48
+ tokenCostOutPerMillion: 1.1,
49
+ },
50
+ [ModelAlias.DEEPSEEK_REASONER]: {
51
+ provider: "deepseek",
52
+ model: "deepseek-reasoner", // R1 family
53
+ tokenCostInPerMillion: 0.55,
54
+ tokenCostOutPerMillion: 2.19,
55
+ },
56
+
57
+ // — OpenAI (2025) —
58
+ [ModelAlias.OPENAI_GPT_5]: {
59
+ provider: "openai",
60
+ model: "gpt-5", // stable flagship
61
+ tokenCostInPerMillion: 1.25,
62
+ tokenCostOutPerMillion: 10.0,
63
+ },
64
+ [ModelAlias.OPENAI_GPT_5_CHAT]: {
65
+ provider: "openai",
66
+ model: "gpt-5-chat-latest", // Chat variant
67
+ tokenCostInPerMillion: 1.25,
68
+ tokenCostOutPerMillion: 10.0,
69
+ },
70
+ [ModelAlias.OPENAI_GPT_5_PRO]: {
71
+ provider: "openai",
72
+ model: "gpt-5-pro", // higher-compute tier
73
+ tokenCostInPerMillion: 15.0,
74
+ tokenCostOutPerMillion: 120.0,
75
+ },
76
+ [ModelAlias.OPENAI_GPT_5_MINI]: {
77
+ provider: "openai",
78
+ model: "gpt-5-mini",
79
+ tokenCostInPerMillion: 0.25,
80
+ tokenCostOutPerMillion: 2.0,
81
+ },
82
+ [ModelAlias.OPENAI_GPT_5_NANO]: {
83
+ provider: "openai",
84
+ model: "gpt-5-nano",
85
+ tokenCostInPerMillion: 0.05,
86
+ tokenCostOutPerMillion: 0.4,
87
+ },
88
+
89
+ // Legacy models for backward compatibility (tests)
90
+ [ModelAlias.OPENAI_GPT_4]: {
91
+ provider: "openai",
92
+ model: "gpt-4",
93
+ tokenCostInPerMillion: 0.5,
94
+ tokenCostOutPerMillion: 2.0,
95
+ },
96
+ [ModelAlias.OPENAI_GPT_4_TURBO]: {
97
+ provider: "openai",
98
+ model: "gpt-4-turbo",
99
+ tokenCostInPerMillion: 0.3,
100
+ tokenCostOutPerMillion: 1.0,
101
+ },
102
+
103
+ // — Google Gemini (2025) —
104
+ [ModelAlias.GEMINI_2_5_PRO]: {
105
+ provider: "gemini",
106
+ model: "gemini-2.5-pro", // ≤200k input tier shown; >200k is higher
107
+ tokenCostInPerMillion: 1.25,
108
+ tokenCostOutPerMillion: 10.0,
109
+ },
110
+ [ModelAlias.GEMINI_2_5_FLASH]: {
111
+ provider: "gemini",
112
+ model: "gemini-2.5-flash",
113
+ tokenCostInPerMillion: 0.3,
114
+ tokenCostOutPerMillion: 2.5,
115
+ },
116
+ [ModelAlias.GEMINI_2_5_FLASH_LITE]: {
117
+ provider: "gemini",
118
+ model: "gemini-2.5-flash-lite",
119
+ tokenCostInPerMillion: 0.1,
120
+ tokenCostOutPerMillion: 0.4,
121
+ },
122
+ [ModelAlias.GEMINI_2_5_FLASH_IMAGE]: {
123
+ provider: "gemini",
124
+ model: "gemini-2.5-flash-image",
125
+ // Inputs follow 2.5 Flash text pricing; outputs are **image tokens** at $30/M (≈$0.039 per 1024² image)
126
+ tokenCostInPerMillion: 0.3,
127
+ tokenCostOutPerMillion: 30.0,
128
+ },
129
+
130
+ // — Z.ai (formerly Zhipu) —
131
+ [ModelAlias.ZAI_GLM_4_6]: {
132
+ provider: "zhipu",
133
+ model: "glm-4.6",
134
+ tokenCostInPerMillion: 0.6,
135
+ tokenCostOutPerMillion: 2.2,
136
+ },
137
+ [ModelAlias.ZAI_GLM_4_5]: {
138
+ provider: "zhipu",
139
+ model: "glm-4.5",
140
+ tokenCostInPerMillion: 0.6,
141
+ tokenCostOutPerMillion: 2.2,
142
+ },
143
+ [ModelAlias.ZAI_GLM_4_5_AIR]: {
144
+ provider: "zhipu",
145
+ model: "glm-4.5-air",
146
+ tokenCostInPerMillion: 0.2,
147
+ tokenCostOutPerMillion: 1.1,
148
+ },
149
+
150
+ // — Anthropic —
151
+ // current (Claude 4.5 / 4.1)
152
+ [ModelAlias.ANTHROPIC_SONNET_4_5]: {
153
+ provider: "anthropic",
154
+ model: "claude-sonnet-4-5-20250929", // Use actual model ID
155
+ tokenCostInPerMillion: 3.0,
156
+ tokenCostOutPerMillion: 15.0,
157
+ },
158
+ [ModelAlias.ANTHROPIC_HAIKU_4_5]: {
159
+ provider: "anthropic",
160
+ model: "claude-haiku-4-5-20250929", // Use actual model ID
161
+ tokenCostInPerMillion: 0.25, // Correct pricing
162
+ tokenCostOutPerMillion: 1.25, // Correct pricing
163
+ },
164
+ [ModelAlias.ANTHROPIC_OPUS_4_1]: {
165
+ provider: "anthropic",
166
+ model: "claude-opus-4-1-20240229", // Use actual model ID
167
+ tokenCostInPerMillion: 15.0,
168
+ tokenCostOutPerMillion: 75.0,
169
+ },
170
+ });
171
+
172
+ // Validation set of all valid model aliases
173
+ export const VALID_MODEL_ALIASES = new Set(Object.keys(MODEL_CONFIG));
174
+
175
+ // Default model alias for each provider (used when no model specified)
176
+ export const DEFAULT_MODEL_BY_PROVIDER = Object.freeze({
177
+ deepseek: ModelAlias.DEEPSEEK_CHAT,
178
+ openai: ModelAlias.OPENAI_GPT_5,
179
+ gemini: ModelAlias.GEMINI_2_5_FLASH,
180
+ zhipu: ModelAlias.ZAI_GLM_4_6,
181
+ anthropic: ModelAlias.ANTHROPIC_SONNET_4_5,
182
+ });
183
+
184
+ /**
185
+ * Convert model alias to function name.
186
+ * Removes hyphens and dots, uppercases following alphanumeric character.
187
+ * @param {string} alias - Model alias (e.g., "gemini:2.5-pro")
188
+ * @returns {string} Function name (e.g., "25Pro")
189
+ * @throws {Error} If alias is invalid
190
+ */
191
+ export function aliasToFunctionName(alias) {
192
+ if (typeof alias !== "string" || !alias.includes(":")) {
193
+ throw new Error(`Invalid model alias: ${alias}`);
194
+ }
195
+
196
+ const model = alias.split(":").slice(1).join(":");
197
+ return model.replace(/[-.]([a-z0-9])/gi, (_, char) => char.toUpperCase());
198
+ }
199
+
200
+ /**
201
+ * Derived map of alias to function name for efficient lookup.
202
+ * Computed at module load time and frozen for immutability.
203
+ */
204
+ export const FUNCTION_NAME_BY_ALIAS = Object.freeze(
205
+ Object.fromEntries(
206
+ Object.keys(MODEL_CONFIG).map((alias) => [
207
+ alias,
208
+ aliasToFunctionName(alias),
209
+ ])
210
+ )
211
+ );
212
+
213
+ /**
214
+ * Build provider functions index with dotted path style.
215
+ * @returns {Object} Frozen provider functions index
216
+ */
217
+ export function buildProviderFunctionsIndex() {
218
+ const result = {};
219
+
220
+ for (const [alias, config] of Object.entries(MODEL_CONFIG)) {
221
+ const { provider } = config;
222
+ const functionName = FUNCTION_NAME_BY_ALIAS[alias];
223
+
224
+ if (!result[provider]) {
225
+ result[provider] = [];
226
+ }
227
+
228
+ const fullPath = `llm.${provider}.${functionName}`;
229
+
230
+ result[provider].push({
231
+ alias,
232
+ provider,
233
+ model: config.model,
234
+ functionName,
235
+ fullPath,
236
+ });
237
+ }
238
+
239
+ // Freeze inner arrays and outer object
240
+ for (const provider of Object.keys(result)) {
241
+ Object.freeze(result[provider]);
242
+ }
243
+ return Object.freeze(result);
244
+ }
245
+
246
+ /**
247
+ * Pre-built provider functions index for convenience.
248
+ * Uses dotted style: llm.anthropic.sonnet45, llm.openai.gpt5, etc.
249
+ */
250
+ export const PROVIDER_FUNCTIONS = buildProviderFunctionsIndex();
251
+
252
+ /**
253
+ * Extract provider name from model alias.
254
+ * @param {string} alias - Model alias (e.g., "openai:gpt-5")
255
+ * @returns {string} Provider name (e.g., "openai")
256
+ */
257
+ export function getProviderFromAlias(alias) {
258
+ if (typeof alias !== "string" || !alias.includes(":")) {
259
+ throw new Error(`Invalid model alias: ${alias}`);
260
+ }
261
+ return alias.split(":")[0];
262
+ }
263
+
264
+ /**
265
+ * Extract model name from model alias.
266
+ * @param {string} alias - Model alias (e.g., "openai:gpt-5")
267
+ * @returns {string} Model name (e.g., "gpt-5")
268
+ */
269
+ export function getModelFromAlias(alias) {
270
+ if (typeof alias !== "string" || !alias.includes(":")) {
271
+ throw new Error(`Invalid model alias: ${alias}`);
272
+ }
273
+ return alias.split(":").slice(1).join(":");
274
+ }
275
+
276
+ /**
277
+ * Get model configuration by alias.
278
+ * @param {string} alias - Model alias (e.g., "openai:gpt-5")
279
+ * @returns {Object|null} Model configuration or null if not found
280
+ */
281
+ export function getModelConfig(alias) {
282
+ return MODEL_CONFIG[alias] ?? null;
283
+ }
284
+
285
+ // Invariant checks to ensure data consistency
286
+ for (const [alias, config] of Object.entries(MODEL_CONFIG)) {
287
+ const providerFromAlias = getProviderFromAlias(alias);
288
+ if (providerFromAlias !== config.provider) {
289
+ throw new Error(
290
+ `Model config invariant violation: alias "${alias}" has provider "${config.provider}" but alias prefix indicates "${providerFromAlias}"`
291
+ );
292
+ }
293
+
294
+ if (
295
+ typeof config.tokenCostInPerMillion !== "number" ||
296
+ config.tokenCostInPerMillion < 0
297
+ ) {
298
+ throw new Error(
299
+ `Model config invariant violation: alias "${alias}" has invalid tokenCostInPerMillion: ${config.tokenCostInPerMillion}`
300
+ );
301
+ }
302
+
303
+ if (
304
+ typeof config.tokenCostOutPerMillion !== "number" ||
305
+ config.tokenCostOutPerMillion < 0
306
+ ) {
307
+ throw new Error(
308
+ `Model config invariant violation: alias "${alias}" has invalid tokenCostOutPerMillion: ${config.tokenCostOutPerMillion}`
309
+ );
310
+ }
311
+ }
312
+
313
+ // Verify VALID_MODEL_ALIASES matches MODEL_CONFIG keys exactly
314
+ const modelConfigKeys = new Set(Object.keys(MODEL_CONFIG));
315
+ if (
316
+ modelConfigKeys.size !== VALID_MODEL_ALIASES.size ||
317
+ ![...modelConfigKeys].every((key) => VALID_MODEL_ALIASES.has(key))
318
+ ) {
319
+ throw new Error(
320
+ "VALID_MODEL_ALIASES does not exactly match MODEL_CONFIG keys"
321
+ );
322
+ }