active_harness 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15017e6a42df89f5372596d3b75deb192117a680ad297196eda52111a8931a96
4
- data.tar.gz: 135fdfb6b453c4c2bfaef6ff8ed7f022b2c3fb1b45923f2ed282701fd106864d
3
+ metadata.gz: 38c99c81636b8ffa378a8f753fbdc86f269d843bf3c78769cbb6600c5ef9b9af
4
+ data.tar.gz: b515af2c5c13e84db218059490302bc16f0b7726d7c11b0ebc18e3e6fe883978
5
5
  SHA512:
6
- metadata.gz: 253c875d86e0d52535ba46ef206679a8cd5920e10b0cc0bcc82fad743e4a7bed1d1a3a28c8063db72400f17f057070ae0ac8cf61e14a3fe5708a766d9fa3b00f
7
- data.tar.gz: f7f5a376c825d3ee9ace72f0512cb55f835dc7bad6b4dc419517d14b42f100b78b4bff1e6bf70cc6e7a137624d0ccf91fa71286805e3408560d3eb4fff54b904
6
+ metadata.gz: f7e1f9b8a0c0f88d300df2fc018dc5580524fd67b44c04df359a77bab67a987b20d19823d88fa7d42c9ae6dbfc140e520c0791c7260119b5787687bb6d22c8ca
7
+ data.tar.gz: c7c24cb33a57a75f9edce08484573dabdd35a938543de5acf46ab670c78fc8cffe7a288ed2a9ddf703ef350519888609c80c54bc42242ff48febd7e245c9cce6
@@ -7,13 +7,16 @@ module ActiveHarness
7
7
  # Calls +on_token+ for each content token as it arrives via SSE.
8
8
  # Accumulates and returns the full content string when the stream ends.
9
9
  class StreamingClient
10
- # @param url [URI]
11
- # @param headers [Hash{String => String}]
12
- # @param body [String] JSON-serialized body
13
- # @param timeout [Integer] seconds (open + read)
14
- # @param on_token [Proc] called with each partial token string
15
- # @return [String] full accumulated content
16
- def post(url, headers:, body:, timeout: 60, on_token:)
10
+ # @param url [URI]
11
+ # @param headers [Hash{String => String}]
12
+ # @param body [String] JSON-serialized body
13
+ # @param timeout [Integer] seconds (open + read)
14
+ # @param on_token [Proc] called with each partial token string
15
+ # @param parse_chunk [Proc, nil] receives each parsed SSE JSON hash;
16
+ # must return { token: String|nil, usage: Hash|nil }.
17
+ # Defaults to OpenAI-compatible format.
18
+ # @return [Hash] { content: String, usage: Hash|nil }
19
+ def post(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
17
20
  http = Net::HTTP.new(url.host, url.port)
18
21
  http.use_ssl = true
19
22
  http.open_timeout = timeout
@@ -25,6 +28,7 @@ module ActiveHarness
25
28
 
26
29
  buffer = ""
27
30
  content = ""
31
+ usage = {}
28
32
 
29
33
  http.request(req) do |response|
30
34
  response.read_body do |chunk|
@@ -36,25 +40,33 @@ module ActiveHarness
36
40
  data = line.delete_prefix("data: ")
37
41
  next if data == "[DONE]"
38
42
 
39
- parsed = JSON.parse(data)
40
- token = parsed.dig("choices", 0, "delta", "content")
43
+ parsed = JSON.parse(data) rescue next
44
+ info = parse_chunk ? parse_chunk.call(parsed) : default_chunk(parsed)
45
+ token = info[:token]
41
46
  if token && !token.empty?
42
47
  on_token.call(token)
43
48
  content += token
44
49
  end
50
+ usage = usage.merge(info[:usage]) if info[:usage]
45
51
  end
46
52
  end
47
53
  end
48
54
 
49
- content
55
+ { content: content, usage: usage.empty? ? nil : usage }
50
56
  rescue Net::OpenTimeout, Net::ReadTimeout
51
57
  raise Errors::TimeoutError, "Request to #{url.host} timed out"
52
- rescue JSON::ParserError
53
- # ignore malformed SSE chunks
54
- content
55
58
  rescue => e
56
59
  raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
57
60
  end
61
+
62
+ private
63
+
64
+ def default_chunk(parsed)
65
+ token = parsed.dig("choices", 0, "delta", "content")
66
+ raw_u = parsed["usage"]
67
+ usage = raw_u ? { input_tokens: raw_u["prompt_tokens"].to_i, output_tokens: raw_u["completion_tokens"].to_i } : nil
68
+ { token: token, usage: usage }
69
+ end
58
70
  end
59
71
  end
60
72
  end
@@ -8,25 +8,26 @@ module ActiveHarness
8
8
  ANTHROPIC_VERSION = "2023-06-01"
9
9
  DEFAULT_MAX_TOKENS = 1024
10
10
 
11
- def call(model:, messages:, temperature: 0.7)
11
+ def call(model:, messages:, temperature: 0.7, stream: nil)
12
12
  system_msg, chat_messages = extract_system(messages)
13
13
 
14
14
  body = {
15
- model: model,
16
- max_tokens: DEFAULT_MAX_TOKENS,
15
+ model: model,
16
+ max_tokens: DEFAULT_MAX_TOKENS,
17
17
  temperature: temperature,
18
- messages: chat_messages
18
+ messages: chat_messages
19
19
  }
20
20
  body[:system] = system_msg if system_msg
21
21
 
22
- raw = post_json(URI(config.anthropic_api_url),
23
- headers: {
24
- "Content-Type" => "application/json",
25
- "x-api-key" => api_key,
26
- "anthropic-version" => ANTHROPIC_VERSION
27
- },
28
- body: body
29
- )
22
+ headers = {
23
+ "Content-Type" => "application/json",
24
+ "x-api-key" => api_key,
25
+ "anthropic-version" => ANTHROPIC_VERSION
26
+ }
27
+
28
+ return call_streaming(url: config.anthropic_api_url, headers: headers, body: body, stream: stream, provider: :anthropic, model: model) if stream
29
+
30
+ raw = post_json(URI(config.anthropic_api_url), headers: headers, body: body)
30
31
  data = parse!(raw)
31
32
  handle_error!(data)
32
33
 
@@ -77,6 +78,30 @@ module ActiveHarness
77
78
  raise Errors::InvalidRequestError.new(msg, error_code: type, metadata: metadata)
78
79
  end
79
80
  end
81
+
82
+ # Anthropic streaming uses plain stream: true — no stream_options.
83
+ def prepare_streaming_body(body)
84
+ body.merge(stream: true)
85
+ end
86
+
87
+ # Anthropic SSE events:
88
+ # message_start → input token count
89
+ # content_block_delta → text token
90
+ # message_delta → output token count
91
+ def build_streaming_chunk(parsed)
92
+ token = if parsed["type"] == "content_block_delta" && parsed.dig("delta", "type") == "text_delta"
93
+ parsed.dig("delta", "text")
94
+ end
95
+
96
+ usage = case parsed["type"]
97
+ when "message_start"
98
+ { input_tokens: parsed.dig("message", "usage", "input_tokens").to_i }
99
+ when "message_delta"
100
+ { output_tokens: parsed.dig("usage", "output_tokens").to_i }
101
+ end
102
+
103
+ { token: token, usage: usage }
104
+ end
80
105
  end
81
106
  end
82
107
  end
@@ -16,8 +16,8 @@ module ActiveHarness
16
16
  HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout)
17
17
  end
18
18
 
19
- def post_json_stream(url, headers:, body:, timeout: 60, on_token:)
20
- STREAMING_HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout, on_token: on_token)
19
+ def post_json_stream(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
20
+ STREAMING_HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout, on_token: on_token, parse_chunk: parse_chunk)
21
21
  end
22
22
 
23
23
  # Normalize OpenAI-compatible usage object to a consistent hash.
@@ -45,6 +45,32 @@ module ActiveHarness
45
45
  }
46
46
  end
47
47
 
48
+ # Streaming call for OpenAI-compatible providers.
49
+ # Subclasses may override +prepare_streaming_body+ and +build_streaming_chunk+
50
+ # to support non-OpenAI SSE formats (e.g. Anthropic).
51
+ def call_streaming(url:, headers:, body:, stream:, provider:, model:)
52
+ body = prepare_streaming_body(body)
53
+ result = post_json_stream(URI(url), headers: headers, body: body, on_token: stream, parse_chunk: method(:build_streaming_chunk))
54
+ u = result[:usage] || {}
55
+ usage = u.any? ? { input_tokens: u[:input_tokens].to_i, output_tokens: u[:output_tokens].to_i, total_tokens: u[:input_tokens].to_i + u[:output_tokens].to_i } : nil
56
+ { content: result[:content], provider: provider, model: model, usage: usage }
57
+ end
58
+
59
+ # Override in subclass to change streaming request body options.
60
+ def prepare_streaming_body(body)
61
+ body.merge(stream: true, stream_options: { include_usage: true })
62
+ end
63
+
64
+ # Override in subclass to parse provider-specific SSE chunks.
65
+ # Must return { token: String|nil, usage: Hash|nil } where usage keys
66
+ # are :input_tokens and :output_tokens.
67
+ def build_streaming_chunk(parsed)
68
+ token = parsed.dig("choices", 0, "delta", "content")
69
+ raw_u = parsed["usage"]
70
+ usage = raw_u ? { input_tokens: raw_u["prompt_tokens"].to_i, output_tokens: raw_u["completion_tokens"].to_i } : nil
71
+ { token: token, usage: usage }
72
+ end
73
+
48
74
  def parse!(raw)
49
75
  JSON.parse(raw)
50
76
  rescue JSON::ParserError => e
@@ -5,14 +5,13 @@ module ActiveHarness
5
5
  # DeepSeek — OpenAI-compatible API.
6
6
  # https://platform.deepseek.com/api-docs
7
7
  class DeepSeek < Base
8
- def call(model:, messages:, temperature: 0.7)
9
- raw = post_json(URI(config.deepseek_api_url),
10
- headers: {
11
- "Content-Type" => "application/json",
12
- "Authorization" => "Bearer #{api_key}"
13
- },
14
- body: { model: model, messages: messages, temperature: temperature }
15
- )
8
+ def call(model:, messages:, temperature: 0.7, stream: nil)
9
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
+ body = { model: model, messages: messages, temperature: temperature }
11
+
12
+ return call_streaming(url: config.deepseek_api_url, headers: headers, body: body, stream: stream, provider: :deepseek, model: model) if stream
13
+
14
+ raw = post_json(URI(config.deepseek_api_url), headers: headers, body: body)
16
15
  data = parse!(raw)
17
16
  handle_error!(data)
18
17
 
@@ -5,23 +5,17 @@ module ActiveHarness
5
5
  # Google Gemini — OpenAI-compatible endpoint (beta).
6
6
  # https://ai.google.dev/gemini-api/docs/openai
7
7
  class Gemini < Base
8
- def call(model:, messages:, temperature: 0.7)
9
- raw = post_json(URI(config.gemini_api_url),
10
- headers: {
11
- "Content-Type" => "application/json",
12
- "Authorization" => "Bearer #{api_key}"
13
- },
14
- body: { model: model, messages: messages, temperature: temperature }
15
- )
8
+ def call(model:, messages:, temperature: 0.7, stream: nil)
9
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
+ body = { model: model, messages: messages, temperature: temperature }
11
+
12
+ return call_streaming(url: config.gemini_api_url, headers: headers, body: body, stream: stream, provider: :gemini, model: model) if stream
13
+
14
+ raw = post_json(URI(config.gemini_api_url), headers: headers, body: body)
16
15
  data = parse!(raw)
17
16
  handle_error!(data)
18
17
 
19
- {
20
- content: data.dig("choices", 0, "message", "content").to_s.strip,
21
- provider: :gemini,
22
- model: data["model"] || model,
23
- usage: extract_usage_openai(data)
24
- }
18
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :gemini, model: data["model"] || model, usage: extract_usage_openai(data) }
25
19
  end
26
20
 
27
21
  private
@@ -13,26 +13,20 @@ module ActiveHarness
13
13
  # use provider: :gpustack, model: "Qwen/Qwen2.5-7B-Instruct-GGUF"
14
14
  # end
15
15
  class GPUStack < Base
16
- def call(model:, messages:, temperature: 0.7)
17
- url = URI("#{api_base}/v1/chat/completions")
18
-
16
+ def call(model:, messages:, temperature: 0.7, stream: nil)
17
+ url = "#{api_base}/v1/chat/completions"
19
18
  headers = { "Content-Type" => "application/json" }
20
19
  key = api_key
21
20
  headers["Authorization"] = "Bearer #{key}" if key
21
+ body = { model: model, messages: messages, temperature: temperature }
22
+
23
+ return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :gpustack, model: model) if stream
22
24
 
23
- raw = post_json(url,
24
- headers: headers,
25
- body: { model: model, messages: messages, temperature: temperature }
26
- )
25
+ raw = post_json(URI(url), headers: headers, body: body)
27
26
  data = parse!(raw)
28
27
  handle_error!(data)
29
28
 
30
- {
31
- content: data.dig("choices", 0, "message", "content").to_s.strip,
32
- provider: :gpustack,
33
- model: data["model"] || model,
34
- usage: extract_usage_openai(data)
35
- }
29
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :gpustack, model: data["model"] || model, usage: extract_usage_openai(data) }
36
30
  end
37
31
 
38
32
  private
@@ -5,23 +5,17 @@ module ActiveHarness
5
5
  # Groq — OpenAI-compatible API with fast inference.
6
6
  # https://console.groq.com/docs/openai
7
7
  class Groq < Base
8
- def call(model:, messages:, temperature: 0.7)
9
- raw = post_json(URI(config.groq_api_url),
10
- headers: {
11
- "Content-Type" => "application/json",
12
- "Authorization" => "Bearer #{api_key}"
13
- },
14
- body: { model: model, messages: messages, temperature: temperature }
15
- )
8
+ def call(model:, messages:, temperature: 0.7, stream: nil)
9
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
+ body = { model: model, messages: messages, temperature: temperature }
11
+
12
+ return call_streaming(url: config.groq_api_url, headers: headers, body: body, stream: stream, provider: :groq, model: model) if stream
13
+
14
+ raw = post_json(URI(config.groq_api_url), headers: headers, body: body)
16
15
  data = parse!(raw)
17
16
  handle_error!(data)
18
17
 
19
- {
20
- content: data.dig("choices", 0, "message", "content").to_s.strip,
21
- provider: :groq,
22
- model: data["model"] || model,
23
- usage: extract_usage_openai(data)
24
- }
18
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :groq, model: data["model"] || model, usage: extract_usage_openai(data) }
25
19
  end
26
20
 
27
21
  private
@@ -5,14 +5,13 @@ module ActiveHarness
5
5
  # Mistral AI — OpenAI-compatible API.
6
6
  # https://docs.mistral.ai/api
7
7
  class Mistral < Base
8
- def call(model:, messages:, temperature: 0.7)
9
- raw = post_json(URI(config.mistral_api_url),
10
- headers: {
11
- "Content-Type" => "application/json",
12
- "Authorization" => "Bearer #{api_key}"
13
- },
14
- body: { model: model, messages: messages, temperature: temperature }
15
- )
8
+ def call(model:, messages:, temperature: 0.7, stream: nil)
9
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
+ body = { model: model, messages: messages, temperature: temperature }
11
+
12
+ return call_streaming(url: config.mistral_api_url, headers: headers, body: body, stream: stream, provider: :mistral, model: model) if stream
13
+
14
+ raw = post_json(URI(config.mistral_api_url), headers: headers, body: body)
16
15
  data = parse!(raw)
17
16
  handle_error!(data)
18
17
 
@@ -13,26 +13,20 @@ module ActiveHarness
13
13
  # use provider: :ollama, model: "llama3.2"
14
14
  # end
15
15
  class Ollama < Base
16
- def call(model:, messages:, temperature: 0.7)
17
- url = URI("#{api_base}/v1/chat/completions")
18
-
16
+ def call(model:, messages:, temperature: 0.7, stream: nil)
17
+ url = "#{api_base}/v1/chat/completions"
19
18
  headers = { "Content-Type" => "application/json" }
20
19
  key = api_key
21
20
  headers["Authorization"] = "Bearer #{key}" if key
21
+ body = { model: model, messages: messages, temperature: temperature }
22
+
23
+ return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :ollama, model: model) if stream
22
24
 
23
- raw = post_json(url,
24
- headers: headers,
25
- body: { model: model, messages: messages, temperature: temperature }
26
- )
25
+ raw = post_json(URI(url), headers: headers, body: body)
27
26
  data = parse!(raw)
28
27
  handle_error!(data)
29
28
 
30
- {
31
- content: data.dig("choices", 0, "message", "content").to_s.strip,
32
- provider: :ollama,
33
- model: data["model"] || model,
34
- usage: extract_usage_openai(data)
35
- }
29
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :ollama, model: data["model"] || model, usage: extract_usage_openai(data) }
36
30
  end
37
31
 
38
32
  private
@@ -7,23 +7,17 @@ module ActiveHarness
7
7
  # @param messages [Array<Hash>] [{role:, content:}, ...]
8
8
  # @param temperature [Float]
9
9
  # @return [Hash] { content:, provider:, model: }
10
- def call(model:, messages:, temperature: 0.7)
11
- raw = post_json(URI(config.openai_api_url),
12
- headers: {
13
- "Content-Type" => "application/json",
14
- "Authorization" => "Bearer #{api_key}"
15
- },
16
- body: { model: model, messages: messages, temperature: temperature }
17
- )
10
+ def call(model:, messages:, temperature: 0.7, stream: nil)
11
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
12
+ body = { model: model, messages: messages, temperature: temperature }
13
+
14
+ return call_streaming(url: config.openai_api_url, headers: headers, body: body, stream: stream, provider: :openai, model: model) if stream
15
+
16
+ raw = post_json(URI(config.openai_api_url), headers: headers, body: body)
18
17
  data = parse!(raw)
19
18
  handle_error!(data)
20
19
 
21
- {
22
- content: data.dig("choices", 0, "message", "content").to_s.strip,
23
- provider: :openai,
24
- model: data["model"] || model,
25
- usage: extract_usage_openai(data)
26
- }
20
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :openai, model: data["model"] || model, usage: extract_usage_openai(data) }
27
21
  end
28
22
 
29
23
  private
@@ -17,11 +17,7 @@ module ActiveHarness
17
17
  }
18
18
  body = { model: model, messages: messages, temperature: temperature }
19
19
 
20
- if stream
21
- body[:stream] = true
22
- content = post_json_stream(URI(config.openrouter_api_url), headers: headers, body: body, on_token: stream)
23
- return { content: content, provider: :openrouter, model: model }
24
- end
20
+ return call_streaming(url: config.openrouter_api_url, headers: headers, body: body, stream: stream, provider: :openrouter, model: model) if stream
25
21
 
26
22
  raw = post_json(URI(config.openrouter_api_url), headers: headers, body: body)
27
23
  data = parse!(raw)
@@ -5,14 +5,13 @@ module ActiveHarness
5
5
  # Perplexity — OpenAI-compatible API with web-search-augmented models.
6
6
  # https://docs.perplexity.ai/api-reference/chat-completions
7
7
  class Perplexity < Base
8
- def call(model:, messages:, temperature: 0.7)
9
- raw = post_json(URI(config.perplexity_api_url),
10
- headers: {
11
- "Content-Type" => "application/json",
12
- "Authorization" => "Bearer #{api_key}"
13
- },
14
- body: { model: model, messages: messages, temperature: temperature }
15
- )
8
+ def call(model:, messages:, temperature: 0.7, stream: nil)
9
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
+ body = { model: model, messages: messages, temperature: temperature }
11
+
12
+ return call_streaming(url: config.perplexity_api_url, headers: headers, body: body, stream: stream, provider: :perplexity, model: model) if stream
13
+
14
+ raw = post_json(URI(config.perplexity_api_url), headers: headers, body: body)
16
15
  data = parse!(raw)
17
16
  handle_error!(data)
18
17
 
@@ -5,14 +5,13 @@ module ActiveHarness
5
5
  # xAI (Grok) — OpenAI-compatible API.
6
6
  # https://docs.x.ai/api
7
7
  class XAI < Base
8
- def call(model:, messages:, temperature: 0.7)
9
- raw = post_json(URI(config.xai_api_url),
10
- headers: {
11
- "Content-Type" => "application/json",
12
- "Authorization" => "Bearer #{api_key}"
13
- },
14
- body: { model: model, messages: messages, temperature: temperature }
15
- )
8
+ def call(model:, messages:, temperature: 0.7, stream: nil)
9
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
+ body = { model: model, messages: messages, temperature: temperature }
11
+
12
+ return call_streaming(url: config.xai_api_url, headers: headers, body: body, stream: stream, provider: :xai, model: model) if stream
13
+
14
+ raw = post_json(URI(config.xai_api_url), headers: headers, body: body)
16
15
  data = parse!(raw)
17
16
  handle_error!(data)
18
17
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.2.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher