prompt_objects 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/Gemfile.lock +1 -1
- data/README.md +2 -2
- data/exe/prompt_objects +387 -1
- data/frontend/src/App.tsx +11 -3
- data/frontend/src/components/ContextMenu.tsx +67 -0
- data/frontend/src/components/MessageBus.tsx +4 -3
- data/frontend/src/components/ModelSelector.tsx +5 -1
- data/frontend/src/components/ThreadsSidebar.tsx +46 -2
- data/frontend/src/components/UsagePanel.tsx +105 -0
- data/frontend/src/hooks/useWebSocket.ts +53 -0
- data/frontend/src/store/index.ts +10 -0
- data/frontend/src/types/index.ts +4 -1
- data/lib/prompt_objects/cli.rb +1 -0
- data/lib/prompt_objects/connectors/mcp.rb +1 -0
- data/lib/prompt_objects/environment.rb +24 -1
- data/lib/prompt_objects/llm/anthropic_adapter.rb +15 -1
- data/lib/prompt_objects/llm/factory.rb +93 -6
- data/lib/prompt_objects/llm/gemini_adapter.rb +13 -1
- data/lib/prompt_objects/llm/openai_adapter.rb +21 -4
- data/lib/prompt_objects/llm/pricing.rb +49 -0
- data/lib/prompt_objects/llm/response.rb +3 -2
- data/lib/prompt_objects/mcp/server.rb +1 -0
- data/lib/prompt_objects/message_bus.rb +27 -8
- data/lib/prompt_objects/prompt_object.rb +5 -3
- data/lib/prompt_objects/server/api/routes.rb +186 -29
- data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +1 -0
- data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +77 -0
- data/lib/prompt_objects/server/public/index.html +2 -2
- data/lib/prompt_objects/server/websocket_handler.rb +93 -9
- data/lib/prompt_objects/server.rb +54 -0
- data/lib/prompt_objects/session/store.rb +399 -4
- data/lib/prompt_objects.rb +1 -0
- data/prompt_objects.gemspec +1 -1
- data/templates/arc-agi-1/manifest.yml +22 -0
- data/templates/arc-agi-1/objects/data_manager.md +42 -0
- data/templates/arc-agi-1/objects/observer.md +100 -0
- data/templates/arc-agi-1/objects/solver.md +118 -0
- data/templates/arc-agi-1/objects/verifier.md +79 -0
- data/templates/arc-agi-1/primitives/check_arc_data.rb +53 -0
- data/templates/arc-agi-1/primitives/find_objects.rb +72 -0
- data/templates/arc-agi-1/primitives/grid_diff.rb +70 -0
- data/templates/arc-agi-1/primitives/grid_info.rb +42 -0
- data/templates/arc-agi-1/primitives/grid_transform.rb +50 -0
- data/templates/arc-agi-1/primitives/load_arc_task.rb +68 -0
- data/templates/arc-agi-1/primitives/render_grid.rb +78 -0
- data/templates/arc-agi-1/primitives/test_solution.rb +131 -0
- metadata +20 -3
- data/lib/prompt_objects/server/public/assets/index-CeNJvqLG.js +0 -77
- data/lib/prompt_objects/server/public/assets/index-Vx4-uMOU.css +0 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { useMemo } from 'react'
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { ContextMenu } from './ContextMenu'
|
|
2
3
|
import type { PromptObject, Session, ThreadType } from '../types'
|
|
3
4
|
|
|
4
5
|
interface ThreadsSidebarProps {
|
|
5
6
|
po: PromptObject
|
|
6
7
|
switchSession: (target: string, sessionId: string) => void
|
|
7
8
|
createThread: (target: string) => void
|
|
9
|
+
requestUsage?: (sessionId: string, includeTree?: boolean) => void
|
|
10
|
+
exportThread?: (sessionId: string, format?: string) => void
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
// Build a flat list with depth info
|
|
@@ -59,12 +62,18 @@ function ThreadTypeIcon({ type }: { type: ThreadType }) {
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSidebarProps) {
|
|
65
|
+
export function ThreadsSidebar({ po, switchSession, createThread, requestUsage, exportThread }: ThreadsSidebarProps) {
|
|
63
66
|
const sessions = po.sessions || []
|
|
64
67
|
const currentSessionId = po.current_session?.id
|
|
68
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null)
|
|
65
69
|
|
|
66
70
|
const threadList = useMemo(() => buildThreadList(sessions), [sessions])
|
|
67
71
|
|
|
72
|
+
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
setContextMenu({ x: e.clientX, y: e.clientY, sessionId })
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
return (
|
|
69
78
|
<div className="h-full flex flex-col">
|
|
70
79
|
<div className="p-3 border-b border-po-border flex items-center justify-between">
|
|
@@ -88,6 +97,7 @@ export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSideb
|
|
|
88
97
|
<button
|
|
89
98
|
key={session.id}
|
|
90
99
|
onClick={() => switchSession(po.name, session.id)}
|
|
100
|
+
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
|
91
101
|
className={`w-full text-left p-2 rounded text-xs transition-colors ${
|
|
92
102
|
session.id === currentSessionId
|
|
93
103
|
? 'bg-po-accent/20 border border-po-accent'
|
|
@@ -114,6 +124,40 @@ export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSideb
|
|
|
114
124
|
))
|
|
115
125
|
)}
|
|
116
126
|
</div>
|
|
127
|
+
|
|
128
|
+
{contextMenu && (
|
|
129
|
+
<ContextMenu
|
|
130
|
+
x={contextMenu.x}
|
|
131
|
+
y={contextMenu.y}
|
|
132
|
+
onClose={() => setContextMenu(null)}
|
|
133
|
+
items={[
|
|
134
|
+
...(requestUsage ? [
|
|
135
|
+
{
|
|
136
|
+
label: 'View Usage',
|
|
137
|
+
icon: '📊',
|
|
138
|
+
onClick: () => requestUsage(contextMenu.sessionId),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
label: 'View Tree Usage',
|
|
142
|
+
icon: '🌳',
|
|
143
|
+
onClick: () => requestUsage(contextMenu.sessionId, true),
|
|
144
|
+
},
|
|
145
|
+
] : []),
|
|
146
|
+
...(exportThread ? [
|
|
147
|
+
{
|
|
148
|
+
label: 'Export as Markdown',
|
|
149
|
+
icon: '📄',
|
|
150
|
+
onClick: () => exportThread(contextMenu.sessionId, 'markdown'),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
label: 'Export as JSON',
|
|
154
|
+
icon: '📋',
|
|
155
|
+
onClick: () => exportThread(contextMenu.sessionId, 'json'),
|
|
156
|
+
},
|
|
157
|
+
] : []),
|
|
158
|
+
]}
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
117
161
|
</div>
|
|
118
162
|
)
|
|
119
163
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
interface UsageData {
|
|
2
|
+
session_id: string
|
|
3
|
+
include_tree: boolean
|
|
4
|
+
input_tokens: number
|
|
5
|
+
output_tokens: number
|
|
6
|
+
total_tokens: number
|
|
7
|
+
estimated_cost_usd: number
|
|
8
|
+
calls: number
|
|
9
|
+
by_model: Record<string, {
|
|
10
|
+
input_tokens: number
|
|
11
|
+
output_tokens: number
|
|
12
|
+
estimated_cost_usd: number
|
|
13
|
+
calls: number
|
|
14
|
+
}>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UsagePanelProps {
|
|
18
|
+
usage: UsageData
|
|
19
|
+
onClose: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatTokens(n: number): string {
|
|
23
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
24
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
|
|
25
|
+
return n.toString()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatCost(usd: number): string {
|
|
29
|
+
if (usd === 0) return '$0.00'
|
|
30
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`
|
|
31
|
+
return `$${usd.toFixed(2)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function UsagePanel({ usage, onClose }: UsagePanelProps) {
|
|
35
|
+
const models = Object.entries(usage.by_model)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
39
|
+
<div
|
|
40
|
+
className="bg-po-surface border border-po-border rounded-lg shadow-2xl w-[420px] max-h-[80vh] overflow-auto"
|
|
41
|
+
onClick={(e) => e.stopPropagation()}
|
|
42
|
+
>
|
|
43
|
+
<div className="flex items-center justify-between p-4 border-b border-po-border">
|
|
44
|
+
<h3 className="font-medium text-white">
|
|
45
|
+
Token Usage {usage.include_tree && <span className="text-xs text-gray-400 ml-1">(full tree)</span>}
|
|
46
|
+
</h3>
|
|
47
|
+
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
|
|
48
|
+
×
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div className="p-4 space-y-4">
|
|
53
|
+
{/* Summary */}
|
|
54
|
+
<div className="grid grid-cols-3 gap-3">
|
|
55
|
+
<div className="bg-po-bg rounded-lg p-3 text-center">
|
|
56
|
+
<div className="text-lg font-mono text-po-accent">{formatTokens(usage.input_tokens)}</div>
|
|
57
|
+
<div className="text-[10px] text-gray-500 uppercase tracking-wider">Input</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="bg-po-bg rounded-lg p-3 text-center">
|
|
60
|
+
<div className="text-lg font-mono text-po-warning">{formatTokens(usage.output_tokens)}</div>
|
|
61
|
+
<div className="text-[10px] text-gray-500 uppercase tracking-wider">Output</div>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="bg-po-bg rounded-lg p-3 text-center">
|
|
64
|
+
<div className="text-lg font-mono text-white">{formatCost(usage.estimated_cost_usd)}</div>
|
|
65
|
+
<div className="text-[10px] text-gray-500 uppercase tracking-wider">Est. Cost</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="flex justify-between text-xs text-gray-400 px-1">
|
|
70
|
+
<span>{usage.calls} LLM call{usage.calls !== 1 ? 's' : ''}</span>
|
|
71
|
+
<span>{formatTokens(usage.total_tokens)} total tokens</span>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Per-model breakdown */}
|
|
75
|
+
{models.length > 0 && (
|
|
76
|
+
<div>
|
|
77
|
+
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">By Model</h4>
|
|
78
|
+
<div className="space-y-2">
|
|
79
|
+
{models.map(([model, data]) => (
|
|
80
|
+
<div key={model} className="bg-po-bg rounded-lg p-3">
|
|
81
|
+
<div className="flex items-center justify-between mb-1">
|
|
82
|
+
<span className="text-xs font-mono text-white">{model}</span>
|
|
83
|
+
<span className="text-xs text-gray-400">{data.calls} call{data.calls !== 1 ? 's' : ''}</span>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="flex justify-between text-[10px] text-gray-500">
|
|
86
|
+
<span>In: {formatTokens(data.input_tokens)}</span>
|
|
87
|
+
<span>Out: {formatTokens(data.output_tokens)}</span>
|
|
88
|
+
<span>{formatCost(data.estimated_cost_usd)}</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{usage.calls === 0 && (
|
|
97
|
+
<div className="text-center text-gray-500 text-sm py-4">
|
|
98
|
+
No usage data recorded for this thread.
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -36,6 +36,7 @@ export function useWebSocket() {
|
|
|
36
36
|
clearPendingResponse,
|
|
37
37
|
setLLMConfig,
|
|
38
38
|
updateCurrentLLM,
|
|
39
|
+
setUsageData,
|
|
39
40
|
} = useStore()
|
|
40
41
|
|
|
41
42
|
const connect = useCallback(() => {
|
|
@@ -213,6 +214,25 @@ export function useWebSocket() {
|
|
|
213
214
|
break
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
case 'session_usage': {
|
|
218
|
+
setUsageData(message.payload as Record<string, unknown>)
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'thread_export': {
|
|
223
|
+
const { content, format, session_id } = message.payload as { content: string; format: string; session_id: string }
|
|
224
|
+
const mimeType = format === 'json' ? 'application/json' : 'text/markdown'
|
|
225
|
+
const ext = format === 'json' ? 'json' : 'md'
|
|
226
|
+
const blob = new Blob([content], { type: mimeType })
|
|
227
|
+
const url = URL.createObjectURL(blob)
|
|
228
|
+
const a = document.createElement('a')
|
|
229
|
+
a.href = url
|
|
230
|
+
a.download = `${session_id}.${ext}`
|
|
231
|
+
a.click()
|
|
232
|
+
URL.revokeObjectURL(url)
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
|
|
216
236
|
case 'error': {
|
|
217
237
|
const { message: errorMsg } = message.payload as { message: string }
|
|
218
238
|
console.error('Server error:', errorMsg)
|
|
@@ -242,6 +262,7 @@ export function useWebSocket() {
|
|
|
242
262
|
removeNotification,
|
|
243
263
|
setLLMConfig,
|
|
244
264
|
updateCurrentLLM,
|
|
265
|
+
setUsageData,
|
|
245
266
|
]
|
|
246
267
|
)
|
|
247
268
|
|
|
@@ -352,6 +373,36 @@ export function useWebSocket() {
|
|
|
352
373
|
)
|
|
353
374
|
}, [])
|
|
354
375
|
|
|
376
|
+
// Request usage data for a session
|
|
377
|
+
const requestUsage = useCallback((sessionId: string, includeTree?: boolean) => {
|
|
378
|
+
if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
|
|
379
|
+
console.error('WebSocket not connected')
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
ws.current.send(
|
|
384
|
+
JSON.stringify({
|
|
385
|
+
type: 'get_session_usage',
|
|
386
|
+
payload: { session_id: sessionId, include_tree: includeTree || false },
|
|
387
|
+
})
|
|
388
|
+
)
|
|
389
|
+
}, [])
|
|
390
|
+
|
|
391
|
+
// Export a thread as markdown or JSON
|
|
392
|
+
const exportThread = useCallback((sessionId: string, format?: string) => {
|
|
393
|
+
if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
|
|
394
|
+
console.error('WebSocket not connected')
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
ws.current.send(
|
|
399
|
+
JSON.stringify({
|
|
400
|
+
type: 'export_thread',
|
|
401
|
+
payload: { session_id: sessionId, format: format || 'markdown' },
|
|
402
|
+
})
|
|
403
|
+
)
|
|
404
|
+
}, [])
|
|
405
|
+
|
|
355
406
|
// Update a PO's prompt (markdown body)
|
|
356
407
|
const updatePrompt = useCallback((target: string, prompt: string) => {
|
|
357
408
|
if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
|
|
@@ -375,5 +426,7 @@ export function useWebSocket() {
|
|
|
375
426
|
switchLLM,
|
|
376
427
|
createThread,
|
|
377
428
|
updatePrompt,
|
|
429
|
+
requestUsage,
|
|
430
|
+
exportThread,
|
|
378
431
|
}
|
|
379
432
|
}
|
data/frontend/src/store/index.ts
CHANGED
|
@@ -58,6 +58,11 @@ interface Store {
|
|
|
58
58
|
llmConfig: LLMConfig | null
|
|
59
59
|
setLLMConfig: (config: LLMConfig) => void
|
|
60
60
|
updateCurrentLLM: (provider: string, model: string) => void
|
|
61
|
+
|
|
62
|
+
// Usage data (for modal display)
|
|
63
|
+
usageData: Record<string, unknown> | null
|
|
64
|
+
setUsageData: (data: Record<string, unknown>) => void
|
|
65
|
+
clearUsageData: () => void
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export const useStore = create<Store>((set) => ({
|
|
@@ -230,6 +235,11 @@ export const useStore = create<Store>((set) => ({
|
|
|
230
235
|
? { ...s.llmConfig, current_provider: provider, current_model: model }
|
|
231
236
|
: null,
|
|
232
237
|
})),
|
|
238
|
+
|
|
239
|
+
// Usage data
|
|
240
|
+
usageData: null,
|
|
241
|
+
setUsageData: (data) => set({ usageData: data }),
|
|
242
|
+
clearUsageData: () => set({ usageData: null }),
|
|
233
243
|
}))
|
|
234
244
|
|
|
235
245
|
// Selectors - use useShallow to prevent infinite re-renders with derived arrays
|
data/frontend/src/types/index.ts
CHANGED
|
@@ -67,7 +67,8 @@ export interface PromptObject {
|
|
|
67
67
|
export interface BusMessage {
|
|
68
68
|
from: string
|
|
69
69
|
to: string
|
|
70
|
-
content: string
|
|
70
|
+
content: string | Record<string, unknown>
|
|
71
|
+
summary?: string
|
|
71
72
|
timestamp: string
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -120,6 +121,8 @@ export type WSMessageType =
|
|
|
120
121
|
| 'thread_tree'
|
|
121
122
|
| 'llm_config'
|
|
122
123
|
| 'llm_switched'
|
|
124
|
+
| 'session_usage'
|
|
125
|
+
| 'thread_export'
|
|
123
126
|
| 'error'
|
|
124
127
|
| 'pong'
|
|
125
128
|
|
data/lib/prompt_objects/cli.rb
CHANGED
|
@@ -365,6 +365,7 @@ module PromptObjects
|
|
|
365
365
|
developer - Code review, debugging, testing specialists
|
|
366
366
|
writer - Editor, researcher for content creation
|
|
367
367
|
empty - Bootstrap assistant only
|
|
368
|
+
arc-agi-1 - ARC-AGI-1 challenge solving with grid primitives
|
|
368
369
|
HELP
|
|
369
370
|
end
|
|
370
371
|
end
|
|
@@ -86,7 +86,7 @@ module PromptObjects
|
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
@registry = Registry.new
|
|
89
|
-
@bus = MessageBus.new
|
|
89
|
+
@bus = MessageBus.new(session_store: @session_store)
|
|
90
90
|
@human_queue = HumanQueue.new
|
|
91
91
|
|
|
92
92
|
register_primitives
|
|
@@ -267,6 +267,29 @@ module PromptObjects
|
|
|
267
267
|
@registry.register(Primitives::ListFiles.new)
|
|
268
268
|
@registry.register(Primitives::WriteFile.new)
|
|
269
269
|
@registry.register(Primitives::HttpGet.new)
|
|
270
|
+
|
|
271
|
+
# Load custom primitives from environment's primitives directory
|
|
272
|
+
load_custom_primitives
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Load custom primitives from the environment's primitives/ directory.
|
|
276
|
+
# Follows the same pattern as Universal::CreatePrimitive for class naming
|
|
277
|
+
# and loading. Warns on errors rather than crashing so a broken primitive
|
|
278
|
+
# file doesn't prevent the environment from starting (a PO can fix it).
|
|
279
|
+
def load_custom_primitives
|
|
280
|
+
return unless @primitives_dir && Dir.exist?(@primitives_dir)
|
|
281
|
+
|
|
282
|
+
Dir.glob(File.join(@primitives_dir, "*.rb")).sort.each do |path|
|
|
283
|
+
begin
|
|
284
|
+
filename = File.basename(path, ".rb")
|
|
285
|
+
class_name = filename.split("_").map(&:capitalize).join
|
|
286
|
+
load(path)
|
|
287
|
+
klass = PromptObjects::Primitives.const_get(class_name)
|
|
288
|
+
@registry.register(klass.new)
|
|
289
|
+
rescue SyntaxError, StandardError => e
|
|
290
|
+
warn "Warning: Failed to load custom primitive #{path}: #{e.message}"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
270
293
|
end
|
|
271
294
|
|
|
272
295
|
# Register universal capabilities (available to all prompt objects).
|
|
@@ -130,7 +130,21 @@ module PromptObjects
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
Response.new(content: content, tool_calls: tool_calls, raw: raw)
|
|
133
|
+
Response.new(content: content, tool_calls: tool_calls, raw: raw, usage: extract_usage(raw))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def extract_usage(raw)
|
|
137
|
+
return nil unless raw.respond_to?(:usage) && raw.usage
|
|
138
|
+
|
|
139
|
+
usage = raw.usage
|
|
140
|
+
{
|
|
141
|
+
input_tokens: usage.respond_to?(:input_tokens) ? usage.input_tokens : 0,
|
|
142
|
+
output_tokens: usage.respond_to?(:output_tokens) ? usage.output_tokens : 0,
|
|
143
|
+
cache_creation_tokens: usage.respond_to?(:cache_creation_input_tokens) ? (usage.cache_creation_input_tokens || 0) : 0,
|
|
144
|
+
cache_read_tokens: usage.respond_to?(:cache_read_input_tokens) ? (usage.cache_read_input_tokens || 0) : 0,
|
|
145
|
+
model: @model,
|
|
146
|
+
provider: "anthropic"
|
|
147
|
+
}
|
|
134
148
|
end
|
|
135
149
|
end
|
|
136
150
|
end
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module PromptObjects
|
|
4
7
|
module LLM
|
|
5
8
|
# Factory for creating LLM adapters based on provider name.
|
|
6
|
-
# Provides a unified interface for switching between
|
|
9
|
+
# Provides a unified interface for switching between providers.
|
|
7
10
|
class Factory
|
|
8
11
|
PROVIDERS = {
|
|
9
12
|
"openai" => {
|
|
@@ -23,6 +26,34 @@ module PromptObjects
|
|
|
23
26
|
env_key: "GEMINI_API_KEY",
|
|
24
27
|
default_model: "gemini-3-flash-preview",
|
|
25
28
|
models: %w[gemini-3-flash-preview gemini-2.5-pro gemini-2.5-flash]
|
|
29
|
+
},
|
|
30
|
+
"ollama" => {
|
|
31
|
+
adapter: "OpenAIAdapter",
|
|
32
|
+
env_key: nil,
|
|
33
|
+
api_key_default: "ollama",
|
|
34
|
+
default_model: "llama3.2",
|
|
35
|
+
models: [], # Dynamic — populated from Ollama API
|
|
36
|
+
base_url: "http://localhost:11434/v1",
|
|
37
|
+
local: true
|
|
38
|
+
},
|
|
39
|
+
"openrouter" => {
|
|
40
|
+
adapter: "OpenAIAdapter",
|
|
41
|
+
env_key: "OPENROUTER_API_KEY",
|
|
42
|
+
default_model: "meta-llama/llama-3.3-70b-instruct",
|
|
43
|
+
models: %w[
|
|
44
|
+
meta-llama/llama-3.3-70b-instruct
|
|
45
|
+
meta-llama/llama-4-scout
|
|
46
|
+
meta-llama/llama-4-maverick
|
|
47
|
+
mistralai/mistral-large-2411
|
|
48
|
+
google/gemma-3-27b-it
|
|
49
|
+
deepseek/deepseek-r1
|
|
50
|
+
qwen/qwen-2.5-72b-instruct
|
|
51
|
+
],
|
|
52
|
+
base_url: "https://openrouter.ai/api/v1",
|
|
53
|
+
extra_headers: {
|
|
54
|
+
"HTTP-Referer" => "https://github.com/prompt-objects",
|
|
55
|
+
"X-Title" => "PromptObjects"
|
|
56
|
+
}
|
|
26
57
|
}
|
|
27
58
|
}.freeze
|
|
28
59
|
|
|
@@ -30,7 +61,7 @@ module PromptObjects
|
|
|
30
61
|
|
|
31
62
|
class << self
|
|
32
63
|
# Create an adapter for the given provider.
|
|
33
|
-
# @param provider [String] Provider name
|
|
64
|
+
# @param provider [String] Provider name
|
|
34
65
|
# @param model [String, nil] Optional model override
|
|
35
66
|
# @param api_key [String, nil] Optional API key override
|
|
36
67
|
# @return [OpenAIAdapter, AnthropicAdapter, GeminiAdapter]
|
|
@@ -40,8 +71,20 @@ module PromptObjects
|
|
|
40
71
|
|
|
41
72
|
raise Error, "Unknown LLM provider: #{provider_name}" unless config
|
|
42
73
|
|
|
74
|
+
# Resolve API key: explicit > env var > default
|
|
75
|
+
resolved_key = api_key ||
|
|
76
|
+
(config[:env_key] && ENV[config[:env_key]]) ||
|
|
77
|
+
config[:api_key_default]
|
|
78
|
+
|
|
43
79
|
adapter_class = LLM.const_get(config[:adapter])
|
|
44
|
-
|
|
80
|
+
|
|
81
|
+
adapter_args = { api_key: resolved_key, model: model || config[:default_model] }
|
|
82
|
+
adapter_args[:base_url] = config[:base_url] if config[:base_url]
|
|
83
|
+
adapter_args[:extra_headers] = config[:extra_headers] if config[:extra_headers]
|
|
84
|
+
# Pass provider name for usage tracking (OpenAI adapter handles multiple providers)
|
|
85
|
+
adapter_args[:provider_name] = provider_name if config[:adapter] == "OpenAIAdapter"
|
|
86
|
+
|
|
87
|
+
adapter_class.new(**adapter_args)
|
|
45
88
|
end
|
|
46
89
|
|
|
47
90
|
# List available providers.
|
|
@@ -57,11 +100,17 @@ module PromptObjects
|
|
|
57
100
|
PROVIDERS[provider.to_s.downcase]
|
|
58
101
|
end
|
|
59
102
|
|
|
60
|
-
# Check which providers have API keys
|
|
103
|
+
# Check which providers are available (have API keys or are local).
|
|
61
104
|
# @return [Hash<String, Boolean>]
|
|
62
105
|
def available_providers
|
|
63
106
|
PROVIDERS.transform_values do |config|
|
|
64
|
-
|
|
107
|
+
if config[:local]
|
|
108
|
+
check_local_provider(config[:base_url])
|
|
109
|
+
elsif config[:env_key]
|
|
110
|
+
ENV.key?(config[:env_key])
|
|
111
|
+
else
|
|
112
|
+
true
|
|
113
|
+
end
|
|
65
114
|
end
|
|
66
115
|
end
|
|
67
116
|
|
|
@@ -73,10 +122,48 @@ module PromptObjects
|
|
|
73
122
|
end
|
|
74
123
|
|
|
75
124
|
# Get available models for a provider.
|
|
125
|
+
# For Ollama, dynamically discovers installed models.
|
|
76
126
|
# @param provider [String] Provider name
|
|
77
127
|
# @return [Array<String>]
|
|
78
128
|
def models_for(provider)
|
|
79
|
-
PROVIDERS
|
|
129
|
+
config = PROVIDERS[provider.to_s.downcase]
|
|
130
|
+
return [] unless config
|
|
131
|
+
|
|
132
|
+
# Dynamic model discovery for Ollama
|
|
133
|
+
if config[:local] && provider.to_s.downcase == "ollama"
|
|
134
|
+
models = discover_ollama_models(config[:base_url])
|
|
135
|
+
return models unless models.empty?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
config[:models] || []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Discover installed Ollama models.
|
|
142
|
+
# @param base_url [String] Ollama API base URL (with /v1 suffix)
|
|
143
|
+
# @return [Array<String>] Model names
|
|
144
|
+
def discover_ollama_models(base_url = "http://localhost:11434/v1")
|
|
145
|
+
# Ollama's model list endpoint is at /api/tags (not under /v1)
|
|
146
|
+
tags_url = base_url.sub(%r{/v1\z}, "") + "/api/tags"
|
|
147
|
+
uri = URI(tags_url)
|
|
148
|
+
response = Net::HTTP.get_response(uri)
|
|
149
|
+
return [] unless response.is_a?(Net::HTTPSuccess)
|
|
150
|
+
|
|
151
|
+
data = JSON.parse(response.body)
|
|
152
|
+
(data["models"] || []).map { |m| m["name"] }
|
|
153
|
+
rescue StandardError
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def check_local_provider(base_url)
|
|
160
|
+
return false unless base_url
|
|
161
|
+
tags_url = base_url.sub(%r{/v1\z}, "") + "/api/tags"
|
|
162
|
+
uri = URI(tags_url)
|
|
163
|
+
response = Net::HTTP.get_response(uri)
|
|
164
|
+
response.is_a?(Net::HTTPSuccess)
|
|
165
|
+
rescue StandardError
|
|
166
|
+
false
|
|
80
167
|
end
|
|
81
168
|
end
|
|
82
169
|
end
|
|
@@ -192,7 +192,19 @@ module PromptObjects
|
|
|
192
192
|
end
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
Response.new(content: text_content, tool_calls: tool_calls, raw: raw)
|
|
195
|
+
Response.new(content: text_content, tool_calls: tool_calls, raw: raw, usage: extract_usage(raw))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def extract_usage(raw)
|
|
199
|
+
meta = raw["usageMetadata"]
|
|
200
|
+
return nil unless meta
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
input_tokens: meta["promptTokenCount"] || 0,
|
|
204
|
+
output_tokens: meta["candidatesTokenCount"] || 0,
|
|
205
|
+
model: @model,
|
|
206
|
+
provider: "gemini"
|
|
207
|
+
}
|
|
196
208
|
end
|
|
197
209
|
|
|
198
210
|
def parse_function_call(fc)
|
|
@@ -6,12 +6,17 @@ module PromptObjects
|
|
|
6
6
|
class OpenAIAdapter
|
|
7
7
|
DEFAULT_MODEL = "gpt-5.2"
|
|
8
8
|
|
|
9
|
-
def initialize(api_key: nil, model: nil)
|
|
9
|
+
def initialize(api_key: nil, model: nil, base_url: nil, extra_headers: nil, provider_name: nil)
|
|
10
10
|
@api_key = api_key || ENV.fetch("OPENAI_API_KEY") do
|
|
11
11
|
raise Error, "OPENAI_API_KEY environment variable not set"
|
|
12
12
|
end
|
|
13
13
|
@model = model || DEFAULT_MODEL
|
|
14
|
-
@
|
|
14
|
+
@provider_name = provider_name || "openai"
|
|
15
|
+
|
|
16
|
+
client_opts = { access_token: @api_key }
|
|
17
|
+
client_opts[:uri_base] = base_url if base_url
|
|
18
|
+
client_opts[:extra_headers] = extra_headers if extra_headers
|
|
19
|
+
@client = OpenAI::Client.new(**client_opts)
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
# Make a chat completion request.
|
|
@@ -80,12 +85,24 @@ module PromptObjects
|
|
|
80
85
|
choice = raw.dig("choices", 0)
|
|
81
86
|
message = choice&.dig("message")
|
|
82
87
|
|
|
83
|
-
return Response.new(content: "", raw: raw) unless message
|
|
88
|
+
return Response.new(content: "", raw: raw, usage: extract_usage(raw)) unless message
|
|
84
89
|
|
|
85
90
|
content = message["content"] || ""
|
|
86
91
|
tool_calls = parse_tool_calls(message["tool_calls"])
|
|
87
92
|
|
|
88
|
-
Response.new(content: content, tool_calls: tool_calls, raw: raw)
|
|
93
|
+
Response.new(content: content, tool_calls: tool_calls, raw: raw, usage: extract_usage(raw))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_usage(raw)
|
|
97
|
+
usage = raw["usage"]
|
|
98
|
+
return nil unless usage
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
input_tokens: usage["prompt_tokens"] || 0,
|
|
102
|
+
output_tokens: usage["completion_tokens"] || 0,
|
|
103
|
+
model: @model,
|
|
104
|
+
provider: @provider_name
|
|
105
|
+
}
|
|
89
106
|
end
|
|
90
107
|
|
|
91
108
|
def parse_tool_calls(raw_tool_calls)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptObjects
|
|
4
|
+
module LLM
|
|
5
|
+
# Static pricing table for cost estimation.
|
|
6
|
+
# Prices are per 1 million tokens in USD.
|
|
7
|
+
# Updated periodically — not guaranteed to be exact.
|
|
8
|
+
class Pricing
|
|
9
|
+
RATES = {
|
|
10
|
+
# OpenAI
|
|
11
|
+
"gpt-5.2" => { input: 2.00, output: 8.00 },
|
|
12
|
+
"gpt-4.1" => { input: 2.00, output: 8.00 },
|
|
13
|
+
"gpt-4.1-mini" => { input: 0.40, output: 1.60 },
|
|
14
|
+
"gpt-4.5-preview" => { input: 75.00, output: 150.00 },
|
|
15
|
+
"o3-mini" => { input: 1.10, output: 4.40 },
|
|
16
|
+
"o1" => { input: 15.00, output: 60.00 },
|
|
17
|
+
# Anthropic
|
|
18
|
+
"claude-opus-4" => { input: 15.00, output: 75.00 },
|
|
19
|
+
"claude-sonnet-4-5" => { input: 3.00, output: 15.00 },
|
|
20
|
+
"claude-haiku-4-5" => { input: 1.00, output: 5.00 },
|
|
21
|
+
# Gemini
|
|
22
|
+
"gemini-3-flash-preview" => { input: 0.15, output: 0.60 },
|
|
23
|
+
"gemini-2.5-pro" => { input: 1.25, output: 10.00 },
|
|
24
|
+
"gemini-2.5-flash" => { input: 0.15, output: 0.60 },
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Calculate cost in USD for a given usage.
|
|
28
|
+
# @param model [String] Model name
|
|
29
|
+
# @param input_tokens [Integer] Number of input tokens
|
|
30
|
+
# @param output_tokens [Integer] Number of output tokens
|
|
31
|
+
# @return [Float] Estimated cost in USD
|
|
32
|
+
def self.calculate(model:, input_tokens:, output_tokens:)
|
|
33
|
+
rates = RATES[model]
|
|
34
|
+
return 0.0 unless rates
|
|
35
|
+
|
|
36
|
+
input_cost = (input_tokens / 1_000_000.0) * rates[:input]
|
|
37
|
+
output_cost = (output_tokens / 1_000_000.0) * rates[:output]
|
|
38
|
+
input_cost + output_cost
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if we have pricing data for a model.
|
|
42
|
+
# @param model [String] Model name
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def self.known_model?(model)
|
|
45
|
+
RATES.key?(model)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -5,12 +5,13 @@ module PromptObjects
|
|
|
5
5
|
# Normalized response from an LLM API call.
|
|
6
6
|
# Wraps provider-specific responses into a common interface.
|
|
7
7
|
class Response
|
|
8
|
-
attr_reader :content, :tool_calls, :raw
|
|
8
|
+
attr_reader :content, :tool_calls, :raw, :usage
|
|
9
9
|
|
|
10
|
-
def initialize(content:, tool_calls: [], raw: nil)
|
|
10
|
+
def initialize(content:, tool_calls: [], raw: nil, usage: nil)
|
|
11
11
|
@content = content
|
|
12
12
|
@tool_calls = tool_calls
|
|
13
13
|
@raw = raw
|
|
14
|
+
@usage = usage # { input_tokens:, output_tokens:, model:, provider: }
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# Check if the response includes tool calls.
|