Class: Tina4::Ai
- Inherits:
-
Object
- Object
- Tina4::Ai
- 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
-
.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).
- .complete(prompt, **options) ⇒ Object
- .embed(text_or_texts, model: nil, timeout: nil, provider: nil) ⇒ Object
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(, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil, tools: nil, tool_choice: nil) () validate_tools(tools) validate_tool_choice(tool_choice) config = resolve_config("chat", model, timeout, provider) body = chat_body(config, , 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
64 65 66 67 68 69 |
# File 'lib/tina4/ai_client.rb', line 64 def complete(prompt, **) raise AiConfigError, "AI prompt must be a string" unless prompt.is_a?(String) .delete(:stream) chat([{ role: "user", content: prompt }], **, stream: false).text end |
.embed(text_or_texts, model: nil, timeout: nil, provider: nil) ⇒ Object
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 (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 |