ask-llm-providers 0.3.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9356b3e21bb6e8e3c69f531eda38a4ae965ac5c19f20e655bd0ebe3c927f729
4
- data.tar.gz: 97d0c10a4937f3d8b6102392328d48b2f5bbf2ad73621fb8139759a892ff0df7
3
+ metadata.gz: 83d9430295753c63779ef81db7fbbc4e9c56c3e1848f44e53a763c70c43cc1f8
4
+ data.tar.gz: a9ea83f80669da1e5869204a42af7bc52e071d81f355da80df26002246d3b828
5
5
  SHA512:
6
- metadata.gz: 138d21391c23578668a9fecec6160a4403e8c01019a2c9795af0ab5f62e380958fe0f9735ef994b7f4a8b9115f060f8a83b724b58a558483465c14a81cb99b6c
7
- data.tar.gz: e8e0e7bc5bd760723b01d9e246b443631f87e0a29fc473f2ac673e3115fe772d5732d2ae90f8e4903296e1d5b70c36ccc17de9d4900d4d66c658f5e5523b4add
6
+ metadata.gz: 870a8543f6810948e62f65b18b39f0924a78ec393108f74e992ecff25cfe8e14ae8331d287d436afdb88f3792379c778598b3188642c4c5a002bb374f56a0c8e
7
+ data.tar.gz: 211593c5668424aab22e2f03c6230d6ad791a4f7f6002b60b8e26e879f2ae8d6e28782b29790611c796c8fa2d0edab1494e1a9d61ec638a4b78fde1fbba639a2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## [0.5.0] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **OpenAI-compatible provider registry** (`Ask::LLM::OPENAI_COMPATIBLE`) — 12 providers defined as data, not classes. Adding a new OpenAI-compatible API (Groq, Together, Fireworks, etc.) is one line in the registry. No new file, no subclass.
6
+ - **`Ask::Providers::OpenAICompatible` class** — single class handling all registered providers by reading from the registry. Each provider gets an anonymous subclass with its slug, api_base, env var mapping, and quirks (reasoning_content, extra_headers) set from config.
7
+ - **Unified test** — `OpenAICompatibleTest` dynamically tests every registered provider: identity, slug, capabilities, api_base, env var resolution, request building, response parsing, streaming, and tool formatting.
8
+
9
+ ### Removed
10
+
11
+ - **5 subclass files** — `deepseek.rb`, `openrouter.rb`, `opencode.rb`, `opencode_go.rb`, `mimo.rb` deleted. Replaced by registry entries.
12
+ - **DeepSeek-specific test file** — covered by the unified test.
13
+
14
+ ## [0.4.0] — 2026-07-16
15
+
16
+ ### Added
17
+
18
+ - **`Ask::LLM::ProviderConfig` transformation contract** — Shared module that every provider includes, defining the wire-format interface: `build_request`, `parse_response`, `parse_stream`, `format_tools`, `format_message`. Adding a new provider is now mechanical — implement five methods and the provider works. (Inspired by LiteLLM's `BaseConfig` pattern.)
19
+ - **`BaseProviderTests` shared test module** — Every provider test includes this module, which enforces 22 contract tests (interface methods, slug, capabilities, config, request building, error mapping) inherited from LiteLLM's `BaseLLMChatTest` approach. Adding a new provider gives you 22 tests for free.
20
+ - **Comprehensive per-provider tests** — Each provider now has dedicated tests for `build_request`, `parse_response`, `parse_stream`, `format_message`, `format_tools`, `parse_error`, and streaming — covering happy paths, edge cases, and error conditions. Total test count: 341 (up from ~33 in v0.1.0).
21
+
22
+ ### Changed
23
+
24
+ - **Provider refactoring** — OpenAI, Anthropic, Google, Bedrock, Ollama, Cloudflare, and Mistral providers now include `Ask::LLM::ProviderConfig` and implement its transformation contract. The `chat` method in each is a clean orchestrator: build request → HTTP → parse response. Internal methods (`build_chat_payload`, `process_chunk`, etc.) are renamed to the contract standard.
25
+ - **Subclass compatibility** — DeepSeek, OpenRouter, OpenCode, OpenCodeGo, and Mimo (all OpenAI subclasses) inherit the transformation contract unchanged. Their `format_messages` overrides continue to work through `build_request`.
26
+
27
+ ### Removed
28
+
29
+ - **Dead files** — Removed `lib/ask/provider/config.rb` (moved to `lib/ask/llm/provider_config.rb` to avoid namespace collision with `Ask::Provider` class).
30
+
1
31
  ## [0.3.1] — 2026-07-14
2
32
 
3
33
  ### Removed
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module LLM
5
+ # Registry of OpenAI-compatible providers (data, not classes).
6
+ #
7
+ # Each entry is configuration for {Ask::Providers::OpenAICompatible}.
8
+ # To add a new provider, add one line here — no new file, no subclass.
9
+ #
10
+ # @example Adding Groq
11
+ # groq: { api_base: "https://api.groq.com/openai/v1", api_key_env: "GROQ_API_KEY" }
12
+ #
13
+ OPENAI_COMPATIBLE = {
14
+ deepseek: { api_base: "https://api.deepseek.com", api_key_env: "DEEPSEEK_API_KEY",
15
+ reasoning_content: true,
16
+ capabilities: { chat: true, streaming: true, tool_calls: true, thinking: true } },
17
+
18
+ openrouter: { api_base: "https://openrouter.ai/api/v1", api_key_env: "OPENROUTER_API_KEY",
19
+ extra_headers: { "HTTP-Referer" => "https://github.com/ask-rb",
20
+ "X-Title" => "ask-rb" },
21
+ capabilities: { chat: true, streaming: true, tool_calls: true, vision: true,
22
+ thinking: true, structured_output: true } },
23
+
24
+ opencode: { api_base: "https://opencode.ai/zen/v1", api_key_env: "OPENCODE_API_KEY",
25
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
26
+
27
+ opencode_go: { api_base: "https://opencode.ai/zen/go/v1", api_key_env: "OPENCODE_GO_API_KEY",
28
+ alternate_env: "OPENCODE_API_KEY",
29
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
30
+
31
+ mimo: { api_base: "https://token-plan-sgp.xiaomimimo.com/v1", api_key_env: "MIMO_API_KEY",
32
+ capabilities: { chat: true, streaming: true } },
33
+
34
+ groq: { api_base: "https://api.groq.com/openai/v1", api_key_env: "GROQ_API_KEY",
35
+ capabilities: { chat: true, streaming: true, tool_calls: true, vision: true } },
36
+
37
+ together: { api_base: "https://api.together.xyz/v1", api_key_env: "TOGETHER_API_KEY",
38
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
39
+
40
+ fireworks: { api_base: "https://api.fireworks.ai/inference/v1", api_key_env: "FIREWORKS_API_KEY",
41
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
42
+
43
+ perplexity: { api_base: "https://api.perplexity.ai", api_key_env: "PERPLEXITY_API_KEY",
44
+ capabilities: { chat: true, streaming: true } },
45
+
46
+ cerebras: { api_base: "https://api.cerebras.ai/v1", api_key_env: "CEREBRAS_API_KEY",
47
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
48
+
49
+ xai: { api_base: "https://api.x.ai/v1", api_key_env: "XAI_API_KEY",
50
+ capabilities: { chat: true, streaming: true, tool_calls: true, vision: true,
51
+ thinking: true } },
52
+
53
+ moonshot: { api_base: "https://api.moonshot.ai/v1", api_key_env: "MOONSHOT_API_KEY",
54
+ capabilities: { chat: true, streaming: true } }
55
+ }.freeze
56
+ end
57
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module LLM
5
+ # Shared contract for provider request/response transformation.
6
+ #
7
+ # Every provider includes this module and implements the core methods
8
+ # that define its wire format. The provider's {#chat} method orchestrates
9
+ # between building requests, making HTTP calls, and parsing responses.
10
+ #
11
+ # This separation makes each wire-format concern testable in isolation
12
+ # and adding a new provider mechanical — you implement four methods and
13
+ # the provider works.
14
+ module ProviderConfig
15
+ # Build a provider-native request payload from internal message format.
16
+ #
17
+ # @param messages [Array<Hash>] normalized messages with :role, :content,
18
+ # :tool_calls, :tool_call_id
19
+ # @param model [String] model ID
20
+ # @param tools [Array<Hash>, nil] tool definitions
21
+ # @param temperature [Float, nil] sampling temperature
22
+ # @param stream [Boolean] whether streaming will be used
23
+ # @param schema [Hash, nil] JSON schema for structured output
24
+ # @return [Hash] provider-native request body
25
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
26
+ raise NotImplementedError, "#{self.class} must implement #build_request"
27
+ end
28
+
29
+ # Parse a non-streaming response into an Ask::Message.
30
+ #
31
+ # @param body [Hash] parsed response body
32
+ # @param model [String] model ID
33
+ # @return [Ask::Message]
34
+ def parse_response(body, model)
35
+ raise NotImplementedError, "#{self.class} must implement #parse_response"
36
+ end
37
+
38
+ # Parse raw stream data and yield Ask::Chunks.
39
+ #
40
+ # @param raw [String] raw data from the stream callback
41
+ # @param stream [Ask::Stream] the accumulating stream
42
+ # @param model [String] model ID
43
+ # @yield [Ask::Chunk] optional per-chunk callback
44
+ def parse_stream(raw, stream, model, &block)
45
+ raise NotImplementedError, "#{self.class} must implement #parse_stream"
46
+ end
47
+
48
+ # Format tool definitions for this provider's wire format.
49
+ #
50
+ # @param tools [Array] tool definitions (Ask::Tool instances or Hashes)
51
+ # @return [Array] provider-native tool format
52
+ def format_tools(tools)
53
+ tools
54
+ end
55
+
56
+ # Format a single message for this provider's wire format.
57
+ #
58
+ # @param msg [Hash] message with :role, :content, :tool_calls, :tool_call_id
59
+ # @return [Hash] provider-native message format
60
+ def format_message(msg)
61
+ msg
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.3.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -5,6 +5,8 @@ module Ask
5
5
  # Anthropic Claude API provider.
6
6
  class Anthropic < Ask::Provider
7
7
  include Ask::LLM::SSEBuffer
8
+ include Ask::LLM::ProviderConfig
9
+
8
10
  def initialize(config = {})
9
11
  config = normalize_config(config)
10
12
  super(config)
@@ -25,7 +27,7 @@ module Ask
25
27
 
26
28
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
27
29
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
28
- payload = build_chat_payload(msgs, model, tools, temperature, stream, schema, **params)
30
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
29
31
  if stream
30
32
  chat_stream(payload, model, &block)
31
33
  else
@@ -40,6 +42,7 @@ module Ask
40
42
  def list_models
41
43
  response = @http.get("v1/models")
42
44
  return [] unless response.success?
45
+
43
46
  response.body["data"].map { |m| Ask::ModelInfo.new(id: m["id"], provider: slug) }
44
47
  end
45
48
 
@@ -49,63 +52,108 @@ module Ask
49
52
  end
50
53
 
51
54
  class << self
55
+ def slug; "anthropic"; end
56
+
52
57
  def capabilities
53
- { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, prompt_caching: true, structured_output: true }
58
+ {
59
+ chat: true, streaming: true, tool_calls: true, vision: true,
60
+ thinking: true, prompt_caching: true, structured_output: true
61
+ }
54
62
  end
63
+
55
64
  def configuration_options; %i[api_key api_base]; end
56
65
  def configuration_requirements; %i[api_key]; end
57
- def slug; "anthropic"; end
58
- end
59
-
60
- private
61
-
62
- def normalize_config(config)
63
- return config unless config.is_a?(Hash)
64
- Ask::LLM::Config.new(
65
- api_key: config[:api_key] || config["api_key"] || config[:anthropic_api_key],
66
- api_base: config[:api_base] || config["api_base"]
67
- )
68
66
  end
69
67
 
70
- def build_http
71
- LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
72
- end
68
+ # --- Config transformation contract ---
73
69
 
74
- def build_chat_payload(messages, model, tools, temperature, stream, schema, **params)
70
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
75
71
  system_msgs, chat_msgs = messages.partition { |m| (m[:role] || m["role"]).to_s == "system" }
76
72
  system_content = format_system_content(system_msgs)
77
- tools_array = format_tools(tools) if tools&.any?
78
73
 
79
74
  payload = {
80
- model: model,
75
+ model:,
81
76
  messages: chat_msgs.map { |m| format_message(m) },
82
77
  max_tokens: params.delete(:max_tokens) || 4096,
83
78
  stream: stream || false
84
79
  }
85
80
 
86
81
  payload[:system] = system_content if system_content
87
- payload[:tools] = tools_array if tools_array
82
+ tool_defs = format_tools(tools) if tools&.any?
83
+ payload[:tools] = tool_defs if tool_defs
88
84
  payload[:temperature] = temperature if temperature
89
85
  payload.merge(params)
90
86
  end
91
87
 
92
- def format_system_content(messages)
93
- return nil if messages.empty?
94
- texts = messages.map { |m| m[:content] || m["content"] }.compact
95
- return nil if texts.empty?
96
- texts.join("\n")
88
+ def parse_response(body, model)
89
+ content_blocks = body["content"] || []
90
+ text_content = content_blocks.select { |c| c["type"] == "text" }.map { |c| c["text"] }.join
91
+ tool_blocks = content_blocks.select { |c| c["type"] == "tool_use" }
92
+ thinking_blocks = content_blocks.select { |c| %w[thinking redacted_thinking].include?(c["type"]) }
93
+ usage = body["usage"] || {}
94
+
95
+ tool_calls = tool_blocks.map do |tb|
96
+ { id: tb["id"], type: "function", name: tb["name"], arguments: JSON.generate(tb["input"]) }
97
+ end
98
+
99
+ metadata = {
100
+ model: body["model"] || model,
101
+ stop_reason: body["stop_reason"],
102
+ stop_sequence: body["stop_sequence"],
103
+ input_tokens: usage["input_tokens"],
104
+ output_tokens: usage["output_tokens"],
105
+ thinking: thinking_blocks.map { |b| b["thinking"] || b["text"] }.compact.join("\n"),
106
+ raw: body
107
+ }.compact
108
+
109
+ text = text_content.empty? ? nil : text_content
110
+ Ask::Message.new(role: :assistant, content: text, tool_calls: tool_calls.empty? ? nil : tool_calls, metadata:)
111
+ end
112
+
113
+ def parse_stream(raw, stream, model, &block)
114
+ each_sse_event(raw) do |data|
115
+ parsed = JSON.parse(data) rescue next
116
+
117
+ case parsed["type"]
118
+ when "content_block_delta"
119
+ delta = parsed.dig("delta")
120
+ next unless delta
121
+
122
+ chunk = Ask::Chunk.new(content: delta["text"])
123
+ stream.add(chunk)
124
+ yield chunk if block_given?
125
+ when "message_stop"
126
+ usage = parsed["usage"] || parsed["message"]&.dig("usage")
127
+ if usage
128
+ chunk = Ask::Chunk.new(finish_reason: "stop", usage:)
129
+ stream.add(chunk)
130
+ yield chunk if block_given?
131
+ end
132
+ when "message_start"
133
+ # Handled by content_block_start instead
134
+ end
135
+ end
136
+ end
137
+
138
+ def format_tools(tools)
139
+ tools.map do |t|
140
+ {
141
+ name: t.respond_to?(:name) ? t.name : t[:name],
142
+ description: t.respond_to?(:description) ? t.description : t[:description],
143
+ input_schema: t.respond_to?(:parameters) ? t.parameters : (t[:parameters] || { type: "object", properties: {} })
144
+ }
145
+ end
97
146
  end
98
147
 
99
148
  def format_message(msg)
100
149
  role = (msg[:role] || msg["role"]).to_s
101
150
  content = msg[:content] || msg["content"]
102
151
 
103
- # Handle tool calls
104
152
  if msg[:tool_calls] || msg["tool_calls"]
105
153
  tc = msg[:tool_calls] || msg["tool_calls"]
106
154
  return {
107
- role: role,
108
- content: content,
155
+ role:,
156
+ content:,
109
157
  tool_calls: tc.map { |t|
110
158
  {
111
159
  type: "tool_use",
@@ -117,7 +165,6 @@ module Ask
117
165
  }.compact
118
166
  end
119
167
 
120
- # Handle tool results
121
168
  if msg[:tool_call_id] || msg["tool_call_id"]
122
169
  return {
123
170
  role: "user",
@@ -129,7 +176,31 @@ module Ask
129
176
  }
130
177
  end
131
178
 
132
- { role: role, content: content }.compact
179
+ { role:, content: }.compact
180
+ end
181
+
182
+ private
183
+
184
+ def normalize_config(config)
185
+ return config unless config.is_a?(Hash)
186
+
187
+ Ask::LLM::Config.new(
188
+ api_key: config[:api_key] || config["api_key"] || config[:anthropic_api_key],
189
+ api_base: config[:api_base] || config["api_base"]
190
+ )
191
+ end
192
+
193
+ def build_http
194
+ LLM::HTTP.connection(api_base, headers:, request: { open_timeout: 30, timeout: 120 })
195
+ end
196
+
197
+ def format_system_content(messages)
198
+ return nil if messages.empty?
199
+
200
+ texts = messages.map { |m| m[:content] || m["content"] }.compact
201
+ return nil if texts.empty?
202
+
203
+ texts.join("\n")
133
204
  end
134
205
 
135
206
  def parse_json(str)
@@ -138,44 +209,11 @@ module Ask
138
209
  {}
139
210
  end
140
211
 
141
- def format_tools(tools)
142
- tools.map do |t|
143
- {
144
- name: t.respond_to?(:name) ? t.name : t[:name],
145
- description: t.respond_to?(:description) ? t.description : t[:description],
146
- input_schema: t.respond_to?(:parameters) ? t.parameters : (t[:parameters] || { type: "object", properties: {} })
147
- }
148
- end
149
- end
150
-
151
212
  def chat_nonstream(payload, model)
152
213
  response = @http.post("v1/messages") { |r| r.body = payload }
153
214
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Anthropic") unless response.success?
154
- parse_response(response.body, model)
155
- end
156
-
157
- def parse_response(body, model)
158
- content_blocks = body["content"] || []
159
- text_content = content_blocks.select { |c| c["type"] == "text" }.map { |c| c["text"] }.join
160
- tool_blocks = content_blocks.select { |c| c["type"] == "tool_use" }
161
- thinking_blocks = content_blocks.select { |c| %w[thinking redacted_thinking].include?(c["type"]) }
162
- usage = body["usage"] || {}
163
-
164
- tool_calls = tool_blocks.map do |tb|
165
- { id: tb["id"], type: "function", name: tb["name"], arguments: JSON.generate(tb["input"]) }
166
- end
167
-
168
- metadata = {
169
- model: body["model"] || model,
170
- stop_reason: body["stop_reason"],
171
- stop_sequence: body["stop_sequence"],
172
- input_tokens: usage["input_tokens"],
173
- output_tokens: usage["output_tokens"],
174
- thinking: thinking_blocks.map { |b| b["thinking"] || b["text"] }.compact.join("\n"),
175
- raw: body
176
- }.compact
177
215
 
178
- Ask::Message.new(role: :assistant, content: text_content.empty? ? nil : text_content, tool_calls: tool_calls.empty? ? nil : tool_calls, metadata: metadata)
216
+ parse_response(response.body, model)
179
217
  end
180
218
 
181
219
  def chat_stream(payload, model, &block)
@@ -183,35 +221,13 @@ module Ask
183
221
  init_sse_buffer
184
222
  response = @http.post("v1/messages") do |req|
185
223
  req.body = payload.merge(stream: true)
186
- req.options.on_data = proc { |data, _bytes, _env| process_anthropic_chunk(data, stream, model, &block) }
224
+ req.options.on_data = proc { |data, _bytes, _env| parse_stream(data, stream, model, &block) }
187
225
  end
188
226
  raise LLM::HTTP.map_error(response.status, JSON.parse(response.body), provider: "Anthropic") unless response.success?
227
+
189
228
  stream.finish!
190
229
  stream
191
230
  end
192
-
193
- def process_anthropic_chunk(raw, stream, model)
194
- each_sse_event(raw) do |data|
195
- parsed = JSON.parse(data) rescue next
196
-
197
- case parsed["type"]
198
- when "content_block_delta"
199
- delta = parsed.dig("delta")
200
- next unless delta
201
- chunk = Ask::Chunk.new(content: delta["text"])
202
- stream.add(chunk)
203
- yield chunk if block_given?
204
- when "message_stop"
205
- usage = parsed["usage"] || parsed["message"]&.dig("usage")
206
- if usage
207
- chunk = Ask::Chunk.new(finish_reason: "stop", usage: usage)
208
- stream.add(chunk)
209
- yield chunk if block_given?
210
- end
211
- when "message_start"
212
- end
213
- end
214
- end
215
231
  end
216
232
  end
217
233
  end
@@ -5,6 +5,8 @@ module Ask
5
5
  # Amazon Bedrock provider using the Converse API.
6
6
  # Uses the AWS SDK for authentication (credentials chain: env, ~/.aws, instance profile).
7
7
  class Bedrock < Ask::Provider
8
+ include Ask::LLM::ProviderConfig
9
+
8
10
  def initialize(config = {})
9
11
  config = normalize_config(config)
10
12
  super(config)
@@ -16,7 +18,7 @@ module Ask
16
18
 
17
19
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
18
20
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
19
- payload = build_converse_payload(msgs, model, tools, temperature, schema, **params)
21
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
20
22
  if stream
21
23
  chat_stream(payload, model, &block)
22
24
  else
@@ -29,7 +31,6 @@ module Ask
29
31
  end
30
32
 
31
33
  def list_models
32
- # Bedrock doesn't have a list models endpoint — rely on model catalog
33
34
  []
34
35
  end
35
36
 
@@ -38,46 +39,84 @@ module Ask
38
39
  end
39
40
 
40
41
  class << self
42
+ def slug; "bedrock"; end
43
+
41
44
  def capabilities
42
45
  { chat: true, streaming: true, tool_calls: true, vision: true }
43
46
  end
47
+
44
48
  def configuration_options; %i[region access_key_id secret_access_key session_token]; end
45
49
  def configuration_requirements; %i[]; end
46
- def slug; "bedrock"; end
47
50
  end
48
51
 
49
- private
52
+ # --- Config transformation contract ---
50
53
 
51
- def normalize_config(config)
52
- return config unless config.is_a?(Hash)
53
- Ask::LLM::Config.new(
54
- region: config[:region] || config["region"] || ENV["AWS_REGION"] || "us-east-1",
55
- access_key_id: config[:access_key_id] || config["access_key_id"] || ENV["AWS_ACCESS_KEY_ID"],
56
- secret_access_key: config[:secret_access_key] || config["secret_access_key"] || ENV["AWS_SECRET_ACCESS_KEY"],
57
- session_token: config[:session_token] || config["session_token"] || ENV["AWS_SESSION_TOKEN"]
58
- )
59
- end
60
-
61
- def build_converse_payload(messages, model, tools, temperature, schema, **params)
54
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
62
55
  system_msgs, chat_msgs = messages.partition { |m| (m[:role] || m["role"]).to_s == "system" }
63
56
  payload = {
64
57
  modelId: model,
65
- messages: chat_msgs.map { |m| format_bedrock_msg(m) },
58
+ messages: chat_msgs.map { |m| format_message(m) },
66
59
  inferenceConfig: { temperature: temperature || 1.0 }.compact
67
60
  }
68
61
 
69
62
  sys = system_msgs.map { |m| m[:content] || m["content"] }.compact
70
63
  payload[:system] = sys.map { |s| { text: s } } if sys.any?
71
- if tools&.any?
72
- payload[:toolConfig] = { tools: format_bedrock_tools(tools) }
73
- end
64
+ tool_defs = format_tools(tools) if tools&.any?
65
+ payload[:toolConfig] = { tools: tool_defs } if tool_defs
74
66
  if schema
75
67
  payload[:inferenceConfig][:response_type] = "json_object"
76
68
  end
77
69
  payload.merge(params)
78
70
  end
79
71
 
80
- def format_bedrock_msg(msg)
72
+ def parse_response(body, model)
73
+ output = body.output
74
+ return Ask::Message.new(role: :assistant, content: nil) unless output
75
+
76
+ msg = output.message
77
+ text = msg.content&.map { |c| c.text }&.compact&.join
78
+ tool_uses = msg.content&.select { |c| c.tool_use } || []
79
+ tool_calls = tool_uses.map do |tu|
80
+ { id: tu.tool_use.tool_use_id, type: "function", name: tu.tool_use.name, arguments: JSON.generate(tu.tool_use.input.to_h) }
81
+ end
82
+
83
+ usage = body.usage || {}
84
+ Ask::Message.new(
85
+ role: :assistant,
86
+ content: text,
87
+ tool_calls: tool_calls.empty? ? nil : tool_calls,
88
+ metadata: {
89
+ model:,
90
+ stop_reason: body.stop_reason,
91
+ input_tokens: usage.input_tokens,
92
+ output_tokens: usage.output_tokens,
93
+ raw: body.to_h
94
+ }
95
+ )
96
+ end
97
+
98
+ def parse_stream(raw, stream, model, &block)
99
+ event = raw
100
+ if event.content_block_delta
101
+ delta = event.content_block_delta.delta
102
+ if delta.respond_to?(:text)
103
+ chunk = Ask::Chunk.new(content: delta.text)
104
+ stream.add(chunk)
105
+ yield chunk if block_given?
106
+ end
107
+ end
108
+ if event.message_stop
109
+ usage = event.message_stop.usage || {}
110
+ chunk = Ask::Chunk.new(
111
+ finish_reason: "stop",
112
+ usage: { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens }
113
+ )
114
+ stream.add(chunk)
115
+ yield chunk if block_given?
116
+ end
117
+ end
118
+
119
+ def format_message(msg)
81
120
  role = (msg[:role] || msg["role"]).to_s
82
121
  content = msg[:content] || msg["content"]
83
122
  bedrock_role = role == "assistant" ? "assistant" : "user"
@@ -110,12 +149,33 @@ module Ask
110
149
  { role: bedrock_role, content: parts }
111
150
  end
112
151
 
113
- def format_bedrock_tools(tools)
152
+ def format_tools(tools)
114
153
  tools.map do |t|
115
- { toolSpec: { name: t.respond_to?(:name) ? t.name : t[:name], description: t.respond_to?(:description) ? t.description : t[:description], inputSchema: { json: t.respond_to?(:parameters) ? t.parameters : (t[:parameters] || { type: "object", properties: {} }) } } }
154
+ {
155
+ toolSpec: {
156
+ name: t.respond_to?(:name) ? t.name : t[:name],
157
+ description: t.respond_to?(:description) ? t.description : t[:description],
158
+ inputSchema: {
159
+ json: t.respond_to?(:parameters) ? t.parameters : (t[:parameters] || { type: "object", properties: {} })
160
+ }
161
+ }
162
+ }
116
163
  end
117
164
  end
118
165
 
166
+ private
167
+
168
+ def normalize_config(config)
169
+ return config unless config.is_a?(Hash)
170
+
171
+ Ask::LLM::Config.new(
172
+ region: config[:region] || config["region"] || ENV["AWS_REGION"] || "us-east-1",
173
+ access_key_id: config[:access_key_id] || config["access_key_id"] || ENV["AWS_ACCESS_KEY_ID"],
174
+ secret_access_key: config[:secret_access_key] || config["secret_access_key"] || ENV["AWS_SECRET_ACCESS_KEY"],
175
+ session_token: config[:session_token] || config["session_token"] || ENV["AWS_SESSION_TOKEN"]
176
+ )
177
+ end
178
+
119
179
  def parse_json(str)
120
180
  JSON.parse(str)
121
181
  rescue JSON::ParserError
@@ -130,45 +190,17 @@ module Ask
130
190
  def chat_nonstream(payload, model)
131
191
  client = bedrock_client
132
192
  resp = client.converse(payload)
133
- parse_bedrock_response(resp, model)
193
+ parse_response(resp, model)
134
194
  rescue Aws::Errors::ServiceError => e
135
195
  raise LLM::HTTP.map_error(e.status_code&.to_i || 500, { message: e.message }, provider: "Bedrock")
136
196
  end
137
197
 
138
- def parse_bedrock_response(resp, model)
139
- output = resp.output
140
- return Ask::Message.new(role: :assistant, content: nil) unless output
141
-
142
- msg = output.message
143
- text = msg.content&.map { |c| c.text }&.compact&.join
144
- tool_uses = msg.content&.select { |c| c.tool_use } || []
145
- tool_calls = tool_uses.map do |tu|
146
- { id: tu.tool_use.tool_use_id, type: "function", name: tu.tool_use.name, arguments: JSON.generate(tu.tool_use.input.to_h) }
147
- end
148
-
149
- usage = resp.usage || {}
150
- Ask::Message.new(role: :assistant, content: text, tool_calls: tool_calls.empty? ? nil : tool_calls, metadata: { model: model, stop_reason: resp.stop_reason, input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, raw: resp.to_h })
151
- end
152
-
153
198
  def chat_stream(payload, model, &block)
154
199
  client = bedrock_client
155
200
  stream = Ask::Stream.new
156
201
  resp = client.converse_stream(payload)
157
202
  resp.stream.each do |event|
158
- if event.content_block_delta
159
- delta = event.content_block_delta.delta
160
- chunk = Ask::Chunk.new(content: delta.text) if delta.respond_to?(:text)
161
- if chunk
162
- stream.add(chunk)
163
- yield chunk if block_given?
164
- end
165
- end
166
- if event.message_stop
167
- usage = event.message_stop.usage || {}
168
- chunk = Ask::Chunk.new(finish_reason: "stop", usage: { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens })
169
- stream.add(chunk)
170
- yield chunk if block_given?
171
- end
203
+ parse_stream(event, stream, model, &block)
172
204
  end
173
205
  stream.finish!
174
206
  stream