Custom Providers
Build an LLM provider for any API. Providers are the bridge between the ask-rb ecosystem and LLM APIs — every provider implements the same interface.
The Provider Contract
Subclass Ask::Provider and implement these methods:
| Method | Required | Returns |
|---|---|---|
chat(messages, model:, **params, &block) | Yes | Ask::Message or yields Ask::Chunk |
embed(text, model:) | No | Array of floats |
list_models | No | Array of Ask::ModelInfo |
api_base | Yes | String (the API base URL) |
headers | Yes | Hash of HTTP headers |
Example: Custom API Provider
class MyCustomProvider < Ask::Provider
def api_base = "https://api.myservice.com/v1"
def headers
{ "Authorization" => "Bearer #{@config.api_key}" }
end
def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
body = {
model: model,
messages: messages,
temperature: temperature,
stream: stream
}
body[:tools] = tools if tools
body[:response_format] = { type: "json_object" } if schema
response = connection.post("/chat/completions") do |req|
req.body = body
req.options.on_data = block if stream
end
if stream
# Streaming handles chunks in the on_data callback
return
end
Ask::Message.new(
role: :assistant,
content: response.body["choices"][0]["message"]["content"]
)
end
def embed(text, model:)
response = connection.post("/embeddings") do |req|
req.body = { model: model, input: text }
end
response.body["data"][0]["embedding"]
end
def list_models
response = connection.get("/models")
response.body["data"].map do |m|
Ask::ModelInfo.new(id: m["id"], provider: "my_custom")
end
end
end
Registration
Ask::Provider.register(:my_custom, MyCustomProvider)
# Now resolvable
Ask::Provider.resolve(:my_custom) # => MyCustomProvider
Capabilities Declaration
Tell the ecosystem what your provider supports:
class MyCustomProvider < Ask::Provider
def self.capabilities
{
chat: true,
streaming: true,
tool_calls: true,
vision: false,
embeddings: true,
image_generation: false,
transcription: false
}
end
end
Streaming Support
def chat(messages, model:, stream: nil, **params, &block)
if stream
connection.post("/chat/completions") do |req|
req.body = { model: model, messages: messages, stream: true }
req.options.on_data = ->(chunk) do
chunk_data = JSON.parse(chunk)
if chunk_data["choices"]&.first&.dig("delta", "content")
yield Ask::Chunk.new(content: chunk_data["choices"].first["delta"]["content"])
end
end
end
else
# Non-streaming path
end
end
Tool Calling
In your chat method, handle tool calls from the API response:
def chat(messages, model:, tools: nil, **params)
body = { model: model, messages: messages }
body[:tools] = tools.map { |t| { type: "function", function: t } } if tools
response = connection.post("/chat/completions", body)
response_body = response.body
choice = response_body["choices"][0]["message"]
if choice["tool_calls"]
# Return a message with tool calls
Ask::Message.new(
role: :assistant,
tool_calls: choice["tool_calls"].map do |tc|
{ id: tc["id"], name: tc["function"]["name"], arguments: tc["function"]["arguments"] }
end
)
else
Ask::Message.new(role: :assistant, content: choice["content"])
end
end
Error Mapping
Map API errors to ask-rb’s structured error types:
rescue Faraday::TooManyRequestsError => e
raise Ask::RateLimitError, "Rate limited by MyService"
rescue Faraday::UnauthorizedError => e
raise Ask::Unauthorized, "Invalid API key for MyService"
rescue Faraday::TimeoutError => e
raise Ask::ProviderError, "MyService request timed out"
Testing
class MyCustomProviderTest < Minitest::Test
def setup
@provider = MyCustomProvider.new(api_key: "test")
end
def test_chat
# Use VCR or Faraday test adapter
response = @provider.chat(
[{ role: "user", content: "Hello" }],
model: "my-model"
)
assert response.assistant?
assert response.content
end
end