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 +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/ask/llm/openai_compatible.rb +57 -0
- data/lib/ask/llm/provider_config.rb +65 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +104 -88
- data/lib/ask/provider/bedrock.rb +84 -52
- data/lib/ask/provider/cloudflare.rb +60 -36
- data/lib/ask/provider/google.rb +110 -71
- data/lib/ask/provider/mistral.rb +156 -6
- data/lib/ask/provider/ollama.rb +82 -34
- data/lib/ask/provider/openai.rb +122 -71
- data/lib/ask/provider/openai_compatible.rb +93 -0
- data/lib/ask-llm-providers.rb +15 -11
- metadata +4 -6
- data/lib/ask/provider/deepseek.rb +0 -47
- data/lib/ask/provider/mimo.rb +0 -30
- data/lib/ask/provider/opencode.rb +0 -30
- data/lib/ask/provider/opencode_go.rb +0 -30
- data/lib/ask/provider/openrouter.rb +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83d9430295753c63779ef81db7fbbc4e9c56c3e1848f44e53a763c70c43cc1f8
|
|
4
|
+
data.tar.gz: a9ea83f80669da1e5869204a42af7bc52e071d81f355da80df26002246d3b828
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/llm/version.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
71
|
-
LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
|
|
72
|
-
end
|
|
68
|
+
# --- Config transformation contract ---
|
|
73
69
|
|
|
74
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
108
|
-
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
|
|
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
|
-
|
|
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|
|
|
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
|
data/lib/ask/provider/bedrock.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
52
|
+
# --- Config transformation contract ---
|
|
50
53
|
|
|
51
|
-
def
|
|
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|
|
|
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
|
-
|
|
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
|
|
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
|
|
152
|
+
def format_tools(tools)
|
|
114
153
|
tools.map do |t|
|
|
115
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|