fantasy-cli 1.2.6

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 (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. metadata +231 -0
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module Gsd
7
+ module AI
8
+ module Providers
9
+ # LM Studio Provider - Integração com LM Studio (LLMs locais)
10
+ #
11
+ # Suporta:
12
+ # - Qualquer modelo rodando no LM Studio
13
+ # - API compatível com OpenAI
14
+ # - Streaming de respostas
15
+ # - Modelos locais gratuitos
16
+ class LMStudio < Base
17
+ PROVIDER_NAME = 'LM Studio'
18
+ DEFAULT_MODEL = 'local-model'
19
+ DEFAULT_HOST = 'localhost'
20
+ DEFAULT_PORT = '1234'
21
+ API_URL = '/v1/chat/completions'
22
+ MODELS_URL = '/v1/models'
23
+
24
+ # Inicializa o provider LM Studio
25
+ #
26
+ # @param model [String] Modelo do LM Studio
27
+ # @param host [String] Host do LM Studio
28
+ # @param port [String] Porta do LM Studio
29
+ # @param debug [Boolean] Habilitar debug
30
+ def initialize(model: nil, host: nil, port: nil, debug: false)
31
+ super(model: model, debug: debug)
32
+ @host = host || DEFAULT_HOST
33
+ @port = port || DEFAULT_PORT
34
+ @base_url = "http://#{@host}:#{@port}"
35
+ @api_key = 'lm-studio' # LM Studio não requer API key real
36
+ end
37
+
38
+ # Chama a API do LM Studio
39
+ #
40
+ # @param system_prompt [String] System prompt
41
+ # @param messages [Array] Histórico de mensagens
42
+ # @param tools [Array] Tools disponíveis
43
+ # @param stream [Boolean] Habilitar streaming
44
+ # @yield [Hash] Chunk de streaming
45
+ # @return [OpenStruct] Resposta
46
+ def call(system_prompt:, messages:, tools:, stream: false, &block)
47
+ validate_lmstudio_available!
48
+
49
+ payload = build_payload(system_prompt, messages, stream)
50
+
51
+ log_debug("Calling LM Studio API with model: #{@model}")
52
+ log_debug("Messages count: #{messages.count}")
53
+
54
+ if stream
55
+ stream_request(payload, &block)
56
+ else
57
+ sync_request(payload)
58
+ end
59
+ rescue => e
60
+ log_debug("Error: #{e.message}")
61
+ raise LMStudioError, "LM Studio API error: #{e.message}"
62
+ end
63
+
64
+ # Verifica se está configurado (LM Studio sempre "configurado" pois é local)
65
+ #
66
+ # @return [Boolean] true
67
+ def configured?
68
+ true
69
+ end
70
+
71
+ # Lista modelos disponíveis
72
+ #
73
+ # @return [Array<String>] Lista de modelos
74
+ def list_models
75
+ uri = URI("#{@base_url}#{MODELS_URL}")
76
+ http = Net::HTTP.new(uri.host, uri.port)
77
+
78
+ request = Net::HTTP::Get.new(uri.path)
79
+ response = http.request(request)
80
+
81
+ if response.code == '200'
82
+ data = JSON.parse(response.body)
83
+ data['data']&.map { |m| m['id'] } || []
84
+ else
85
+ []
86
+ end
87
+ rescue => e
88
+ log_debug("Error listing models: #{e.message}")
89
+ []
90
+ end
91
+
92
+ # Verifica se um modelo está disponível
93
+ #
94
+ # @param model [String] Nome do modelo
95
+ # @return [Boolean] true se disponível
96
+ def model_available?(model)
97
+ list_models.include?(model)
98
+ end
99
+
100
+ # Verifica se LM Studio está rodando
101
+ #
102
+ # @return [Boolean] true se disponível
103
+ def available?
104
+ uri = URI("#{@base_url}#{MODELS_URL}")
105
+ http = Net::HTTP.new(uri.host, uri.port)
106
+ http.read_timeout = 5
107
+
108
+ request = Net::HTTP::Get.new(uri.path)
109
+ response = http.request(request)
110
+
111
+ response.code == '200'
112
+ rescue
113
+ false
114
+ end
115
+
116
+ private
117
+
118
+ # Valida se LM Studio está disponível
119
+ #
120
+ # @raise [RuntimeError] Se LM Studio não estiver disponível
121
+ def validate_lmstudio_available!
122
+ raise "LM Studio not available at #{@base_url}" unless available?
123
+ end
124
+
125
+ # Constrói o payload da requisição
126
+ #
127
+ # @param system_prompt [String] System prompt
128
+ # @param messages [Array] Mensagens
129
+ # @param stream [Boolean] Streaming
130
+ # @return [Hash] Payload
131
+ def build_payload(system_prompt, messages, stream)
132
+ formatted_messages = [{ role: 'system', content: system_prompt }] +
133
+ messages.map { |m| { role: m[:role], content: m[:content] } }
134
+
135
+ {
136
+ model: @model,
137
+ messages: formatted_messages,
138
+ stream: stream,
139
+ temperature: 0.7,
140
+ max_tokens: -1 # Ilimitado
141
+ }
142
+ end
143
+
144
+ # Faz requisição síncrona
145
+ #
146
+ # @param payload [Hash] Payload da requisição
147
+ # @return [OpenStruct] Resposta
148
+ def sync_request(payload)
149
+ uri = URI("#{@base_url}#{API_URL}")
150
+ http = Net::HTTP.new(uri.host, uri.port)
151
+ http.use_ssl = false
152
+
153
+ request = Net::HTTP::Post.new(uri.path)
154
+ request['Content-Type'] = 'application/json'
155
+ request.body = JSON.generate(payload)
156
+
157
+ log_debug("Sending request to #{uri}")
158
+
159
+ response = http.request(request)
160
+ log_debug("Response status: #{response.code}")
161
+
162
+ if response.code == '200'
163
+ parse_response(JSON.parse(response.body))
164
+ else
165
+ raise LMStudioError, "API error: #{response.code} - #{response.body}"
166
+ end
167
+ end
168
+
169
+ # Faz requisição com streaming
170
+ #
171
+ # @param payload [Hash] Payload da requisição
172
+ # @yield [Hash] Chunk de streaming
173
+ # @return [OpenStruct] Resposta
174
+ def stream_request(payload, &block)
175
+ uri = URI("#{@base_url}#{API_URL}")
176
+ http = Net::HTTP.new(uri.host, uri.port)
177
+ http.use_ssl = false
178
+
179
+ request = Net::HTTP::Post.new(uri.path)
180
+ request['Content-Type'] = 'application/json'
181
+ request.body = JSON.generate(payload)
182
+
183
+ content = +''
184
+
185
+ http.request(request) do |response|
186
+ if response.code == '200'
187
+ response.read_body do |chunk|
188
+ chunk.each_line do |line|
189
+ line = line.strip
190
+ next if line.empty?
191
+ next unless line.start_with?('data: ')
192
+
193
+ data = line[6..-1]
194
+ next if data == '[DONE]'
195
+
196
+ begin
197
+ event = JSON.parse(data)
198
+ yield(event) if block_given?
199
+
200
+ choice = event.dig(:choices, 0)
201
+ if choice
202
+ delta = choice[:delta]
203
+ content << delta[:content].to_s if delta && delta[:content]
204
+ end
205
+ rescue JSON::ParserError
206
+ # Ignora linhas inválidas
207
+ end
208
+ end
209
+ end
210
+ else
211
+ raise LMStudioError, "API error: #{response.code}"
212
+ end
213
+ end
214
+
215
+ OpenStruct.new(
216
+ content: content,
217
+ tool_calls: [],
218
+ usage: { input_tokens: 0, output_tokens: 0 },
219
+ raw: {}
220
+ )
221
+ end
222
+
223
+ # Parseia a resposta da API
224
+ #
225
+ # @param data [Hash] Dados da resposta
226
+ # @return [OpenStruct] Resposta parseada
227
+ def parse_response(data)
228
+ content = extract_text(data)
229
+ tool_calls = extract_tool_calls(data)
230
+ usage = extract_usage(data)
231
+
232
+ OpenStruct.new(
233
+ content: content,
234
+ tool_calls: tool_calls,
235
+ usage: usage,
236
+ raw: data
237
+ )
238
+ end
239
+
240
+ # Extrai texto da resposta
241
+ #
242
+ # @param data [Hash] Dados da resposta
243
+ # @return [String] Texto extraído
244
+ def extract_text(data)
245
+ choices = data['choices'] || []
246
+ return '' if choices.empty?
247
+
248
+ message = choices[0]['message'] || {}
249
+ message['content'] || ''
250
+ end
251
+
252
+ # Extrai tool calls da resposta
253
+ #
254
+ # @param data [Hash] Dados da resposta
255
+ # @return [Array] Tool calls
256
+ def extract_tool_calls(data)
257
+ # LM Studio não suporta tool calls nativamente
258
+ []
259
+ end
260
+
261
+ # Extrai usage da resposta
262
+ #
263
+ # @param data [Hash] Dados da resposta
264
+ # @return [Hash] Usage data
265
+ def extract_usage(data)
266
+ usage = data['usage'] || {}
267
+ {
268
+ input_tokens: usage['prompt_tokens'] || 0,
269
+ output_tokens: usage['completion_tokens'] || 0,
270
+ total_tokens: usage['total_tokens'] || 0
271
+ }
272
+ end
273
+ end
274
+
275
+ # Erro customizado para LM Studio
276
+ class LMStudioError < StandardError; end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module Gsd
7
+ module AI
8
+ module Providers
9
+ # Ollama Provider - Integração com Ollama (LLMs locais)
10
+ #
11
+ # Suporta:
12
+ # - Llama 2
13
+ # - Mistral
14
+ # - Codellama
15
+ # - Qualquer modelo rodando no Ollama
16
+ # - Streaming de respostas
17
+ # - Modelos multimodais (LLaVA)
18
+ # - Fallback automático
19
+ class Ollama < Base
20
+ PROVIDER_NAME = 'Ollama'
21
+ DEFAULT_MODEL = 'llama2'
22
+ DEFAULT_HOST = 'localhost'
23
+ DEFAULT_PORT = '11434'
24
+ API_URL = '/api/generate'
25
+ CHAT_URL = '/api/chat'
26
+ MODELS_URL = '/api/tags'
27
+
28
+ # Modelos recomendados
29
+ RECOMMENDED_MODELS = [
30
+ 'llama2',
31
+ 'llama2:70b',
32
+ 'mistral',
33
+ 'codellama',
34
+ 'codellama:34b',
35
+ 'phi',
36
+ 'gemma:7b',
37
+ 'llava' # Multimodal
38
+ ].freeze
39
+
40
+ # Inicializa o provider Ollama
41
+ #
42
+ # @param model [String] Modelo do Ollama
43
+ # @param host [String] Host do Ollama
44
+ # @param port [String] Porta do Ollama
45
+ # @param debug [Boolean] Habilitar debug
46
+ # @param auto_pull [Boolean] Auto pull de modelos ausentes
47
+ def initialize(model: nil, host: nil, port: nil, debug: false, auto_pull: false)
48
+ super(model: model, debug: debug)
49
+ @host = host || DEFAULT_HOST
50
+ @port = port || DEFAULT_PORT
51
+ @base_url = "http://#{@host}:#{@port}"
52
+ @auto_pull = auto_pull
53
+ @token_cache = {}
54
+ end
55
+
56
+ # Chama a API do Ollama
57
+ #
58
+ # @param system_prompt [String] System prompt
59
+ # @param messages [Array] Histórico de mensagens
60
+ # @param tools [Array] Tools disponíveis
61
+ # @param stream [Boolean] Habilitar streaming
62
+ # @yield [Hash] Chunk de streaming
63
+ # @return [OpenStruct] Resposta
64
+ def call(system_prompt:, messages:, tools:, stream: false, &block)
65
+ validate_ollama_available!
66
+
67
+ payload = build_payload(system_prompt, messages, stream)
68
+
69
+ log_debug("Calling Ollama API with model: #{@model}")
70
+ log_debug("Messages count: #{messages.count}")
71
+
72
+ if stream
73
+ stream_request(payload, &block)
74
+ else
75
+ sync_request(payload)
76
+ end
77
+ rescue => e
78
+ log_debug("Error: #{e.message}")
79
+ raise OllamaError, "Ollama API error: #{e.message}"
80
+ end
81
+
82
+ # Verifica se está configurado (Ollama sempre "configurado" pois é local)
83
+ #
84
+ # @return [Boolean] true
85
+ def configured?
86
+ true
87
+ end
88
+
89
+ # Lista modelos disponíveis
90
+ #
91
+ # @return [Array<String>] Lista de modelos
92
+ def list_models
93
+ uri = URI("#{@base_url}/api/tags")
94
+ http = Net::HTTP.new(uri.host, uri.port)
95
+
96
+ request = Net::HTTP::Get.new(uri.path)
97
+ response = http.request(request)
98
+
99
+ if response.code == '200'
100
+ data = JSON.parse(response.body)
101
+ data['models']&.map { |m| m['name'] } || []
102
+ else
103
+ []
104
+ end
105
+ rescue => e
106
+ log_debug("Error listing models: #{e.message}")
107
+ []
108
+ end
109
+
110
+ # Verifica se um modelo está disponível
111
+ #
112
+ # @param model [String] Nome do modelo
113
+ # @return [Boolean] true se disponível
114
+ def model_available?(model)
115
+ list_models.include?(model)
116
+ end
117
+
118
+ # Conta tokens de forma precisa
119
+ #
120
+ # @param text [String] Texto para contar
121
+ # @return [Integer] Número de tokens
122
+ def count_tokens(text)
123
+ return @token_cache[text] if @token_cache[text]
124
+
125
+ # Estimativa: ~4 caracteres por token
126
+ estimated = text.length / 4
127
+ @token_cache[text] = estimated
128
+ estimated
129
+ end
130
+
131
+ # Limpa o cache de tokens
132
+ #
133
+ # @return [void]
134
+ def clear_token_cache
135
+ @token_cache = {}
136
+ end
137
+
138
+ # Formata imagem para API do Ollama (LLaVA)
139
+ #
140
+ # @param image_path [String] Caminho da imagem ou base64
141
+ # @return [String] Base64 da imagem
142
+ def format_image(image_path)
143
+ if image_path.start_with?('http')
144
+ # Download e conversão
145
+ require 'open-uri'
146
+ image_data = URI.open(image_path).read
147
+ elsif File.exist?(image_path)
148
+ image_data = File.read(image_path, mode: 'rb')
149
+ else
150
+ # Assume que já é base64
151
+ return image_path
152
+ end
153
+
154
+ [image_data].pack('m0') # Base64
155
+ end
156
+
157
+ # Verifica se modelo é multimodal
158
+ #
159
+ # @param model [String] Nome do modelo
160
+ # @return [Boolean] true se é multimodal
161
+ def multimodal?(model)
162
+ model.downcase.include?('llava') ||
163
+ model.downcase.include?('bakllava') ||
164
+ model.downcase.include?('vision')
165
+ end
166
+
167
+ # Faz pull do modelo se não existir
168
+ #
169
+ # @param model [String] Nome do modelo
170
+ # @return [void]
171
+ def pull_model_if_needed(model)
172
+ return if model_available?(model)
173
+
174
+ if @auto_pull
175
+ log_debug("Pulling model #{model}...")
176
+ # Implementação do pull seria adicionada aqui
177
+ else
178
+ raise OllamaError, "Model #{model} not available. Enable auto_pull or run: ollama pull #{model}"
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ # Valida se Ollama está disponível
185
+ #
186
+ # @raise [RuntimeError] Se Ollama não estiver disponível
187
+ def validate_ollama_available!
188
+ uri = URI("#{@base_url}/api/tags")
189
+ http = Net::HTTP.new(uri.host, uri.port)
190
+ http.read_timeout = 5
191
+
192
+ request = Net::HTTP::Get.new(uri.path)
193
+ response = http.request(request)
194
+
195
+ raise "Ollama not available at #{@base_url}" unless response.code == '200'
196
+ rescue => e
197
+ raise "Ollama not available: #{e.message}"
198
+ end
199
+
200
+ # Constrói o payload da requisição
201
+ #
202
+ # @param system_prompt [String] System prompt
203
+ # @param messages [Array] Mensagens
204
+ # @param stream [Boolean] Streaming
205
+ # @return [Hash] Payload
206
+ def build_payload(system_prompt, messages, stream)
207
+ # Ollama usa formato diferente para chat
208
+ formatted_messages = messages.map do |msg|
209
+ {
210
+ role: msg[:role],
211
+ content: msg[:content]
212
+ }
213
+ end
214
+
215
+ # Adiciona system prompt como primeira mensagem se não existir
216
+ if formatted_messages.empty? || formatted_messages.first[:role] != 'system'
217
+ formatted_messages.unshift({ role: 'system', content: system_prompt })
218
+ end
219
+
220
+ {
221
+ model: @model,
222
+ messages: formatted_messages,
223
+ stream: stream,
224
+ options: {
225
+ temperature: 0.7,
226
+ top_p: 0.9
227
+ }
228
+ }
229
+ end
230
+
231
+ # Faz requisição síncrona
232
+ #
233
+ # @param payload [Hash] Payload da requisição
234
+ # @return [OpenStruct] Resposta
235
+ def sync_request(payload)
236
+ uri = URI("#{@base_url}#{CHAT_URL}")
237
+ http = Net::HTTP.new(uri.host, uri.port)
238
+ http.use_ssl = false
239
+
240
+ request = Net::HTTP::Post.new(uri.path)
241
+ request['Content-Type'] = 'application/json'
242
+ request.body = JSON.generate(payload)
243
+
244
+ log_debug("Sending request to #{uri}")
245
+
246
+ response = http.request(request)
247
+ log_debug("Response status: #{response.code}")
248
+
249
+ if response.code == '200'
250
+ parse_response(JSON.parse(response.body))
251
+ else
252
+ raise OllamaError, "API error: #{response.code} - #{response.body}"
253
+ end
254
+ end
255
+
256
+ # Faz requisição com streaming
257
+ #
258
+ # @param payload [Hash] Payload da requisição
259
+ # @yield [Hash] Chunk de streaming
260
+ # @return [OpenStruct] Resposta
261
+ def stream_request(payload, &block)
262
+ # Para streaming, precisamos ler a resposta chunk por chunk
263
+ # Cada linha é um JSON completo
264
+ uri = URI("#{@base_url}#{CHAT_URL}")
265
+ http = Net::HTTP.new(uri.host, uri.port)
266
+ http.use_ssl = false
267
+
268
+ request = Net::HTTP::Post.new(uri.path)
269
+ request['Content-Type'] = 'application/json'
270
+ request.body = JSON.generate(payload)
271
+
272
+ content = +''
273
+ total_duration = 0
274
+
275
+ http.request(request) do |response|
276
+ if response.code == '200'
277
+ response.read_body do |chunk|
278
+ chunk.each_line do |line|
279
+ begin
280
+ data = JSON.parse(line.strip)
281
+ if block_given?
282
+ yield(data)
283
+ end
284
+
285
+ if data['message']
286
+ content << data['message']['content'].to_s
287
+ end
288
+
289
+ total_duration += data['total_duration'].to_i
290
+ rescue JSON::ParserError
291
+ # Ignora linhas inválidas
292
+ end
293
+ end
294
+ end
295
+ else
296
+ raise OllamaError, "API error: #{response.code}"
297
+ end
298
+ end
299
+
300
+ OpenStruct.new(
301
+ content: content,
302
+ tool_calls: [],
303
+ usage: {
304
+ input_tokens: 0,
305
+ output_tokens: 0,
306
+ total_duration: total_duration
307
+ },
308
+ raw: {}
309
+ )
310
+ end
311
+
312
+ # Parseia a resposta da API
313
+ #
314
+ # @param data [Hash] Dados da resposta
315
+ # @return [OpenStruct] Resposta parseada
316
+ def parse_response(data)
317
+ content = data.dig('message', 'content') || ''
318
+
319
+ OpenStruct.new(
320
+ content: content,
321
+ tool_calls: [],
322
+ usage: {
323
+ input_tokens: 0,
324
+ output_tokens: 0,
325
+ total_duration: data['total_duration'].to_i
326
+ },
327
+ raw: data
328
+ )
329
+ end
330
+ end
331
+
332
+ # Erro customizado para Ollama
333
+ class OllamaError < StandardError; end
334
+ end
335
+ end
336
+ end