arcana-ai v0.6.1

AI service library for Arcana (and other applications)

arcana-ai

Provider-agnostic AI client library for Crystal. Chat, image, TTS, SFX, and embedding providers behind a common interface.

Extracted from arcana so it can be used without pulling in the bus/server. Usable standalone as an AI client library, or as a dependency of arcana itself.

Providers

  • Chat: OpenAI (endpoint-configurable — also drives Grok, DeepSeek), Anthropic (native Messages API), Gemini (native API)
  • Image: OpenAI (GPT Image; gpt-image-2.5-sunburst by default), Runware (FLUX)
  • TTS: OpenAI (gpt-4o-mini-tts; OpenAI shuts down its speech models on 2027-01-06), ElevenLabs
  • SFX: ElevenLabs
  • Embed: OpenAI, Voyage (with query/document input types)

Installation

Add to your shard.yml:

dependencies:
  arcana-ai:
    github: trans/arcana-ai
    version: ~> 0.6.0

Then shards install.

Usage

require "arcana-ai"

chat = Arcana::AI::Chat::Anthropic.new(api_key: ENV["ANTHROPIC_API_KEY"])
request = Arcana::AI::Chat::Request.new(
  messages: [Arcana::AI::Chat::Message.new("user", "Hello!")],
  model: "claude-sonnet-4-6",
  max_tokens: 200,
)
response = chat.complete(request)
puts response.content

Images in a message (a file or bytes, or a URL the provider fetches):

Arcana::AI::Chat::Message.user("What does this picture show?", images: [
  Arcana::AI::Chat::ImagePart.file("room.webp", detail: "low"),
])

Chat calls retry rate limits (429), server errors (500, 502, 503, 504, Anthropic's 529) and network errors up to 3 times, waiting as long as the provider asks (Retry-After, Gemini's retryDelay) or backing off 0.5 s, 1 s, 2 s with jitter. They never retry OpenAI's insufficient_quota. response.retries and response.retry_wait say what happened; set chat.retry_policy = Arcana::AI::RetryPolicy.none to turn it off.

Via the registry:

provider = Arcana::AI::Registry.create_chat("anthropic", {
  "api_key" => JSON::Any.new(ENV["ANTHROPIC_API_KEY"]),
})

Speech providers wait up to 120 s for the audio: OpenAI sends gpt-4o-mini-tts's only once the clip is done, which can take over a minute. Pass read_timeout: (or "read_timeout" in seconds, through the registry), or set tts.read_timeout, to change it. A request's speed goes to ElevenLabs as its speed voice setting (0.7 to 1.2; eleven_v3 ignores it), the only voice setting arcana-ai sends.

What calls cost

Each chat response and embedding result carries usage: what the call used, in the categories providers bill by (uncached input, cache reads, cache writes, output with reasoning included, web searches), the same for every provider. OpenAI image and speech results carry it too, with input and output images and output audio counted apart from text. It's nil when the provider reported none, never zero.

cost prices it at the provider's list rates:

case cost = response.cost
in Arcana::AI::Pricing::Cost
  cost.total     # => 0.0111 (BigDecimal, USD)
  cost.lines     # input, cache_read, output..., each with its rate
  cost.rates     # => "arcana-ai 2026-10-17", the rate table used
in Arcana::AI::Pricing::Unpriced
  cost.reason    # NoUsage, UnknownModel, Tier, Region or NoRate
  cost.detail
end

Images and speech work the same way: result.cost on an Image::Result or a TTS::Result, with at (when the call was made) for a stored one, as for chat. An image's cost is the provider's own figure when it reports one (Runware does, as reported_cost) and its usage at list rates otherwise. OpenAI's speech reports usage only for gpt-4o-mini-tts, which arcana-ai asks for its audio as events. tts-1 and tts-1-hd report none but bill every character of the input, so their usage.characters is the input's length. OpenAI's older image models (whose cached tokens go unreported) come back Unpriced.

ElevenLabs speech reports the characters it billed, as usage.characters, priced at its API rates per thousand characters, which are the same on every plan. The count is ElevenLabs' own and can be less than the text's length: eleven_v3 bills fewer characters than it reads.

A Cost isn't an estimate. The rates come from the providers' pricing pages (src/arcana-ai/pricing/rates.yml) and are looked up by the model the provider says answered, at the time of the call. Prices that change with prompt length, US-only inference on Anthropic, and dated price changes are all applied. What can't be known isn't guessed: a model missing from the table, a call with no usage, or a tier or region without rates comes back Unpriced. Account-level terms (free allowances, credits, discounts, taxes) are outside a call's price. cost.complete? is false when the total leaves out a charge that isn't set per call, such as Anthropic code-execution time.

Other services, or other terms, go in a table of your own laid over the built-in one. Pass name: to a provider pointed at an OpenAI-compatible service so its responses carry that name:

rates = Arcana::AI::Pricing::Table.default.merge(Arcana::AI::Pricing::Table.from_yaml(<<-YAML))
  version: my-app 2026-10-13
  models:
    - {provider: my-gateway, model: my-model, input: 0.50, output: 1.50}
  YAML
response.cost(table: rates)
Arcana::AI::Pricing.cost(provider, model, usage, at: logged_at, table: rates) # price a stored call

Development

crystal spec

License

MIT

Repository

arcana-ai

Owner
Statistic
  • 0
  • 0
  • 0
  • 2
  • 0
  • 1 day ago
  • July 1, 2026
License

MIT License

Links
Synced at

Wed, 21 Oct 2026 16:59:04 GMT

Languages