github.com/trans/arcana-ai

AI service library for Arcana (and other applications)

0 stars
3 dependents
License: MIT

Nothing has been indexed for 0.3.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.

Installation

# Add this to your shard.yml
dependencies:
  arcana-ai:
    github: trans/arcana-ai
    version: ~> 0.3.0

Then run:

shards install

shard.yml

No shard.yml has been indexed for 0.3.0. You can read it on the repository.

Dependencies

Unknown: the shard.yml for this version has not been read yet.

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

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.5.3

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"]),
})

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-13", 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(at) on an Image::Result (at: when the call was made) and result.cost on a TTS::Result. 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 now asks for its audio as events; tts-1 and tts-1-hd calls, ElevenLabs calls, and OpenAI's older image models (whose cached tokens go unreported) come back Unpriced.

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

The older counts in each provider's own terms (Chat::Response's prompt_tokens, completion_tokens, cache_read_tokens, cache_creation_tokens and reasoning_tokens, and Embed::Result's total_tokens) are deprecated: the compiler warns where you use them, naming what replaces each. So is Image::Result#cost as a Float64, and building a result with one. They go away in 0.6.0, when an image's plain cost becomes the Cost or Unpriced above.

Development

crystal spec

License

MIT