Class: Tina4::Ai

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

Overview

Zero-dependency app-facing AI client. ADR-0053 defined the base contract (chat / complete / embed with a normalised, provider-neutral response shape); ADR-0060 extended it with typed streaming events and multimodal content parts. Both live here so the AI surface is a single file.

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, tools: nil, tool_choice: nil) ⇒ Object

chat(stream: false) still returns a ChatResponse (ADR-0053). chat(stream: true) returns an Enumerator of typed events (ADR-0060):

{ type: :text_delta, text: "..." }
{ type: :tool_call,  id: "...", name: "...", args: {...} }
{ type: :done,       finish_reason: "...", usage: {...} | nil }
{ type: :error,      message: "...", code: "..." | nil }

The typed events replace the ADR-0053 string-only stream shape. That was a deliberately breaking change (see ADR-0060 ยง7) so an agent loop could observe tool_calls and finish_reason without hand-rolled SSE parsing per app.

ADR-0061 adds the SEND half of the agent loop:

  • tools: [description:, parameters:] (parameters is JSON-Schema)
  • tool_choice: 'auto' | 'none' | 'required' | '...'
  • tool-result turns accepted in either OpenAI or Anthropic form; the client normalises to whichever the current provider expects.


51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/tina4/ai_client.rb', line 51

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

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

.complete(prompt, **options) ⇒ Object

Raises:



64
65
66
67
68
69
# File 'lib/tina4/ai_client.rb', line 64

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:



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/tina4/ai_client.rb', line 71

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