active_harness 0.3.0 → 0.3.1

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: 779258acc7a5947e923ac8f5ad262f653e96ad0509585f8a309e362bc0ee3703
4
- data.tar.gz: 1364d064abfe80ccdd5fdadd7bfb6d55396ec2ceaf7680d0bdd1373011cd9923
3
+ metadata.gz: 7de169d09a7a2e65620b93b57b9b760e8cf740342d35383deea404d1edf0f91e
4
+ data.tar.gz: 4a36da37f5953611eb9c9fe5ad6710381ec332e6ea49e1355af50540baab2fd0
5
5
  SHA512:
6
- metadata.gz: 94bf07f8a86b78da1f5507e02644c1a6bb2310074ea3b02eca476164480a546c286d12c699ee97743a4e8b9e1c52fd74f1fc1f33e90f1302c4235b0009e2bbd6
7
- data.tar.gz: 3355b0cb47da66faa4164f4712ba7f5e9e0e23d405a00177599ce26d34255ea3e80b29effa8f9fa7d5bfc34e6dfb45005e0a6281df70dfa847db965e7039e54e
6
+ metadata.gz: ab66c8538123326d8bbe8a634ee22420646cfd66f316ea503499585d058a521a5903b4e1487d29628117ddb4d7d8872839359f7977e5dda905ac7eb4c05453a4
7
+ data.tar.gz: 60e8a27f74ad023a94c039d09b374525fa571f4b2e09b15c42678c1acbd1ba16bd0a4012c1399f343f10eb24785af05ac4f102d11cf039580faa7381510a0f56
@@ -99,6 +99,12 @@ module ActiveHarness
99
99
  attr_accessor :azure_api_base # e.g. "https://my-resource.openai.azure.com"
100
100
  attr_accessor :azure_api_version # e.g. "2024-05-01-preview"
101
101
 
102
+ # -------------------------------------------------------------------------
103
+ # Vercel AI Gateway — TypeSafe-compatible evaluation endpoint (Jev)
104
+ # -------------------------------------------------------------------------
105
+ attr_accessor :vercel_api_key
106
+ attr_accessor :vercel_api_url
107
+
102
108
  # -------------------------------------------------------------------------
103
109
  # Custom providers
104
110
  #
@@ -170,6 +176,9 @@ module ActiveHarness
170
176
  @azure_ai_auth_token = ENV["AZURE_AI_AUTH_TOKEN"]
171
177
  @azure_api_base = ENV["AZURE_API_BASE"]
172
178
  @azure_api_version = ENV.fetch("AZURE_API_VERSION", "2024-05-01-preview")
179
+
180
+ @vercel_api_key = ENV["VERCEL_API_KEY"]
181
+ @vercel_api_url = "https://ai-gateway.vercel.sh/typesafe/v1/systemone"
173
182
  end
174
183
  end
175
184
  end
@@ -0,0 +1,120 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # Vercel AI Gateway — TypeSafe-compatible "System One" evaluation endpoint (Jev).
6
+ #
7
+ # This is fundamentally different from every other provider here: Jev does not
8
+ # take free-form chat messages and does not return free text. It evaluates a
9
+ # single `state` string against typed `questions` and returns typed answers
10
+ # (a score/probability/choice per question), e.g.:
11
+ #
12
+ # POST https://ai-gateway.vercel.sh/typesafe/v1/systemone
13
+ # { "model": "typesafe-ai/jev", "state": "...", "questions": { "answer": { "type": "noul", "instructions": "..." } } }
14
+ # => { "answers": { "answer": { "type": "noul", "noul": 0.8 } }, "usage": {...} }
15
+ #
16
+ # Question types are "noul" (0..1 probability, optionally scoped by a
17
+ # true/false `criteria` hash), "choice" (requires `criteria` — a name=>
18
+ # description hash), and "score" (requires `criteria` — an ordered array of
19
+ # at least 2 labels).
20
+ #
21
+ # To fit the standard Request(messages:) call shape without building a whole
22
+ # typed-questions DSL into Request itself, this bridges it minimally: the
23
+ # last user message becomes `state`, and — unless the model-chain entry
24
+ # supplies its own `questions:` hash (already in the API's own shape) — the
25
+ # system message (if any) becomes a single default "noul" question's
26
+ # `instructions`. Either way the raw `answers` payload is returned as-is
27
+ # (pretty JSON) as `content` — no interpretation of the typed answer is
28
+ # attempted.
29
+ #
30
+ # # simplest form — one implicit yes/no-ish question from the system prompt
31
+ # model do
32
+ # use provider: :vercel, model: "typesafe-ai/jev"
33
+ # end
34
+ #
35
+ # # explicit multi-question form — full control over the question set
36
+ # model do
37
+ # use provider: :vercel, model: "typesafe-ai/jev", questions: {
38
+ # sentiment: { type: "score", instructions: "...", criteria: ["negative", "neutral", "positive"] },
39
+ # topic: { type: "choice", instructions: "...", criteria: { support: "...", spam: "..." } },
40
+ # urgent: { type: "noul", instructions: "..." }
41
+ # }
42
+ # end
43
+ class Vercel < Base
44
+ DEFAULT_INSTRUCTIONS = "Evaluate the given state.".freeze
45
+
46
+ def call(model:, messages:, temperature: nil, stream: nil, questions: nil)
47
+ raise Errors::InvalidRequestError, "provider: :vercel (Jev) does not support token streaming" if stream
48
+
49
+ state = messages.reverse.find { |m| m[:role] == "user" }&.fetch(:content, nil).to_s
50
+
51
+ headers = {
52
+ "Content-Type" => "application/json",
53
+ "Authorization" => "Bearer #{api_key}"
54
+ }
55
+ body = {
56
+ model: model,
57
+ state: state,
58
+ questions: questions || default_questions(messages)
59
+ }
60
+
61
+ raw = post_json(URI(config.vercel_api_url), headers: headers, body: body)
62
+ data = parse!(raw)
63
+ handle_error!(data)
64
+
65
+ {
66
+ content: JSON.pretty_generate(data["answers"]),
67
+ provider: :vercel,
68
+ model: data["model"] || model,
69
+ usage: extract_usage(data)
70
+ }
71
+ end
72
+
73
+ private
74
+
75
+ def default_questions(messages)
76
+ instructions = messages.find { |m| m[:role] == "system" }&.fetch(:content, nil).to_s
77
+ instructions = DEFAULT_INSTRUCTIONS if instructions.empty?
78
+ { answer: { type: "noul", instructions: instructions } }
79
+ end
80
+
81
+ def api_key
82
+ key = config.vercel_api_key.to_s
83
+ raise Errors::InvalidApiKeyError, "vercel_api_key is not configured" if key.empty?
84
+ key
85
+ end
86
+
87
+ # Jev's usage object only has input/output tokens, OpenAI-shaped but
88
+ # under different keys than the chat-completions providers use.
89
+ def extract_usage(data)
90
+ u = data["usage"]
91
+ return nil unless u
92
+
93
+ input = u["input_tokens"].to_i
94
+ output = u["output_tokens"].to_i
95
+ { input_tokens: input, output_tokens: output, total_tokens: input + output }
96
+ end
97
+
98
+ # Two error shapes seen in practice:
99
+ # - TypeSafe request-validation errors: { "message": "...", "error_type": "..." }
100
+ # - AI Gateway account/billing errors (OpenAI-style, nested): { "error": { "message": "...", "type": "..." } }
101
+ def handle_error!(data)
102
+ msg, type =
103
+ if data["message"] && data["error_type"]
104
+ [data["message"].to_s, data["error_type"].to_s]
105
+ elsif data["error"].is_a?(Hash)
106
+ [data["error"]["message"].to_s, data["error"]["type"].to_s]
107
+ end
108
+ return unless msg
109
+
110
+ case type
111
+ when "invalid_request" then raise Errors::InvalidRequestError.new(msg, error_code: type)
112
+ when "unauthorized", "authentication_error" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
113
+ when "rate_limit", "rate_limit_error" then raise Errors::RateLimitError.new(msg, error_code: type)
114
+ when "customer_verification_required" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
115
+ else raise Errors::ProviderError.new(msg, error_code: type)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -134,7 +134,7 @@ module ActiveHarness
134
134
  @models = []
135
135
  end
136
136
 
137
- def use(provider:, model:, temperature: nil, name: nil, size: nil, quality: nil, retry_attempts: nil, retry_delay: nil)
137
+ def use(provider:, model:, temperature: nil, name: nil, size: nil, quality: nil, retry_attempts: nil, retry_delay: nil, questions: nil)
138
138
  @models << {
139
139
  provider: provider,
140
140
  model: model,
@@ -143,7 +143,8 @@ module ActiveHarness
143
143
  size: size,
144
144
  quality: quality,
145
145
  retry_attempts: retry_attempts,
146
- retry_delay: retry_delay
146
+ retry_delay: retry_delay,
147
+ questions: questions
147
148
  }.compact
148
149
  end
149
150
 
@@ -35,7 +35,8 @@ module ActiveHarness
35
35
  azure: -> { Providers::Azure.new },
36
36
  bedrock: -> { Providers::Bedrock.new },
37
37
  vertexai: -> { Providers::VertexAI.new },
38
- custom: -> { Providers::Custom.new }
38
+ custom: -> { Providers::Custom.new },
39
+ vercel: -> { Providers::Vercel.new }
39
40
  }.freeze
40
41
 
41
42
  IMAGE_PROVIDERS = {
@@ -61,6 +62,7 @@ module ActiveHarness
61
62
  opts[:temperature] = entry[:temperature] if entry[:temperature]
62
63
  opts[:stream] = @token if @token
63
64
  opts[:name] = entry[:name] if entry[:name]
65
+ opts[:questions] = entry[:questions] if entry[:questions]
64
66
  provider.call(**opts)
65
67
  end
66
68
 
@@ -21,6 +21,7 @@ require_relative "active_harness/providers/azure"
21
21
  require_relative "active_harness/providers/bedrock"
22
22
  require_relative "active_harness/providers/vertexai"
23
23
  require_relative "active_harness/providers/custom"
24
+ require_relative "active_harness/providers/vercel"
24
25
  require_relative "active_harness/providers/images/openai"
25
26
  require_relative "active_harness/providers/images/openrouter"
26
27
  require_relative "active_harness/providers/audio/openai"
@@ -34,7 +35,7 @@ require_relative "active_harness/pipeline"
34
35
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
35
36
 
36
37
  module ActiveHarness
37
- VERSION = "0.3.0"
38
+ VERSION = "0.3.1"
38
39
 
39
40
  class << self
40
41
  # Configure ActiveHarness.
@@ -43,11 +43,11 @@ module ActiveHarness
43
43
  def inject_routes
44
44
  route <<~ROUTES.strip
45
45
  # ActiveHarness — AI support endpoints
46
- post "ai/agent", to: "ai_support#agent"
47
- post "ai/agent_memory", to: "ai_support#agent_memory"
48
- post "ai/tribunal", to: "ai_support#tribunal"
49
- post "ai/pipeline", to: "ai_support#pipeline"
50
- get "ai/agent_stream", to: "ai_support#agent_stream"
46
+ post "ai/agent", to: "ai_support#agent" # not "request" — would override ActionController::Base#request
47
+ post "ai/request_memory", to: "ai_support#request_memory"
48
+ post "ai/tribunal", to: "ai_support#tribunal"
49
+ post "ai/pipeline", to: "ai_support#pipeline"
50
+ get "ai/request_stream", to: "ai_support#request_stream"
51
51
  ROUTES
52
52
  end
53
53
 
@@ -6,6 +6,10 @@ class AiSupportController < ApplicationController
6
6
  # ---------------------------------------------------------------------------
7
7
  # POST /ai/agent
8
8
  # body: { input: "What is your return policy?" }
9
+ #
10
+ # Kept named "agent" (not "request") — defining a #request method on a Rails
11
+ # controller would override ActionController::Base#request and break
12
+ # request.env / CSRF / anything else that relies on the real HTTP request.
9
13
  # ---------------------------------------------------------------------------
10
14
  def agent
11
15
  result = SupportRequest.call(input: params.require(:input))
@@ -18,13 +22,13 @@ class AiSupportController < ApplicationController
18
22
  end
19
23
 
20
24
  # ---------------------------------------------------------------------------
21
- # POST /ai/agent_memory
25
+ # POST /ai/request_memory
22
26
  # body: { input: "Does that apply to accessories?", session_id: "user_42" }
23
27
  #
24
28
  # Uses AppMemory so the same session keeps conversational context
25
29
  # across multiple requests.
26
30
  # ---------------------------------------------------------------------------
27
- def agent_memory
31
+ def request_memory
28
32
  memory = AppMemory.new(session_id: params.require(:session_id))
29
33
  result = SupportRequest.call(input: params.require(:input), memory: memory)
30
34
 
@@ -71,21 +75,21 @@ class AiSupportController < ApplicationController
71
75
  end
72
76
 
73
77
  # ---------------------------------------------------------------------------
74
- # GET /ai/agent_stream?input=What+is+your+return+policy%3F
78
+ # GET /ai/request_stream?input=What+is+your+return+policy%3F
75
79
  #
76
80
  # Streams the response token by token using Server-Sent Events.
77
81
  # Each token arrives as: data: {"token":"..."}
78
82
  # End of stream is marked: data: {"done":true}
79
83
  #
80
84
  # JavaScript client example:
81
- # const es = new EventSource('/ai/agent_stream?input=Hello');
85
+ # const es = new EventSource('/ai/request_stream?input=Hello');
82
86
  # es.onmessage = ({ data }) => {
83
87
  # const { token, done } = JSON.parse(data);
84
88
  # if (done) { es.close(); return; }
85
89
  # document.querySelector('#output').insertAdjacentText('beforeend', token);
86
90
  # };
87
91
  # ---------------------------------------------------------------------------
88
- def agent_stream
92
+ def request_stream
89
93
  input = params.require(:input)
90
94
 
91
95
  response.headers["Content-Type"] = "text/event-stream"
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.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
@@ -80,6 +80,7 @@ files:
80
80
  - lib/active_harness/providers/openai.rb
81
81
  - lib/active_harness/providers/openrouter.rb
82
82
  - lib/active_harness/providers/perplexity.rb
83
+ - lib/active_harness/providers/vercel.rb
83
84
  - lib/active_harness/providers/vertexai.rb
84
85
  - lib/active_harness/providers/xai.rb
85
86
  - lib/active_harness/railtie.rb