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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +2 -2
  5. data/exe/prompt_objects +387 -1
  6. data/frontend/src/App.tsx +11 -3
  7. data/frontend/src/components/ContextMenu.tsx +67 -0
  8. data/frontend/src/components/MessageBus.tsx +4 -3
  9. data/frontend/src/components/ModelSelector.tsx +5 -1
  10. data/frontend/src/components/ThreadsSidebar.tsx +46 -2
  11. data/frontend/src/components/UsagePanel.tsx +105 -0
  12. data/frontend/src/hooks/useWebSocket.ts +53 -0
  13. data/frontend/src/store/index.ts +10 -0
  14. data/frontend/src/types/index.ts +4 -1
  15. data/lib/prompt_objects/cli.rb +1 -0
  16. data/lib/prompt_objects/connectors/mcp.rb +1 -0
  17. data/lib/prompt_objects/environment.rb +24 -1
  18. data/lib/prompt_objects/llm/anthropic_adapter.rb +15 -1
  19. data/lib/prompt_objects/llm/factory.rb +93 -6
  20. data/lib/prompt_objects/llm/gemini_adapter.rb +13 -1
  21. data/lib/prompt_objects/llm/openai_adapter.rb +21 -4
  22. data/lib/prompt_objects/llm/pricing.rb +49 -0
  23. data/lib/prompt_objects/llm/response.rb +3 -2
  24. data/lib/prompt_objects/mcp/server.rb +1 -0
  25. data/lib/prompt_objects/message_bus.rb +27 -8
  26. data/lib/prompt_objects/prompt_object.rb +5 -3
  27. data/lib/prompt_objects/server/api/routes.rb +186 -29
  28. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +1 -0
  29. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +77 -0
  30. data/lib/prompt_objects/server/public/index.html +2 -2
  31. data/lib/prompt_objects/server/websocket_handler.rb +93 -9
  32. data/lib/prompt_objects/server.rb +54 -0
  33. data/lib/prompt_objects/session/store.rb +399 -4
  34. data/lib/prompt_objects.rb +1 -0
  35. data/prompt_objects.gemspec +1 -1
  36. data/templates/arc-agi-1/manifest.yml +22 -0
  37. data/templates/arc-agi-1/objects/data_manager.md +42 -0
  38. data/templates/arc-agi-1/objects/observer.md +100 -0
  39. data/templates/arc-agi-1/objects/solver.md +118 -0
  40. data/templates/arc-agi-1/objects/verifier.md +79 -0
  41. data/templates/arc-agi-1/primitives/check_arc_data.rb +53 -0
  42. data/templates/arc-agi-1/primitives/find_objects.rb +72 -0
  43. data/templates/arc-agi-1/primitives/grid_diff.rb +70 -0
  44. data/templates/arc-agi-1/primitives/grid_info.rb +42 -0
  45. data/templates/arc-agi-1/primitives/grid_transform.rb +50 -0
  46. data/templates/arc-agi-1/primitives/load_arc_task.rb +68 -0
  47. data/templates/arc-agi-1/primitives/render_grid.rb +78 -0
  48. data/templates/arc-agi-1/primitives/test_solution.rb +131 -0
  49. metadata +20 -3
  50. data/lib/prompt_objects/server/public/assets/index-CeNJvqLG.js +0 -77
  51. 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
+ &times;
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
  }
@@ -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
@@ -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
 
@@ -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
@@ -127,6 +127,7 @@ module PromptObjects
127
127
  {
128
128
  from: entry[:from],
129
129
  to: entry[:to],
130
+ summary: entry[:summary],
130
131
  message: entry[:message],
131
132
  timestamp: entry[:timestamp]&.iso8601
132
133
  }
@@ -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 OpenAI, Anthropic, and Gemini.
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 (openai, anthropic, gemini)
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
- adapter_class.new(api_key: api_key, model: model)
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 configured.
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
- ENV.key?(config[:env_key])
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.dig(provider.to_s.downcase, :models) || []
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
- @client = OpenAI::Client.new(access_token: @api_key)
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.