active_harness 0.3.6 → 0.3.7

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: 347d7944af01b44bda7568ac47fc335fd58a48320f80ae71c4a52b83458a710f
4
- data.tar.gz: 13343b28d031fd4dd86b4114a7d4c3f7b7223db6402b3c1f2bf9e135073e3bc3
3
+ metadata.gz: 54990f7712275af0727b75a7518f30f2ecf5ea798fb14548b5872997a6b272bc
4
+ data.tar.gz: d81370f86507d5c1d42bc12aac01209afb76bed63d2bb5545bb686ef85cb49ad
5
5
  SHA512:
6
- metadata.gz: 3eb2200ec4c40e7ecf626d9cff3c5f56917b07dbf5756fa54d986d327ce740b18b95b5806f4e9924674a010196c76bbb11f9c66fbe9f54256ac0cfce4c901a7c
7
- data.tar.gz: 8c1c186d855e6ef3ee0e8961665904da7175cf7018c0e4e352eb2b6a15bcd42f2d019963cb996d0bb8407f8eeb9eda98bac13b8def11fd2c43b382a8dce8d8f5
6
+ metadata.gz: cc8f9d543c65fde9818611d68073b2a7de6285f37c815d81e6730c393103add7aed45ba9c9eaf7e9461ddbcf5062b3bd2e4978b3b690cbb190b0cf8eeff19209
7
+ data.tar.gz: 73f6e6c7a606e4f36bebf8cdd330534d4a37fd028f5f53fd2133c8a365caa136e62e5044baa54518b9ff8883022a6c4e4af4a01f84ea0d4bb4798c0d9b584d01
@@ -105,6 +105,12 @@ module ActiveHarness
105
105
  attr_accessor :vercel_api_key
106
106
  attr_accessor :vercel_api_url
107
107
 
108
+ # -------------------------------------------------------------------------
109
+ # Cohere (native v2 Chat API)
110
+ # -------------------------------------------------------------------------
111
+ attr_accessor :cohere_api_key
112
+ attr_accessor :cohere_api_url
113
+
108
114
  # -------------------------------------------------------------------------
109
115
  # TypeSafe — direct Jev (System One) API; also any Jev-compatible server
110
116
  # -------------------------------------------------------------------------
@@ -210,6 +216,9 @@ module ActiveHarness
210
216
  @vercel_api_key = ENV["VERCEL_API_KEY"]
211
217
  @vercel_api_url = "https://ai-gateway.vercel.sh/typesafe/v1/systemone"
212
218
 
219
+ @cohere_api_key = ENV["COHERE_API_KEY"]
220
+ @cohere_api_url = "https://api.cohere.com/v2/chat"
221
+
213
222
  @typesafe_api_key = ENV["TYPESAFE_API_KEY"]
214
223
  @typesafe_api_url = "https://api.typesafe.ai/v1/systemone"
215
224
 
@@ -8,7 +8,9 @@ module ActiveHarness
8
8
  # @param headers [Hash{String => String}]
9
9
  # @param body [String] JSON-serialized body
10
10
  # @param timeout [Integer] seconds (open + read)
11
- # @return [String] raw response body
11
+ # @return [String] raw response body; also responds to #http_status
12
+ # (Integer) so providers can classify errors by
13
+ # status without changing the return type
12
14
  def post(url, headers:, body:, timeout: 30)
13
15
  http = Net::HTTP.new(url.host, url.port)
14
16
  http.use_ssl = true
@@ -19,7 +21,14 @@ module ActiveHarness
19
21
  headers.each { |k, v| req[k] = v }
20
22
  req.body = body
21
23
 
22
- http.request(req).body
24
+ res = http.request(req)
25
+ body = res.body
26
+ return body if body.nil?
27
+
28
+ body = body.dup
29
+ status = res.code.to_i
30
+ body.define_singleton_method(:http_status) { status }
31
+ body
23
32
  rescue Net::OpenTimeout, Net::ReadTimeout
24
33
  raise Errors::TimeoutError, "Request to #{url.host} timed out"
25
34
  rescue => e
@@ -0,0 +1,101 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # Cohere — native v2 Chat API (not OpenAI-compatible).
6
+ # https://docs.cohere.com/reference/chat
7
+ #
8
+ # Text chat only: tools, citations, documents, rerank and embeddings are not
9
+ # supported. Thinking blocks returned by reasoning models are discarded —
10
+ # only `text` blocks make it into the result.
11
+ #
12
+ # Example:
13
+ # model do
14
+ # use provider: :cohere, model: "command-a-03-2025"
15
+ # end
16
+ class Cohere < Base
17
+ def call(model:, messages:, temperature: 0.7, stream: nil)
18
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
19
+ body = { model: model, messages: messages, temperature: temperature }
20
+
21
+ return call_streaming(url: config.cohere_api_url, headers: headers, body: body, stream: stream, provider: :cohere, model: model) if stream
22
+
23
+ raw = post_json(URI(config.cohere_api_url), headers: headers, body: body)
24
+ data = parse!(raw)
25
+ handle_error!(data, status: raw.respond_to?(:http_status) ? raw.http_status : nil)
26
+
27
+ {
28
+ content: extract_text(data),
29
+ provider: :cohere,
30
+ model: model,
31
+ usage: extract_usage_cohere(data["usage"])
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def api_key
38
+ key = config.cohere_api_key.to_s
39
+ raise Errors::InvalidApiKeyError, "cohere_api_key is not configured" if key.empty?
40
+ key
41
+ end
42
+
43
+ # message.content is an array of typed blocks ({type: "text"|"thinking", ...});
44
+ # keep only the text.
45
+ def extract_text(data)
46
+ Array(data.dig("message", "content"))
47
+ .select { |block| block["type"] == "text" }
48
+ .map { |block| block["text"].to_s }
49
+ .join
50
+ .strip
51
+ end
52
+
53
+ # `tokens` is what the model processed, `billed_units` what Cohere charges
54
+ # for (it does not bill its own preamble). Prefer `tokens`, like ruby_llm.
55
+ def extract_usage_cohere(usage)
56
+ return nil unless usage
57
+
58
+ tokens = usage["tokens"] || {}
59
+ billed = usage["billed_units"] || {}
60
+ input = (tokens["input_tokens"] || billed["input_tokens"]).to_i
61
+ output = (tokens["output_tokens"] || billed["output_tokens"]).to_i
62
+ { input_tokens: input, output_tokens: output, total_tokens: input + output }
63
+ end
64
+
65
+ # Cohere errors are flat: { "message": "..." } (validation errors may add
66
+ # an "id"). Success bodies have no "message" string, only a message Hash.
67
+ def handle_error!(data, status: nil)
68
+ return unless data["message"].is_a?(String)
69
+ return if status && status < 400
70
+
71
+ msg = data["message"]
72
+ code = status ? status.to_s : nil
73
+ meta = data["id"] ? { id: data["id"] } : nil
74
+
75
+ case status
76
+ when 401, 403 then raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: meta)
77
+ when 429 then raise Errors::RateLimitError.new(msg, error_code: code, metadata: meta)
78
+ when 503 then raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: meta)
79
+ when 500..599 then raise Errors::ServerError.new(msg, error_code: code, metadata: meta)
80
+ else raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: meta)
81
+ end
82
+ end
83
+
84
+ # Cohere streaming takes a plain `stream: true` — no stream_options.
85
+ def prepare_streaming_body(body)
86
+ body.merge(stream: true)
87
+ end
88
+
89
+ # Cohere SSE events:
90
+ # content-delta → delta.message.content.text (or .thinking, ignored)
91
+ # message-end → delta.usage with token counts
92
+ def build_streaming_chunk(parsed)
93
+ token = parsed.dig("delta", "message", "content", "text") if parsed["type"] == "content-delta"
94
+ usage = extract_usage_cohere(parsed.dig("delta", "usage")) if parsed["type"] == "message-end"
95
+ usage = usage&.reject { |k, _| k == :total_tokens }
96
+
97
+ { token: token, usage: usage }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -60,7 +60,7 @@ module ActiveHarness
60
60
 
61
61
  raw = post_json(URI(endpoint_url), headers: headers, body: body)
62
62
  data = parse!(raw)
63
- handle_error!(data)
63
+ handle_error!(data, status: raw.respond_to?(:http_status) ? raw.http_status : nil)
64
64
 
65
65
  {
66
66
  content: JSON.pretty_generate(data["answers"]),
@@ -114,15 +114,27 @@ module ActiveHarness
114
114
  end
115
115
  end
116
116
 
117
+ # Prefer the HTTP status when we have it; fall back to sniffing the
118
+ # message only when we don't (e.g. a custom HTTP client without #http_status).
119
+ def detail_error_type(msg, status)
120
+ case status
121
+ when 401, 403 then "unauthorized"
122
+ when 429 then "rate_limit"
123
+ when 500..599 then "server_error"
124
+ when nil then msg =~ /api key|unauthori|authenticat|invalid token|credentials/i ? "unauthorized" : "invalid_request"
125
+ else "invalid_request"
126
+ end
127
+ end
128
+
117
129
  # Error shapes seen in practice:
118
130
  # - TypeSafe direct API: { "detail": ... } (see detail_message)
119
131
  # - TypeSafe request-validation errors: { "message": "...", "error_type": "..." }
120
132
  # - AI Gateway account/billing errors (OpenAI-style, nested): { "error": { "message": "...", "type": "..." } }
121
- def handle_error!(data)
133
+ def handle_error!(data, status: nil)
122
134
  msg, type =
123
135
  if data.key?("detail")
124
136
  msg = detail_message(data["detail"])
125
- [msg, msg =~ /api key|unauthori|authenticat|invalid token|credentials/i ? "unauthorized" : "invalid_request"]
137
+ [msg, detail_error_type(msg, status)]
126
138
  elsif data["message"] && data["error_type"]
127
139
  [data["message"].to_s, data["error_type"].to_s]
128
140
  elsif data["error"].is_a?(Hash)
@@ -134,6 +146,7 @@ module ActiveHarness
134
146
  when "invalid_request" then raise Errors::InvalidRequestError.new(msg, error_code: type)
135
147
  when "unauthorized", "authentication_error" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
136
148
  when "rate_limit", "rate_limit_error" then raise Errors::RateLimitError.new(msg, error_code: type)
149
+ when "server_error" then raise Errors::ServerError.new(msg, error_code: type)
137
150
  when "customer_verification_required" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
138
151
  else raise Errors::ProviderError.new(msg, error_code: type)
139
152
  end
@@ -37,6 +37,7 @@ module ActiveHarness
37
37
  vertexai: -> { Providers::VertexAI.new },
38
38
  custom: -> { Providers::Custom.new },
39
39
  vercel: -> { Providers::Vercel.new },
40
+ cohere: -> { Providers::Cohere.new },
40
41
  typesafe: -> { Providers::TypeSafe.new },
41
42
  ollama_cloud: -> { Providers::OllamaCloud.new },
42
43
  hetzner: -> { Providers::Hetzner.new }
@@ -23,6 +23,7 @@ require_relative "active_harness/providers/vertexai"
23
23
  require_relative "active_harness/providers/custom"
24
24
  require_relative "active_harness/providers/vercel"
25
25
  require_relative "active_harness/providers/typesafe"
26
+ require_relative "active_harness/providers/cohere"
26
27
  require_relative "active_harness/providers/ollama_cloud"
27
28
  require_relative "active_harness/providers/hetzner"
28
29
  require_relative "active_harness/providers/images/openai"
@@ -40,7 +41,7 @@ require_relative "active_harness/pipeline"
40
41
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
41
42
 
42
43
  module ActiveHarness
43
- VERSION = "0.3.6"
44
+ VERSION = "0.3.7"
44
45
 
45
46
  class << self
46
47
  # Configure ActiveHarness.
@@ -81,6 +81,12 @@ ActiveHarness.configure do |config|
81
81
  # config.azure_ai_auth_token = ENV["AZURE_AI_AUTH_TOKEN"] # OAuth bearer (alternative)
82
82
  # config.azure_api_version = "2024-05-01-preview"
83
83
 
84
+ # ---------------------------------------------------------------------------
85
+ # Cohere (native v2 Chat API)
86
+ # ---------------------------------------------------------------------------
87
+ # config.cohere_api_key = ENV["COHERE_API_KEY"]
88
+ # config.cohere_api_url = "https://api.cohere.com/v2/chat"
89
+
84
90
  # ---------------------------------------------------------------------------
85
91
  # TypeSafe — Jev (System One) evaluation model, direct API
86
92
  # ---------------------------------------------------------------------------
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.3.6
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
@@ -70,6 +70,7 @@ files:
70
70
  - lib/active_harness/providers/azure.rb
71
71
  - lib/active_harness/providers/base.rb
72
72
  - lib/active_harness/providers/bedrock.rb
73
+ - lib/active_harness/providers/cohere.rb
73
74
  - lib/active_harness/providers/custom.rb
74
75
  - lib/active_harness/providers/deepseek.rb
75
76
  - lib/active_harness/providers/gemini.rb