@agile-vibe-coding/avc 0.1.1 → 0.3.1

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 (239) hide show
  1. package/cli/agent-loader.js +21 -0
  2. package/cli/agents/agent-selector.md +152 -0
  3. package/cli/agents/architecture-recommender.md +418 -0
  4. package/cli/agents/code-implementer.md +117 -0
  5. package/cli/agents/code-validator.md +80 -0
  6. package/cli/agents/context-reviewer-epic.md +101 -0
  7. package/cli/agents/context-reviewer-story.md +92 -0
  8. package/cli/agents/context-writer-epic.md +145 -0
  9. package/cli/agents/context-writer-story.md +111 -0
  10. package/cli/agents/database-deep-dive.md +470 -0
  11. package/cli/agents/database-recommender.md +634 -0
  12. package/cli/agents/doc-distributor.md +176 -0
  13. package/cli/agents/doc-writer-epic.md +42 -0
  14. package/cli/agents/doc-writer-story.md +43 -0
  15. package/cli/agents/documentation-updater.md +203 -0
  16. package/cli/agents/duplicate-detector.md +110 -0
  17. package/cli/agents/epic-story-decomposer.md +559 -0
  18. package/cli/agents/feature-context-generator.md +91 -0
  19. package/cli/agents/gap-checker-epic.md +52 -0
  20. package/cli/agents/impact-checker-story.md +51 -0
  21. package/cli/agents/migration-guide-generator.md +305 -0
  22. package/cli/agents/mission-scope-generator.md +143 -0
  23. package/cli/agents/mission-scope-validator.md +146 -0
  24. package/cli/agents/project-context-extractor.md +122 -0
  25. package/cli/agents/project-documentation-creator.json +226 -0
  26. package/cli/agents/project-documentation-creator.md +595 -0
  27. package/cli/agents/question-prefiller.md +269 -0
  28. package/cli/agents/refiner-epic.md +39 -0
  29. package/cli/agents/refiner-story.md +42 -0
  30. package/cli/agents/scaffolding-generator.md +99 -0
  31. package/cli/agents/seed-validator.md +71 -0
  32. package/cli/agents/story-doc-enricher.md +133 -0
  33. package/cli/agents/story-scope-reviewer.md +147 -0
  34. package/cli/agents/story-splitter.md +83 -0
  35. package/cli/agents/suggestion-business-analyst.md +88 -0
  36. package/cli/agents/suggestion-deployment-architect.md +263 -0
  37. package/cli/agents/suggestion-product-manager.md +129 -0
  38. package/cli/agents/suggestion-security-specialist.md +156 -0
  39. package/cli/agents/suggestion-technical-architect.md +269 -0
  40. package/cli/agents/suggestion-ux-researcher.md +93 -0
  41. package/cli/agents/task-subtask-decomposer.md +188 -0
  42. package/cli/agents/validator-documentation.json +183 -0
  43. package/cli/agents/validator-documentation.md +455 -0
  44. package/cli/agents/validator-selector.md +211 -0
  45. package/cli/ansi-colors.js +21 -0
  46. package/cli/api-reference-tool.js +368 -0
  47. package/cli/build-docs.js +29 -8
  48. package/cli/ceremony-history.js +369 -0
  49. package/cli/checks/catalog.json +76 -0
  50. package/cli/checks/code/quality.json +26 -0
  51. package/cli/checks/code/testing.json +14 -0
  52. package/cli/checks/code/traceability.json +26 -0
  53. package/cli/checks/cross-refs/epic.json +171 -0
  54. package/cli/checks/cross-refs/story.json +149 -0
  55. package/cli/checks/epic/api.json +114 -0
  56. package/cli/checks/epic/backend.json +126 -0
  57. package/cli/checks/epic/cloud.json +126 -0
  58. package/cli/checks/epic/data.json +102 -0
  59. package/cli/checks/epic/database.json +114 -0
  60. package/cli/checks/epic/developer.json +182 -0
  61. package/cli/checks/epic/devops.json +174 -0
  62. package/cli/checks/epic/frontend.json +162 -0
  63. package/cli/checks/epic/mobile.json +102 -0
  64. package/cli/checks/epic/qa.json +90 -0
  65. package/cli/checks/epic/security.json +184 -0
  66. package/cli/checks/epic/solution-architect.json +192 -0
  67. package/cli/checks/epic/test-architect.json +90 -0
  68. package/cli/checks/epic/ui.json +102 -0
  69. package/cli/checks/epic/ux.json +90 -0
  70. package/cli/checks/fixes/epic-fix-template.md +10 -0
  71. package/cli/checks/fixes/story-fix-template.md +10 -0
  72. package/cli/checks/story/api.json +186 -0
  73. package/cli/checks/story/backend.json +102 -0
  74. package/cli/checks/story/cloud.json +102 -0
  75. package/cli/checks/story/data.json +210 -0
  76. package/cli/checks/story/database.json +102 -0
  77. package/cli/checks/story/developer.json +168 -0
  78. package/cli/checks/story/devops.json +102 -0
  79. package/cli/checks/story/frontend.json +174 -0
  80. package/cli/checks/story/mobile.json +102 -0
  81. package/cli/checks/story/qa.json +210 -0
  82. package/cli/checks/story/security.json +198 -0
  83. package/cli/checks/story/solution-architect.json +230 -0
  84. package/cli/checks/story/test-architect.json +210 -0
  85. package/cli/checks/story/ui.json +102 -0
  86. package/cli/checks/story/ux.json +102 -0
  87. package/cli/coding-order.js +401 -0
  88. package/cli/command-logger.js +49 -12
  89. package/cli/components/static-output.js +63 -0
  90. package/cli/console-output-manager.js +94 -0
  91. package/cli/dependency-checker.js +72 -0
  92. package/cli/docs-sync.js +306 -0
  93. package/cli/epic-story-validator.js +659 -0
  94. package/cli/evaluation-prompts.js +1008 -0
  95. package/cli/execution-context.js +195 -0
  96. package/cli/generate-summary-table.js +340 -0
  97. package/cli/init-model-config.js +704 -0
  98. package/cli/init.js +1737 -278
  99. package/cli/kanban-server-manager.js +227 -0
  100. package/cli/llm-claude.js +150 -1
  101. package/cli/llm-gemini.js +109 -0
  102. package/cli/llm-local.js +493 -0
  103. package/cli/llm-mock.js +233 -0
  104. package/cli/llm-openai.js +454 -0
  105. package/cli/llm-provider.js +379 -3
  106. package/cli/llm-token-limits.js +211 -0
  107. package/cli/llm-verifier.js +662 -0
  108. package/cli/llm-xiaomi.js +143 -0
  109. package/cli/message-constants.js +49 -0
  110. package/cli/message-manager.js +334 -0
  111. package/cli/message-types.js +96 -0
  112. package/cli/messaging-api.js +291 -0
  113. package/cli/micro-check-fixer.js +335 -0
  114. package/cli/micro-check-runner.js +449 -0
  115. package/cli/micro-check-scorer.js +148 -0
  116. package/cli/micro-check-validator.js +538 -0
  117. package/cli/model-pricing.js +192 -0
  118. package/cli/model-query-engine.js +468 -0
  119. package/cli/model-recommendation-analyzer.js +495 -0
  120. package/cli/model-selector.js +270 -0
  121. package/cli/output-buffer.js +107 -0
  122. package/cli/process-manager.js +73 -2
  123. package/cli/prompt-logger.js +57 -0
  124. package/cli/repl-ink.js +4625 -1094
  125. package/cli/repl-old.js +3 -4
  126. package/cli/seed-processor.js +962 -0
  127. package/cli/sprint-planning-processor.js +4162 -0
  128. package/cli/template-processor.js +2149 -105
  129. package/cli/templates/project.md +25 -8
  130. package/cli/templates/vitepress-config.mts.template +5 -4
  131. package/cli/token-tracker.js +547 -0
  132. package/cli/tools/generate-story-validators.js +317 -0
  133. package/cli/tools/generate-validators.js +669 -0
  134. package/cli/update-checker.js +19 -17
  135. package/cli/update-notifier.js +4 -4
  136. package/cli/validation-router.js +667 -0
  137. package/cli/verification-tracker.js +563 -0
  138. package/cli/worktree-runner.js +654 -0
  139. package/kanban/README.md +386 -0
  140. package/kanban/client/README.md +205 -0
  141. package/kanban/client/components.json +20 -0
  142. package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
  143. package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
  144. package/kanban/client/dist/index.html +16 -0
  145. package/kanban/client/dist/vite.svg +1 -0
  146. package/kanban/client/index.html +15 -0
  147. package/kanban/client/package-lock.json +9442 -0
  148. package/kanban/client/package.json +44 -0
  149. package/kanban/client/postcss.config.js +6 -0
  150. package/kanban/client/public/vite.svg +1 -0
  151. package/kanban/client/src/App.jsx +651 -0
  152. package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
  153. package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
  154. package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
  155. package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
  156. package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
  157. package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
  158. package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
  159. package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
  160. package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
  161. package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
  162. package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
  163. package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
  164. package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
  165. package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
  166. package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
  167. package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
  168. package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
  169. package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
  170. package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
  171. package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
  172. package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
  173. package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
  174. package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
  175. package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
  176. package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
  177. package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
  178. package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
  179. package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
  180. package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
  181. package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
  182. package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
  183. package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
  184. package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
  185. package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
  186. package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
  187. package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
  188. package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
  189. package/kanban/client/src/components/stats/CostModal.jsx +384 -0
  190. package/kanban/client/src/components/ui/badge.jsx +27 -0
  191. package/kanban/client/src/components/ui/dialog.jsx +121 -0
  192. package/kanban/client/src/components/ui/tabs.jsx +85 -0
  193. package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
  194. package/kanban/client/src/hooks/useGrouping.js +177 -0
  195. package/kanban/client/src/hooks/useWebSocket.js +120 -0
  196. package/kanban/client/src/lib/__tests__/api.test.js +196 -0
  197. package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
  198. package/kanban/client/src/lib/api.js +515 -0
  199. package/kanban/client/src/lib/status-grouping.js +154 -0
  200. package/kanban/client/src/lib/utils.js +11 -0
  201. package/kanban/client/src/main.jsx +10 -0
  202. package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
  203. package/kanban/client/src/store/ceremonyStore.js +172 -0
  204. package/kanban/client/src/store/filterStore.js +201 -0
  205. package/kanban/client/src/store/kanbanStore.js +123 -0
  206. package/kanban/client/src/store/processStore.js +65 -0
  207. package/kanban/client/src/store/sprintPlanningStore.js +33 -0
  208. package/kanban/client/src/styles/globals.css +59 -0
  209. package/kanban/client/tailwind.config.js +77 -0
  210. package/kanban/client/vite.config.js +28 -0
  211. package/kanban/client/vitest.config.js +28 -0
  212. package/kanban/dev-start.sh +47 -0
  213. package/kanban/package.json +12 -0
  214. package/kanban/server/index.js +537 -0
  215. package/kanban/server/routes/ceremony.js +454 -0
  216. package/kanban/server/routes/costs.js +163 -0
  217. package/kanban/server/routes/openai-oauth.js +366 -0
  218. package/kanban/server/routes/processes.js +50 -0
  219. package/kanban/server/routes/settings.js +736 -0
  220. package/kanban/server/routes/websocket.js +281 -0
  221. package/kanban/server/routes/work-items.js +487 -0
  222. package/kanban/server/services/CeremonyService.js +1441 -0
  223. package/kanban/server/services/FileSystemScanner.js +95 -0
  224. package/kanban/server/services/FileWatcher.js +144 -0
  225. package/kanban/server/services/HierarchyBuilder.js +196 -0
  226. package/kanban/server/services/ProcessRegistry.js +122 -0
  227. package/kanban/server/services/TaskRunnerService.js +261 -0
  228. package/kanban/server/services/WorkItemReader.js +123 -0
  229. package/kanban/server/services/WorkItemRefineService.js +510 -0
  230. package/kanban/server/start.js +49 -0
  231. package/kanban/server/utils/kanban-logger.js +132 -0
  232. package/kanban/server/utils/markdown.js +91 -0
  233. package/kanban/server/utils/status-grouping.js +107 -0
  234. package/kanban/server/workers/run-task-worker.js +121 -0
  235. package/kanban/server/workers/seed-worker.js +94 -0
  236. package/kanban/server/workers/sponsor-call-worker.js +92 -0
  237. package/kanban/server/workers/sprint-planning-worker.js +212 -0
  238. package/package.json +19 -7
  239. package/cli/agents/documentation.md +0 -302
@@ -0,0 +1,269 @@
1
+ import { useState } from 'react';
2
+ import { ExternalLink } from 'lucide-react';
3
+ import { saveModelPricing } from '../../lib/api';
4
+
5
+ const PROVIDERS = ['claude', 'gemini', 'openai', 'xiaomi'];
6
+ const PROVIDER_LABELS = { claude: 'Claude', gemini: 'Gemini', openai: 'OpenAI', xiaomi: 'Xiaomi MiMo' };
7
+ const PROVIDER_TAB_COLORS = {
8
+ claude: { active: 'border-orange-500 text-orange-700', badge: 'bg-orange-50 text-orange-700 border-orange-200' },
9
+ gemini: { active: 'border-blue-500 text-blue-700', badge: 'bg-blue-50 text-blue-700 border-blue-200' },
10
+ openai: { active: 'border-green-500 text-green-700', badge: 'bg-green-50 text-green-700 border-green-200' },
11
+ xiaomi: { active: 'border-purple-500 text-purple-700', badge: 'bg-purple-50 text-purple-700 border-purple-200' },
12
+ };
13
+
14
+ const PROVIDER_COLORS = {
15
+ claude: 'bg-orange-50 text-orange-700 border-orange-200',
16
+ gemini: 'bg-blue-50 text-blue-700 border-blue-200',
17
+ openai: 'bg-green-50 text-green-700 border-green-200',
18
+ xiaomi: 'bg-purple-50 text-purple-700 border-purple-200',
19
+ };
20
+
21
+ const UNIT_OPTIONS = [
22
+ { value: 'million', label: 'per 1M tokens' },
23
+ { value: 'thousand', label: 'per 1K tokens' },
24
+ ];
25
+
26
+ function initState(models) {
27
+ const state = {};
28
+ for (const [modelId, info] of Object.entries(models)) {
29
+ state[modelId] = {
30
+ input: String(info.pricing?.input ?? ''),
31
+ inputCached: String(info.pricing?.inputCached ?? ''),
32
+ output: String(info.pricing?.output ?? ''),
33
+ unit: info.pricing?.unit ?? 'million',
34
+ source: info.pricing?.source ?? '',
35
+ lastUpdated: info.pricing?.lastUpdated ?? '',
36
+ };
37
+ }
38
+ return state;
39
+ }
40
+
41
+ function formatDate(iso) {
42
+ if (!iso) return null;
43
+ const d = new Date(iso + 'T00:00:00');
44
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
45
+ }
46
+
47
+ export function ModelPricingTab({ settings, onSaved }) {
48
+ const models = settings.models || {};
49
+ const [pricing, setPricing] = useState(() => initState(models));
50
+ const [status, setStatus] = useState(null); // null | 'saving' | 'saved' | 'error'
51
+
52
+ // Determine which providers actually have models, preserve stable order
53
+ const availableProviders = PROVIDERS.filter(p =>
54
+ Object.values(models).some(m => m.provider === p)
55
+ );
56
+ const [activeProvider, setActiveProvider] = useState(() => availableProviders[0] || 'claude');
57
+
58
+ const modelEntries = Object.entries(models).filter(
59
+ ([, info]) => info.provider === activeProvider
60
+ );
61
+
62
+ const update = (modelId, field, value) => {
63
+ setPricing((prev) => ({
64
+ ...prev,
65
+ [modelId]: { ...prev[modelId], [field]: value },
66
+ }));
67
+ };
68
+
69
+ const handleSave = async () => {
70
+ setStatus('saving');
71
+ try {
72
+ // Build payload: { modelId: { pricing: { input, output, unit } } }
73
+ const payload = {};
74
+ for (const [modelId, p] of Object.entries(pricing)) {
75
+ payload[modelId] = {
76
+ pricing: {
77
+ input: parseFloat(p.input) || 0,
78
+ inputCached: parseFloat(p.inputCached) || 0,
79
+ output: parseFloat(p.output) || 0,
80
+ unit: p.unit,
81
+ source: p.source,
82
+ },
83
+ };
84
+ }
85
+ await saveModelPricing(payload);
86
+ setStatus('saved');
87
+ onSaved();
88
+ setTimeout(() => setStatus(null), 2000);
89
+ } catch {
90
+ setStatus('error');
91
+ setTimeout(() => setStatus(null), 2000);
92
+ }
93
+ };
94
+
95
+ if (modelEntries.length === 0) {
96
+ return (
97
+ <div className="px-5 py-8 text-center">
98
+ <p className="text-sm text-slate-500">
99
+ No models configured yet. Run your first ceremony to populate model settings.
100
+ </p>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ return (
106
+ <div className="px-5 py-4 flex flex-col gap-4">
107
+ <p className="text-xs text-slate-500">
108
+ Set the cost per token for each model. These rates are used by the cost tracker
109
+ to estimate LLM spend. Prices are in <strong>USD</strong>.
110
+ </p>
111
+
112
+ {/* Provider tabs */}
113
+ {availableProviders.length > 1 && (
114
+ <div className="flex border-b border-slate-200 -mx-5 px-5">
115
+ {availableProviders.map((p) => {
116
+ const colors = PROVIDER_TAB_COLORS[p] || { active: 'border-slate-900 text-slate-900' };
117
+ const isActive = p === activeProvider;
118
+ const count = Object.values(models).filter(m => m.provider === p).length;
119
+ return (
120
+ <button
121
+ key={p}
122
+ type="button"
123
+ onClick={() => setActiveProvider(p)}
124
+ className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
125
+ isActive
126
+ ? colors.active
127
+ : 'border-transparent text-slate-500 hover:text-slate-700'
128
+ }`}
129
+ >
130
+ {PROVIDER_LABELS[p] || p}
131
+ <span className={`ml-1.5 text-xs px-1.5 py-0.5 rounded-full border ${
132
+ isActive ? colors.badge : 'bg-slate-100 text-slate-400 border-slate-200'
133
+ }`}>
134
+ {count}
135
+ </span>
136
+ </button>
137
+ );
138
+ })}
139
+ </div>
140
+ )}
141
+
142
+ <div className="flex flex-col gap-2">
143
+ {modelEntries.map(([modelId, info]) => {
144
+ const p = pricing[modelId] ?? { input: '', output: '', unit: 'million' };
145
+ const providerColor = PROVIDER_COLORS[info.provider] || 'bg-slate-50 text-slate-600 border-slate-200';
146
+
147
+ return (
148
+ <div key={modelId} className="border border-slate-200 rounded-lg p-4">
149
+ {/* Model header */}
150
+ <div className="flex items-center gap-2 mb-3">
151
+ <span className="text-sm font-semibold text-slate-800">
152
+ {info.displayName || modelId}
153
+ </span>
154
+ <span className={`text-xs font-medium border rounded-full px-2 py-0.5 ${providerColor}`}>
155
+ {info.provider}
156
+ </span>
157
+ <span className="text-xs text-slate-400 font-mono ml-auto">{modelId}</span>
158
+ {p.lastUpdated && (
159
+ <span className="text-xs text-slate-400 whitespace-nowrap">
160
+ verified {formatDate(p.lastUpdated)}
161
+ </span>
162
+ )}
163
+ </div>
164
+
165
+ {/* Pricing rows */}
166
+ <div className="grid grid-cols-[80px_1fr_1fr] gap-x-3 gap-y-2 items-center text-xs">
167
+ {/* Column headers */}
168
+ <div />
169
+ <div className="text-slate-400 font-medium">Price (USD $)</div>
170
+ <div className="text-slate-400 font-medium">Unit</div>
171
+
172
+ {/* Input row */}
173
+ <label className="text-slate-600 font-medium">Input</label>
174
+ <input
175
+ type="number"
176
+ min="0"
177
+ step="0.01"
178
+ value={p.input}
179
+ onChange={(e) => update(modelId, 'input', e.target.value)}
180
+ placeholder="0.00"
181
+ className="rounded-md border border-slate-300 px-2 py-1.5 text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full max-w-[120px]"
182
+ />
183
+ <select
184
+ value={p.unit}
185
+ onChange={(e) => update(modelId, 'unit', e.target.value)}
186
+ className="rounded-md border border-slate-300 px-2 py-1.5 text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white w-full max-w-[160px]"
187
+ >
188
+ {UNIT_OPTIONS.map((opt) => (
189
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
190
+ ))}
191
+ </select>
192
+
193
+ {/* Cache Read row */}
194
+ <label className="text-slate-600 font-medium">Cache Read</label>
195
+ <input
196
+ type="number"
197
+ min="0"
198
+ step="0.01"
199
+ value={p.inputCached}
200
+ onChange={(e) => update(modelId, 'inputCached', e.target.value)}
201
+ placeholder="0.00"
202
+ className="rounded-md border border-slate-300 px-2 py-1.5 text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full max-w-[120px]"
203
+ />
204
+ <span className="text-slate-400 text-xs">
205
+ {UNIT_OPTIONS.find((o) => o.value === p.unit)?.label} · discounted
206
+ </span>
207
+
208
+ {/* Output row */}
209
+ <label className="text-slate-600 font-medium">Output</label>
210
+ <input
211
+ type="number"
212
+ min="0"
213
+ step="0.01"
214
+ value={p.output}
215
+ onChange={(e) => update(modelId, 'output', e.target.value)}
216
+ placeholder="0.00"
217
+ className="rounded-md border border-slate-300 px-2 py-1.5 text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full max-w-[120px]"
218
+ />
219
+ {/* Unit selector is shared — show a static label for output to keep layout consistent */}
220
+ <span className="text-slate-400">
221
+ {UNIT_OPTIONS.find((o) => o.value === p.unit)?.label}
222
+ </span>
223
+
224
+ {/* Source URL row — aligned with price inputs */}
225
+ <label className="text-slate-600 font-medium">Source</label>
226
+ <div className="col-span-2 flex items-center gap-1.5">
227
+ <input
228
+ type="url"
229
+ value={p.source}
230
+ readOnly
231
+ placeholder="https://provider.com/pricing"
232
+ className="flex-1 rounded-md border border-slate-200 bg-slate-50 px-2 py-1.5 text-xs text-slate-500 cursor-default focus:outline-none min-w-0"
233
+ />
234
+ {p.source && (
235
+ <a
236
+ href={p.source}
237
+ target="_blank"
238
+ rel="noopener noreferrer"
239
+ className="text-blue-500 hover:text-blue-700 shrink-0"
240
+ title="Open pricing page"
241
+ >
242
+ <ExternalLink className="w-3.5 h-3.5" />
243
+ </a>
244
+ )}
245
+ </div>
246
+ </div>
247
+ </div>
248
+ );
249
+ })}
250
+ </div>
251
+
252
+ <div className="flex justify-end pt-1">
253
+ <button
254
+ type="button"
255
+ onClick={handleSave}
256
+ disabled={status === 'saving'}
257
+ className="px-4 py-2 text-sm font-medium bg-slate-900 text-white rounded-md hover:bg-slate-700 transition-colors disabled:opacity-40"
258
+ >
259
+ {status === 'saving' ? (
260
+ <span className="inline-flex items-center gap-2">
261
+ <span className="w-3.5 h-3.5 border border-white/40 border-t-white rounded-full animate-spin" />
262
+ Saving…
263
+ </span>
264
+ ) : status === 'saved' ? '✓ Saved' : status === 'error' ? '✗ Error' : 'Save Pricing'}
265
+ </button>
266
+ </div>
267
+ </div>
268
+ );
269
+ }
@@ -0,0 +1,412 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { Eye, EyeOff } from 'lucide-react';
3
+ import { saveApiKeys, connectOpenAIOAuth, disconnectOpenAIOAuth, getOpenAIOAuthStatus, testOpenAIOAuth, setOpenAIOAuthFallback } from '../../lib/api';
4
+
5
+ function formatExpiresIn(seconds) {
6
+ if (seconds <= 0) return 'expired';
7
+ if (seconds < 60) return `${seconds}s`;
8
+ const mins = Math.floor(seconds / 60);
9
+ if (mins < 60) return `${mins} min`;
10
+ return `${Math.floor(mins / 60)}h ${mins % 60}m`;
11
+ }
12
+
13
+ export function OpenAIAuthSection({ apiKeyInfo, onSaved }) {
14
+ const [authMode, setAuthMode] = useState(apiKeyInfo?.authMode || 'api-key');
15
+
16
+ // API-key sub-state
17
+ const [keyValue, setKeyValue] = useState('');
18
+ const [showKey, setShowKey] = useState(false);
19
+ const [saveStatus, setSaveStatus] = useState(null); // null | 'saving' | 'saved' | 'error'
20
+
21
+ // OAuth sub-state
22
+ const [oauthPhase, setOauthPhase] = useState(
23
+ apiKeyInfo?.oauth?.connected ? 'connected' : 'idle'
24
+ );
25
+ const [oauthInfo, setOauthInfo] = useState(apiKeyInfo?.oauth || { connected: false });
26
+ const [authorizeUrl, setAuthorizeUrl] = useState(null);
27
+ const pollRef = useRef(null);
28
+ const pollTimeoutRef = useRef(null);
29
+
30
+ // Test sub-state
31
+ const [testStatus, setTestStatus] = useState(null); // null | 'running' | { ok, response, model, elapsed } | { error }
32
+
33
+ // Fallback sub-state
34
+ const [fallbackEnabled, setFallbackEnabled] = useState(apiKeyInfo?.oauth?.fallback ?? false);
35
+ const [fallbackStatus, setFallbackStatus] = useState(null); // null | 'saving' | { error }
36
+
37
+
38
+ // Sync from parent settings refresh
39
+ useEffect(() => {
40
+ setAuthMode(apiKeyInfo?.authMode || 'api-key');
41
+ setOauthInfo(apiKeyInfo?.oauth || { connected: false });
42
+ setFallbackEnabled(apiKeyInfo?.oauth?.fallback ?? false);
43
+ if (apiKeyInfo?.oauth?.connected) {
44
+ setOauthPhase('connected');
45
+ }
46
+ }, [apiKeyInfo]);
47
+
48
+ // Stop polling on unmount
49
+ useEffect(() => {
50
+ return () => {
51
+ if (pollRef.current) clearInterval(pollRef.current);
52
+ if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current);
53
+ };
54
+ }, []);
55
+
56
+ const startPolling = () => {
57
+ pollRef.current = setInterval(async () => {
58
+ try {
59
+ const status = await getOpenAIOAuthStatus();
60
+ if (status.connected) {
61
+ setOauthInfo(status);
62
+ setOauthPhase('connected');
63
+ setAuthorizeUrl(null);
64
+ stopPolling();
65
+ onSaved();
66
+ }
67
+ } catch { /* ignore */ }
68
+ }, 2000);
69
+
70
+ // Auto-stop after 5 minutes
71
+ pollTimeoutRef.current = setTimeout(() => {
72
+ stopPolling();
73
+ setOauthPhase('idle');
74
+ }, 300_000);
75
+ };
76
+
77
+ const stopPolling = () => {
78
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
79
+ if (pollTimeoutRef.current) { clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; }
80
+ };
81
+
82
+ const handleModeToggle = (mode) => {
83
+ if (mode === authMode) return;
84
+ if (authMode === 'oauth' && oauthPhase === 'connecting') {
85
+ // Cancel an in-progress connection attempt when switching away
86
+ stopPolling();
87
+ setOauthPhase('idle');
88
+ setAuthorizeUrl(null);
89
+ }
90
+ if (mode === 'oauth') {
91
+ // Restore the correct phase when switching back to Subscription tab
92
+ setOauthPhase(oauthInfo.connected ? 'connected' : 'idle');
93
+ }
94
+ setAuthMode(mode);
95
+ };
96
+
97
+ const handleConnect = async () => {
98
+ setOauthPhase('connecting');
99
+ setAuthorizeUrl(null);
100
+ try {
101
+ const result = await connectOpenAIOAuth();
102
+ setAuthorizeUrl(result.authorizeUrl || null);
103
+ startPolling();
104
+ } catch (err) {
105
+ setOauthPhase('idle');
106
+ console.error('OAuth connect error:', err);
107
+ }
108
+ };
109
+
110
+ const handleCancel = () => {
111
+ stopPolling();
112
+ setOauthPhase('idle');
113
+ setAuthorizeUrl(null);
114
+ };
115
+
116
+ const handleDisconnect = async () => {
117
+ try { await disconnectOpenAIOAuth(); } catch { /* ignore */ }
118
+ setOauthPhase('idle');
119
+ setOauthInfo({ connected: false });
120
+ setTestStatus(null);
121
+ setAuthMode('api-key');
122
+ onSaved();
123
+ };
124
+
125
+ const handleFallbackToggle = async (enabled) => {
126
+ setFallbackStatus('saving');
127
+ try {
128
+ await setOpenAIOAuthFallback(enabled);
129
+ setFallbackEnabled(enabled);
130
+ setFallbackStatus(null);
131
+ } catch (err) {
132
+ setFallbackStatus({ error: err.message });
133
+ setTimeout(() => setFallbackStatus(null), 4000);
134
+ }
135
+ };
136
+
137
+ const handleTest = async () => {
138
+ setTestStatus('running');
139
+ try {
140
+ const result = await testOpenAIOAuth();
141
+ setTestStatus(result);
142
+ } catch (err) {
143
+ setTestStatus({ error: err.message });
144
+ }
145
+ };
146
+
147
+ const handleSaveKey = async () => {
148
+ setSaveStatus('saving');
149
+ try {
150
+ await saveApiKeys({ openai: keyValue });
151
+ setSaveStatus('saved');
152
+ setKeyValue('');
153
+ onSaved();
154
+ setTimeout(() => setSaveStatus(null), 2000);
155
+ } catch {
156
+ setSaveStatus('error');
157
+ setTimeout(() => setSaveStatus(null), 2000);
158
+ }
159
+ };
160
+
161
+ const handleClearKey = async () => {
162
+ setSaveStatus('clearing');
163
+ try {
164
+ await saveApiKeys({ openai: '' });
165
+ setSaveStatus('saved');
166
+ onSaved();
167
+ setTimeout(() => setSaveStatus(null), 2000);
168
+ } catch {
169
+ setSaveStatus('error');
170
+ setTimeout(() => setSaveStatus(null), 2000);
171
+ }
172
+ };
173
+
174
+ return (
175
+ <div className="py-3 border-b border-slate-100 last:border-0">
176
+ {/* Header row */}
177
+ <div className="flex items-center gap-3 mb-2">
178
+ <div className="w-36 flex-shrink-0">
179
+ <p className="text-sm font-medium text-slate-800">OpenAI</p>
180
+ <p className="text-xs text-slate-400">Auth mode</p>
181
+ </div>
182
+
183
+ {/* Mode toggle */}
184
+ <div className="flex items-center gap-1 bg-slate-100 rounded-lg p-1">
185
+ <button
186
+ type="button"
187
+ onClick={() => handleModeToggle('api-key')}
188
+ className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
189
+ authMode === 'api-key'
190
+ ? 'bg-white text-slate-800 shadow-sm'
191
+ : 'text-slate-500 hover:text-slate-700'
192
+ }`}
193
+ >
194
+ API Key
195
+ </button>
196
+ <button
197
+ type="button"
198
+ onClick={() => handleModeToggle('oauth')}
199
+ className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
200
+ authMode === 'oauth'
201
+ ? 'bg-white text-slate-800 shadow-sm'
202
+ : 'text-slate-500 hover:text-slate-700'
203
+ }`}
204
+ >
205
+ Subscription
206
+ </button>
207
+ </div>
208
+ </div>
209
+
210
+ {/* API Key mode */}
211
+ {authMode === 'api-key' && (
212
+ <div className="flex items-center gap-3 pl-0 pt-1">
213
+ <div className="w-36 flex-shrink-0">
214
+ <p className="text-xs text-slate-400">OPENAI_API_KEY</p>
215
+ </div>
216
+
217
+ <div className="w-16 flex-shrink-0">
218
+ {apiKeyInfo?.isSet ? (
219
+ <span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
220
+ ✓ Set
221
+ </span>
222
+ ) : (
223
+ <span className="inline-flex items-center text-xs font-medium text-slate-400 bg-slate-50 border border-slate-200 rounded-full px-2 py-0.5">
224
+ Not set
225
+ </span>
226
+ )}
227
+ </div>
228
+
229
+ {apiKeyInfo?.isSet && !keyValue && (
230
+ <p className="text-xs text-slate-400 font-mono flex-shrink-0">{apiKeyInfo.preview}</p>
231
+ )}
232
+
233
+ <div className="flex-1 flex items-center gap-2 min-w-0">
234
+ <div className="relative flex-1">
235
+ <input
236
+ type={showKey ? 'text' : 'password'}
237
+ value={keyValue}
238
+ onChange={(e) => setKeyValue(e.target.value)}
239
+ placeholder={apiKeyInfo?.isSet ? 'Enter new key to update…' : 'sk-…'}
240
+ className="w-full rounded-md border border-slate-300 px-2 py-1.5 pr-8 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
241
+ />
242
+ <button
243
+ type="button"
244
+ onClick={() => setShowKey(v => !v)}
245
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
246
+ tabIndex={-1}
247
+ >
248
+ {showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
249
+ </button>
250
+ </div>
251
+
252
+ {apiKeyInfo?.isSet && !keyValue && (
253
+ <button
254
+ type="button"
255
+ onClick={handleClearKey}
256
+ disabled={saveStatus === 'clearing'}
257
+ className="px-3 py-1.5 text-xs font-medium border border-red-200 text-red-600 rounded-md hover:bg-red-50 transition-colors disabled:opacity-40 flex-shrink-0"
258
+ >
259
+ {saveStatus === 'clearing' ? '…' : 'Reset'}
260
+ </button>
261
+ )}
262
+
263
+ <button
264
+ type="button"
265
+ onClick={handleSaveKey}
266
+ disabled={!keyValue.trim() || saveStatus === 'saving'}
267
+ className="px-3 py-1.5 text-xs font-medium bg-slate-900 text-white rounded-md hover:bg-slate-700 transition-colors disabled:opacity-40 flex-shrink-0"
268
+ >
269
+ {saveStatus === 'saving' ? (
270
+ <span className="inline-flex items-center gap-1">
271
+ <span className="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin" />
272
+ Saving
273
+ </span>
274
+ ) : saveStatus === 'saved' ? '✓ Saved' : saveStatus === 'error' ? '✗ Error' : 'Save'}
275
+ </button>
276
+ </div>
277
+ </div>
278
+ )}
279
+
280
+ {/* OAuth mode */}
281
+ {authMode === 'oauth' && (
282
+ <div className="pl-0 pt-1">
283
+ {/* Idle — not yet connecting */}
284
+ {oauthPhase === 'idle' && (
285
+ <div className="flex flex-col gap-2">
286
+ <p className="text-xs text-slate-500">
287
+ <span className="font-medium text-amber-700">ℹ</span>{' '}
288
+ Requires a <strong>ChatGPT Pro</strong> subscription ($200/mo).
289
+ Only Codex-endpoint models work (<code className="font-mono bg-slate-100 px-1 rounded">gpt-5.2-codex</code>,{' '}
290
+ <code className="font-mono bg-slate-100 px-1 rounded">gpt-5.3-codex</code>).
291
+ This endpoint is unofficial and may change without notice.
292
+ </p>
293
+ <div>
294
+ <button
295
+ type="button"
296
+ onClick={handleConnect}
297
+ className="inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-emerald-600 text-white rounded-md hover:bg-emerald-700 transition-colors"
298
+ >
299
+ Connect with ChatGPT ↗
300
+ </button>
301
+ </div>
302
+ </div>
303
+ )}
304
+
305
+ {/* Connecting — waiting for browser callback */}
306
+ {oauthPhase === 'connecting' && (
307
+ <div className="flex flex-col gap-2">
308
+ <p className="text-xs text-slate-600 flex items-center gap-2">
309
+ <span className="w-3 h-3 border border-slate-400 border-t-slate-700 rounded-full animate-spin inline-block" />
310
+ Waiting for browser login…
311
+ </p>
312
+ {authorizeUrl && (
313
+ <p className="text-xs text-slate-500">
314
+ If your browser did not open,{' '}
315
+ <a
316
+ href={authorizeUrl}
317
+ target="_blank"
318
+ rel="noreferrer"
319
+ className="text-blue-600 underline break-all"
320
+ >
321
+ click here to authenticate
322
+ </a>
323
+ .
324
+ </p>
325
+ )}
326
+ <div>
327
+ <button
328
+ type="button"
329
+ onClick={handleCancel}
330
+ className="px-3 py-1 text-xs font-medium border border-slate-300 rounded-md text-slate-600 hover:bg-slate-50 transition-colors"
331
+ >
332
+ Cancel
333
+ </button>
334
+ </div>
335
+ </div>
336
+ )}
337
+
338
+ {/* Connected */}
339
+ {oauthPhase === 'connected' && (
340
+ <div className="flex flex-col gap-2">
341
+ <div className="flex items-center gap-3">
342
+ <span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5">
343
+ ✓ Connected
344
+ </span>
345
+ {oauthInfo.accountId && (
346
+ <span className="text-xs text-slate-500 font-mono">{oauthInfo.accountId}</span>
347
+ )}
348
+ {oauthInfo.expiresIn != null && (
349
+ <span className="text-xs text-slate-400">
350
+ · expires in {formatExpiresIn(oauthInfo.expiresIn)}
351
+ </span>
352
+ )}
353
+ <div className="ml-auto flex items-center gap-2">
354
+ <button
355
+ type="button"
356
+ onClick={handleTest}
357
+ disabled={testStatus === 'running'}
358
+ className="w-14 flex items-center justify-center py-1 text-xs font-medium border border-emerald-300 rounded-md text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-40"
359
+ >
360
+ {testStatus === 'running'
361
+ ? <span className="w-3 h-3 border border-emerald-400 border-t-emerald-700 rounded-full animate-spin" />
362
+ : 'Test'}
363
+ </button>
364
+ <button
365
+ type="button"
366
+ onClick={handleDisconnect}
367
+ className="px-3 py-1 text-xs font-medium border border-slate-300 rounded-md text-slate-600 hover:bg-slate-50 transition-colors"
368
+ >
369
+ Disconnect
370
+ </button>
371
+ </div>
372
+ </div>
373
+ {testStatus && testStatus !== 'running' && (
374
+ <div className={`text-xs rounded-md px-3 py-2 font-mono ${testStatus.error ? 'bg-red-50 text-red-700 border border-red-200' : 'bg-slate-50 text-slate-700 border border-slate-200'}`}>
375
+ {testStatus.error
376
+ ? `✗ ${testStatus.error}`
377
+ : `✓ ${testStatus.response || 'Connected'} [${testStatus.model} · ${testStatus.elapsed}ms]`}
378
+ </div>
379
+ )}
380
+ {/* Fallback toggle — only shown when an API key is also configured */}
381
+ {apiKeyInfo?.isSet && (
382
+ <div className="flex items-center gap-3 pt-1 border-t border-slate-100">
383
+ <label className="flex items-center gap-2 cursor-pointer select-none">
384
+ <div className="relative">
385
+ <input
386
+ type="checkbox"
387
+ checked={fallbackEnabled}
388
+ disabled={fallbackStatus === 'saving'}
389
+ onChange={(e) => handleFallbackToggle(e.target.checked)}
390
+ className="sr-only"
391
+ />
392
+ <div
393
+ onClick={() => fallbackStatus !== 'saving' && handleFallbackToggle(!fallbackEnabled)}
394
+ className={`w-8 h-4 rounded-full transition-colors cursor-pointer ${fallbackEnabled ? 'bg-blue-500' : 'bg-slate-300'} ${fallbackStatus === 'saving' ? 'opacity-40' : ''}`}
395
+ >
396
+ <div className={`absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full shadow transition-transform ${fallbackEnabled ? 'translate-x-4' : ''}`} />
397
+ </div>
398
+ </div>
399
+ <span className="text-xs text-slate-600">Fallback to API Key on failure</span>
400
+ </label>
401
+ {fallbackStatus?.error && (
402
+ <span className="text-xs text-red-600">{fallbackStatus.error}</span>
403
+ )}
404
+ </div>
405
+ )}
406
+ </div>
407
+ )}
408
+ </div>
409
+ )}
410
+ </div>
411
+ );
412
+ }