ask-core
The foundation gem. Zero dependencies. Every ask-rb app depends on this.
gem "ask-core"
What it provides
Conversation
The core data structure — an ordered list of messages that can be serialized for any provider API.
conversation = Ask::Conversation.new
conversation.add_message(:system, "You are a helpful assistant")
conversation.add_message(:user, "What's the weather in Paris?")
conversation.add_message(:assistant, tool_calls: [
{ id: "call_1", name: "get_weather", arguments: { city: "Paris" } }
])
conversation.to_a # => array of hashes ready for API call
Streaming
Receive responses incrementally without mutating state.
stream = Ask::Stream.new
stream.each { |chunk| print chunk.content }
stream.transcript # full accumulated response
Provider interface
A base class that all provider gems implement.
class MyProvider < Ask::Provider
def chat(conversation, tools: [], model: nil, &stream_block)
# Returns Ask::Response or calls stream_block with Ask::Chunk
end
end
Auth helpers
Ask::Auth.from_env("OPENAI_API_KEY") # env var
Ask::Auth::OAuth.new(client_id: "...") # PKCE flow
Ask::Auth::CredentialStore.new(path: "...") # file-backed with 0600
Role mapping
Ask::RoleMap.normalize("developer") # => "system"
Ask::RoleMap.normalize("ai") # => "assistant"
Model Catalog
A process-wide singleton registry of known LLM models. Each entry is an immutable Ask::ModelInfo value object.
# Find a model by ID (provider preference resolves ambiguity)
model = Ask::ModelCatalog.find("gpt-4o")
model.provider # => "openai"
model.context_window # => 128000
model.max_output_tokens # => 16384
model.supports?(:vision) # => true
model.capabilities # => ["function_calling", "structured_output", "vision"]
# Filter by capability
Ask::ModelCatalog.chat_models # models that support chat
Ask::ModelCatalog.embedding_models # models that support embeddings
Ask::ModelCatalog.audio_models # models with audio output
Ask::ModelCatalog.image_models # models with image output
# Filter by metadata
Ask::ModelCatalog.by_provider("openai")
Ask::ModelCatalog.by_family("gpt")
# Refresh from models.dev API
Ask::ModelCatalog.refresh!
Models are loaded into the catalog by Ask::LLM::Catalog.load! (from ask-llm-providers) or registered individually:
model = Ask::ModelInfo.new(
id: "my-model",
provider: "local",
context_window: 4096,
max_output_tokens: 1024,
capabilities: ["chat"]
)
Ask::ModelCatalog.instance.register(model)
ModelInfo
Immutable value object for model metadata.
| Attribute | Type | Description |
|---|---|---|
id | String | Model identifier (e.g. "gpt-4o") |
name | String | Human-readable name |
provider | String | Provider slug (e.g. "openai") |
family | String, nil | Model family (e.g. "gpt", "claude") |
capabilities | Array | Capability flags |
context_window | Integer, nil | Max context window in tokens |
max_output_tokens | Integer, nil | Max output tokens |
modalities | Hash | Input/output modality lists |
pricing | Hash | Cost per token |
knowledge_cutoff | Date, nil | Training data cutoff |
created_at | Date, nil | Release date |
Provider Override
When using the catalog through ask-agent, you can override which provider serves a model by passing provider: to Ask::Agent::Session or Ask::Agent::Chat:
# deepseek-v4-flash is cataloged under the "deepseek" provider,
# but can be served through a different compatible provider:
session = Ask::Agent::Session.new(
model: "deepseek-v4-flash",
provider: :opencode_go,
tools: [...]
)
This bypasses the catalog’s provider field and uses the specified provider instead. Useful when a model is available through multiple providers (e.g., self-hosted vs API).
Error Types
Structured errors with actionable metadata. RateLimitError carries category, type, and retry-after for intelligent handling:
rescue Ask::RateLimitError => e
e.category # => :vendor (upstream provider) or :local (ask-rb)
e.rate_limit_type # => :requests, :tokens, :concurrent, or :budget
e.retry_after # => seconds to wait before retrying (from provider headers)
if e.category == :vendor && e.retry_after
sleep e.retry_after
retry
end
end
| Error | When |
|---|---|
Ask::RateLimitError | 429 — carries category, rate_limit_type, retry_after |
Ask::Unauthorized | 401/403 — API key missing or invalid |
Ask::ServerError | 5xx — provider outage |
Ask::ServiceUnavailable | 503 — temporary downtime |
Ask::ContextLengthExceeded | Context window exceeded |
Ask::ProviderError | Other provider API errors (carries status_code, response_body) |
Document
Ask::Document is a frozen value object for text content with metadata. It flows through every stage of a RAG pipeline — loaded from files, split into chunks, embedded and stored, and returned by retrieval queries.
doc = Ask::Document.new(
content: "Ruby was created by Matz in 1995.",
metadata: { source: "history.pdf", page: 3 }
)
doc.content # => "Ruby was created by Matz in 1995."
doc.metadata # => { source: "history.pdf", page: 3 }
doc.id # => nil (optional)
doc.to_h # => { content: "...", metadata: { source: "history.pdf", page: 3 } }
Two documents are equal when their content and metadata match. The id field is ignored for equality comparisons.
Content (Multi-Modal)
Ask::Content provides typed value objects for multi-modal message content. Messages can carry images, audio, video, and files alongside text.
# Text
Ask::Content::Text.new("What's in this image?")
# Image from URL
Ask::Content::Image.new(url: "https://example.com/photo.jpg", mime_type: "image/jpeg")
# Image from base64 data
Ask::Content::Image.new(base64: "...base64...", mime_type: "image/png")
# Audio
Ask::Content::Audio.new(url: "https://example.com/audio.mp3", mime_type: "audio/mpeg")
# Video
Ask::Content::Video.new(url: "https://example.com/video.mp4", mime_type: "video/mp4")
# File (inline content)
Ask::Content::File.new(data: "file content", mime_type: "text/plain", filename: "notes.txt")
Use content blocks in messages:
msg = Ask::Message.new(role: :user, content: [
Ask::Content::Text.new("What's in this image?"),
Ask::Content::Image.new(url: "https://example.com/photo.jpg", mime_type: "image/jpeg")
])
msg.multimodal? # => true
msg.content_blocks # => [Text("What's in this?"), Image(...)]
msg.content # => "What's in this image?" (backward compatible)
# Or via conversation
conv.user([
Ask::Content::Text.new("Describe this"),
Ask::Content::Image.new(base64: "data", mime_type: "image/png")
])
Content blocks are frozen value objects with structural equality and #to_h serialization. Each provider (OpenAI, Anthropic, etc.) transforms them to its own wire format automatically.
CostCalculator
Ask::LLM::CostCalculator computes API costs from model pricing data. Supports text and audio tokens, cache read/write, reasoning tokens, and batch tier pricing:
cost = Ask::LLM::CostCalculator.calculate(model,
input_tokens: 1000, output_tokens: 500,
cache_read_tokens: 2000, reasoning_tokens: 200,
tier: :batch)
Ask::LLM::CostCalculator.per_million(model)
# => { input: 2.5, output: 10.0, cache_read: 1.25, ... }
Model pricing data
The gem ships 406 models across 12 providers with pricing data sourced from models.dev and OpenRouter. Run rake models:update to refresh before releasing.
Exports
Conversation, Stream, Chunk, Response, Provider, ToolDef, ToolCall, Auth, Auth::OAuth, Auth::CredentialStore, RoleMap, Error classes.