Class: Tina4::Ai

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/ai_client.rb

Overview

Zero-dependency app-facing AI client (ADR-0053).

Constant Summary collapse

PROVIDERS =
%w[local openai anthropic].freeze

Class Method Summary collapse

Class Method Details

.chat(messages, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil) ⇒ Object



29
30
31
32
33
34
35
36
# File 'lib/tina4/ai_client.rb', line 29

def chat(messages, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil)
  validate_messages(messages)
  config = resolve_config("chat", model, timeout, provider)
  body = chat_body(config, messages, temperature, max_tokens, stream)
  return stream_request(config, headers(config), body) if stream

  normalize_chat(config[:provider], request_json(config, headers(config), body))
end

.complete(prompt, **options) ⇒ Object

Raises:



38
39
40
41
42
43
# File 'lib/tina4/ai_client.rb', line 38

def complete(prompt, **options)
  raise AiConfigError, "AI prompt must be a string" unless prompt.is_a?(String)

  options.delete(:stream)
  chat([{ role: "user", content: prompt }], **options, stream: false).text
end

.embed(text_or_texts, model: nil, timeout: nil, provider: nil) ⇒ Object

Raises:



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/tina4/ai_client.rb', line 45

def embed(text_or_texts, model: nil, timeout: nil, provider: nil)
  single = text_or_texts.is_a?(String)
  valid_batch = text_or_texts.is_a?(Array) && !text_or_texts.empty? && text_or_texts.all? { |item| item.is_a?(String) }
  raise AiConfigError, "AI embedding input must be a string or a non-empty list of strings" unless single || valid_batch

  config = resolve_config("embed", model, timeout, provider)
  raise AiConfigError, "Anthropic does not provide the embedding endpoint in this contract" if config[:provider] == "anthropic"

  raw = request_json(config, headers(config), { model: config[:model], input: text_or_texts })
  begin
    data = raw.fetch("data").sort_by { |item| item.fetch("index", 0) }
    vectors = data.map { |item| item.fetch("embedding") }
    expected = single ? 1 : text_or_texts.length
    valid = vectors.length == expected && vectors.all? do |vector|
      vector.is_a?(Array) && !vector.empty? && vector.all? { |value| value.is_a?(Numeric) }
    end
    raise KeyError unless valid
  rescue KeyError, TypeError
    raise AiParseError, "AI provider returned a malformed embedding response"
  end
  single ? vectors.first : vectors
end