Files
sure/test/models/provider/openai_test.rb
T
Joe Maples cc826df909 feat(ai): support OPENAI_EXTRA_HEADERS on the OpenAI-compatible provider (#3362)
* feat(ai): support OPENAI_EXTRA_HEADERS on OpenAI-compatible provider

Adds a fail-closed parser for the OPENAI_EXTRA_HEADERS env var (a JSON
object of header names to values) as a new Provider::Openai.extra_headers
class method. Malformed, non-object, blank, or unset values yield {}
with an error log and never the raw value, so chat keeps working on bad
config. Parsed headers are passed to the ruby-openai client at
construction, attaching them to every request the provider's client
makes (chat and batch flows alike).

ENV-only by design: no Setting fallback or settings-UI entry. Values are
stringified (nested JSON becomes Ruby-inspect strings) and blank values
are dropped.

Adds hosting docs and commented examples in .env.example and
.env.local.example, plus Minitest coverage mirroring the request_timeout
tests, including a docs-consistency test binding the knob to its docs.

* feat(ai): substitute {session_id} in OPENAI_EXTRA_HEADERS per chat request

Header values containing the literal {session_id} are now withheld at
client construction and merged onto the client at request time, with the
placeholder replaced by the chat's UUID. This identifies requests per
conversation rather than per install, for gateways that key sessions
(e.g. OpenCode Zen's x-opencode-session).

A session header is only merged when a session_id is present, so batch
flows (auto-categorize, merchant detection, PDF processing) — which
bypass chat_response — never send it; they receive static headers only.
The merge adds/overwrites without deleting managed headers.

Docs updated to cover both static and session-valued usage.

* fix(ai): keep OPENAI_EXTRA_HEADERS session values request-scoped

client.add_headers persists headers on the shared client in
ruby-openai 8.1.0, so a chat's resolved session header could survive
onto later requests made through the same provider instance. Session
headers are now merged onto a request-scoped dup of the client; the
shared client is never mutated. Batch flows and session-less chats
cannot observe another chat's session id.

Also updates the CodeRabbit-flagged tests to assert the shared client
stays untouched and the scoped copy is what issues the chat request.

* docs(ai): add YARD tags to OPENAI_EXTRA_HEADERS method docs

Converts the comment blocks on the four methods touched by this
feature (extra_headers, initialize, request_timeout, and
with_session_headers) into YARD docstrings with @param/@return tags,
satisfying CodeRabbit's docstring-coverage pre-merge check.
2026-09-03 21:38:23 +02:00

733 lines
27 KiB
Ruby

require "test_helper"
class Provider::OpenaiTest < ActiveSupport::TestCase
include LLMInterfaceTest
setup do
@subject = @openai = Provider::Openai.new(ENV.fetch("OPENAI_ACCESS_TOKEN", "test-openai-token"))
@subject_model = "gpt-4.1"
end
test "request_timeout uses ENV then Setting then default" do
Setting.stubs(:openai_request_timeout).returns(nil)
with_env_overrides("OPENAI_REQUEST_TIMEOUT" => nil) do
assert_equal Provider::Openai::DEFAULT_REQUEST_TIMEOUT, Provider::Openai.request_timeout
end
Setting.stubs(:openai_request_timeout).returns(180)
with_env_overrides("OPENAI_REQUEST_TIMEOUT" => nil) do
assert_equal 180, Provider::Openai.request_timeout
end
Setting.stubs(:openai_request_timeout).returns(180)
with_env_overrides("OPENAI_REQUEST_TIMEOUT" => "300") do
assert_equal 300, Provider::Openai.request_timeout
end
end
test "request_timeout is passed to OpenAI client" do
with_env_overrides("OPENAI_REQUEST_TIMEOUT" => nil, "OPENAI_EXTRA_HEADERS" => nil) do
Setting.stubs(:openai_request_timeout).returns(180)
::OpenAI::Client.expects(:new).with(access_token: "test-token", request_timeout: 180).returns(mock)
Provider::Openai.new("test-token")
end
end
test "extra_headers parses valid JSON, stringifies keys and values, drops blanks" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => '{"x-session": "abc", "Retry": 3, "empty": "", "drop": null}') do
assert_equal({ "x-session" => "abc", "Retry" => "3" }, Provider::Openai.extra_headers)
end
end
test "extra_headers stringifies nested values Ruby-style" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => '{"x-meta":{"a":1}}') do
assert_equal({ "x-meta" => '{"a" => 1}' }, Provider::Openai.extra_headers)
end
end
test "extra_headers returns empty hash for unset, blank, malformed, and non-object JSON" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => nil) do
assert_equal({}, Provider::Openai.extra_headers)
end
with_env_overrides("OPENAI_EXTRA_HEADERS" => " ") do
assert_equal({}, Provider::Openai.extra_headers)
end
with_env_overrides("OPENAI_EXTRA_HEADERS" => "{not json") do
Rails.logger.expects(:error).with(regexp_matches(/OPENAI_EXTRA_HEADERS is not valid JSON/))
assert_equal({}, Provider::Openai.extra_headers)
end
with_env_overrides("OPENAI_EXTRA_HEADERS" => "[1,2]") do
Rails.logger.expects(:error).with(regexp_matches(/must be a JSON object/))
assert_equal({}, Provider::Openai.extra_headers)
end
end
test "client construction receives parsed extra_headers" do
with_env_overrides(
"OPENAI_REQUEST_TIMEOUT" => nil,
"OPENAI_EXTRA_HEADERS" => '{"X-Static":"1"}'
) do
Setting.stubs(:openai_request_timeout).returns(nil)
::OpenAI::Client.expects(:new).with(
access_token: "test-token",
request_timeout: 60,
extra_headers: { "X-Static" => "1" }
).returns(mock)
Provider::Openai.new("test-token")
end
end
test "extra headers knob is documented in hosting docs and env examples" do
doc = Rails.root.join("docs/hosting/ai.md").read
assert_includes doc, "OPENAI_EXTRA_HEADERS"
assert_includes doc, "{session_id}"
assert_includes Rails.root.join(".env.example").read, "OPENAI_EXTRA_HEADERS"
assert_includes Rails.root.join(".env.local.example").read, "OPENAI_EXTRA_HEADERS"
end
test "client construction receives static extra_headers only; session values withheld" do
with_env_overrides(
"OPENAI_REQUEST_TIMEOUT" => nil,
"OPENAI_EXTRA_HEADERS" => '{"X-Static":"1","x-opencode-session":"sess-{session_id}"}'
) do
Setting.stubs(:openai_request_timeout).returns(nil)
::OpenAI::Client.expects(:new).with(
access_token: "test-token",
request_timeout: 60,
extra_headers: { "X-Static" => "1" }
).returns(mock)
Provider::Openai.new("test-token")
end
end
test "session headers substitute the chat id onto a request-scoped client copy" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => '{"x-opencode-session":"sess-{session_id}","X-Static":"1"}') do
subject = Provider::Openai.new("test-token")
fake_client = mock
scoped_client = mock
fake_client.expects(:dup).returns(scoped_client)
scoped_client.expects(:add_headers).with({ "x-opencode-session" => "sess-chat-42" })
subject.stubs(:client).returns(fake_client)
assert_same scoped_client, subject.send(:with_session_headers, session_id: "chat-42")
end
end
test "session headers are absent from the request when no session_id is given" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => '{"x-opencode-session":"sess-{session_id}"}') do
subject = Provider::Openai.new("test-token")
fake_client = mock
fake_client.expects(:dup).never
fake_client.expects(:add_headers).never
subject.stubs(:client).returns(fake_client)
assert_same subject.send(:client), subject.send(:with_session_headers, session_id: nil)
end
end
test "session headers never persist onto the shared client after a chat request" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => '{"x-opencode-session":"sess-{session_id}"}') do
subject = Provider::Openai.new("test-token")
scoped_client = mock
fake_client = mock
fake_client.expects(:dup).returns(scoped_client)
scoped_client.expects(:add_headers).with({ "x-opencode-session" => "sess-chat-42" })
fake_client.expects(:responses).never
subject.stubs(:client).returns(fake_client)
scoped = subject.send(:with_session_headers, session_id: "chat-42")
assert_same scoped_client, scoped
assert_same fake_client, subject.send(:client)
end
end
test "native chat path resolves session headers onto a request-scoped client" do
with_env_overrides("OPENAI_EXTRA_HEADERS" => '{"x-opencode-session":"sess-{session_id}"}') do
subject = Provider::Openai.new("test-token")
fake_responses = mock
scoped_client = mock
scoped_client.stubs(:responses).returns(fake_responses)
fake_client = mock
fake_client.stubs(:dup).returns(scoped_client)
scoped_client.expects(:add_headers).with({ "x-opencode-session" => "sess-chat-42" })
fake_responses.stubs(:create).returns(
{ "id" => "resp_1", "model" => "gpt-4.1", "output" => [], "usage" => { "total_tokens" => 1 } }
)
subject.stubs(:client).returns(fake_client)
response = subject.chat_response("hi", model: "gpt-4.1", session_id: "chat-42")
assert response.success?
end
end
test "generic chat path resolves session headers onto a request-scoped client" do
with_env_overrides(
"OPENAI_SUPPORTS_RESPONSES_ENDPOINT" => "false",
"OPENAI_EXTRA_HEADERS" => '{"x-opencode-session":"sess-{session_id}"}'
) do
subject = Provider::Openai.new("test-token")
scoped_client = mock
fake_client = mock
fake_client.stubs(:dup).returns(scoped_client)
scoped_client.expects(:add_headers).with({ "x-opencode-session" => "sess-chat-42" })
scoped_client.stubs(:chat).returns(
{ "id" => "resp_1", "model" => "gpt-4.1", "choices" => [ { "message" => { "content" => "Yes" } } ],
"usage" => { "total_tokens" => 1 } }
)
subject.stubs(:client).returns(fake_client)
response = subject.chat_response("hi", model: "gpt-4.1", session_id: "chat-42")
assert response.success?
assert_equal "Yes", response.data.messages.first.output_text
end
end
test "openai errors are automatically raised" do
VCR.use_cassette("openai/chat/error") do
response = @openai.chat_response("Test", model: "invalid-model-that-will-trigger-api-error")
assert_not response.success?
assert_kind_of Provider::Openai::Error, response.error
end
end
test "auto categorizes transactions by various attributes" do
VCR.use_cassette("openai/auto_categorize") do
input_transactions = [
{ id: "1", name: "McDonalds", amount: 20, classification: "expense", merchant: "McDonalds", hint: "Fast Food" },
{ id: "2", name: "Amazon purchase", amount: 100, classification: "expense", merchant: "Amazon" },
{ id: "3", name: "Netflix subscription", amount: 10, classification: "expense", merchant: "Netflix", hint: "Subscriptions" },
{ id: "4", name: "paycheck", amount: 3000, classification: "income" },
{ id: "5", name: "Italian dinner with friends", amount: 100, classification: "expense" },
{ id: "6", name: "1212XXXBCaaa charge", amount: 2.99, classification: "expense" }
]
response = @subject.auto_categorize(
transactions: input_transactions,
user_categories: [
{ id: "shopping_id", name: "Shopping", is_subcategory: false, parent_id: nil, classification: "expense" },
{ id: "subscriptions_id", name: "Subscriptions", is_subcategory: true, parent_id: nil, classification: "expense" },
{ id: "restaurants_id", name: "Restaurants", is_subcategory: false, parent_id: nil, classification: "expense" },
{ id: "fast_food_id", name: "Fast Food", is_subcategory: true, parent_id: "restaurants_id", classification: "expense" },
{ id: "income_id", name: "Income", is_subcategory: false, parent_id: nil, classification: "income" }
]
)
assert response.success?
assert_equal input_transactions.size, response.data.size
txn1 = response.data.find { |c| c.transaction_id == "1" }
txn2 = response.data.find { |c| c.transaction_id == "2" }
txn3 = response.data.find { |c| c.transaction_id == "3" }
txn4 = response.data.find { |c| c.transaction_id == "4" }
txn5 = response.data.find { |c| c.transaction_id == "5" }
txn6 = response.data.find { |c| c.transaction_id == "6" }
assert_equal "Fast Food", txn1.category_name
assert_equal "Shopping", txn2.category_name
assert_equal "Subscriptions", txn3.category_name
assert_equal "Income", txn4.category_name
assert_equal "Restaurants", txn5.category_name
assert_nil txn6.category_name
end
end
test "auto detects merchants" do
VCR.use_cassette("openai/auto_detect_merchants") do
input_transactions = [
{ id: "1", name: "McDonalds", amount: 20, classification: "expense" },
{ id: "2", name: "local pub", amount: 20, classification: "expense" },
{ id: "3", name: "WMT purchases", amount: 20, classification: "expense" },
{ id: "4", name: "amzn 123 abc", amount: 20, classification: "expense" },
{ id: "5", name: "chaseX1231", amount: 2000, classification: "income" },
{ id: "6", name: "check deposit 022", amount: 200, classification: "income" },
{ id: "7", name: "shooters bar and grill", amount: 200, classification: "expense" },
{ id: "8", name: "Microsoft Office subscription", amount: 200, classification: "expense" }
]
response = @subject.auto_detect_merchants(
transactions: input_transactions,
user_merchants: [ { name: "Shooters" } ]
)
assert response.success?
assert_equal input_transactions.size, response.data.size
txn1 = response.data.find { |c| c.transaction_id == "1" }
txn2 = response.data.find { |c| c.transaction_id == "2" }
txn3 = response.data.find { |c| c.transaction_id == "3" }
txn4 = response.data.find { |c| c.transaction_id == "4" }
txn5 = response.data.find { |c| c.transaction_id == "5" }
txn6 = response.data.find { |c| c.transaction_id == "6" }
txn7 = response.data.find { |c| c.transaction_id == "7" }
txn8 = response.data.find { |c| c.transaction_id == "8" }
assert_equal "McDonald's", txn1.business_name
assert_equal "mcdonalds.com", txn1.business_url
assert_nil txn2.business_name
assert_nil txn2.business_url
assert_equal "Walmart", txn3.business_name
assert_equal "walmart.com", txn3.business_url
assert_equal "Amazon", txn4.business_name
assert_equal "amazon.com", txn4.business_url
assert_nil txn5.business_name
assert_nil txn5.business_url
assert_nil txn6.business_name
assert_nil txn6.business_url
assert_equal "Shooters", txn7.business_name
assert_nil txn7.business_url
assert_equal "Microsoft", txn8.business_name
assert_equal "microsoft.com", txn8.business_url
end
end
test "basic chat response" do
VCR.use_cassette("openai/chat/basic_response") do
response = @subject.chat_response(
"This is a chat test. If it's working, respond with a single word: Yes",
model: @subject_model
)
assert response.success?
assert_equal 1, response.data.messages.size
assert_includes response.data.messages.first.output_text, "Yes"
end
end
test "streams basic chat response" do
VCR.use_cassette("openai/chat/basic_streaming_response") do
collected_chunks = []
mock_streamer = proc do |chunk|
collected_chunks << chunk
end
response = @subject.chat_response(
"This is a chat test. If it's working, respond with a single word: Yes",
model: @subject_model,
streamer: mock_streamer
)
text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" }
response_chunks = collected_chunks.select { |chunk| chunk.type == "response" }
assert_equal 1, text_chunks.size
assert_equal 1, response_chunks.size
assert_equal "Yes", text_chunks.first.data
assert_equal "Yes", response_chunks.first.data.messages.first.output_text
assert_equal response_chunks.first.data, response.data
end
end
test "chat response with function calls" do
VCR.use_cassette("openai/chat/function_calls") do
prompt = "What is my net worth?"
functions = [
{
name: "get_net_worth",
description: "Gets a user's net worth",
params_schema: { type: "object", properties: {}, required: [], additionalProperties: false },
strict: true
}
]
first_response = @subject.chat_response(
prompt,
model: @subject_model,
instructions: "Use the tools available to you to answer the user's question.",
functions: functions
)
assert first_response.success?
function_request = first_response.data.function_requests.first
assert function_request.present?
second_response = @subject.chat_response(
prompt,
model: @subject_model,
function_results: [ {
call_id: function_request.call_id,
output: { amount: 10000, currency: "USD" }.to_json
} ],
previous_response_id: first_response.data.id
)
assert second_response.success?
assert_equal 1, second_response.data.messages.size
assert_includes second_response.data.messages.first.output_text, "$10,000"
end
end
test "streams chat response with function calls" do
VCR.use_cassette("openai/chat/streaming_function_calls") do
collected_chunks = []
mock_streamer = proc do |chunk|
collected_chunks << chunk
end
prompt = "What is my net worth?"
functions = [
{
name: "get_net_worth",
description: "Gets a user's net worth",
params_schema: { type: "object", properties: {}, required: [], additionalProperties: false },
strict: true
}
]
# Call #1: First streaming call, will return a function request
@subject.chat_response(
prompt,
model: @subject_model,
instructions: "Use the tools available to you to answer the user's question.",
functions: functions,
streamer: mock_streamer
)
text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" }
response_chunks = collected_chunks.select { |chunk| chunk.type == "response" }
assert_equal 0, text_chunks.size
assert_equal 1, response_chunks.size
first_response = response_chunks.first.data
function_request = first_response.function_requests.first
# Reset collected chunks for the second call
collected_chunks = []
# Call #2: Second streaming call, will return a function result
@subject.chat_response(
prompt,
model: @subject_model,
function_results: [
{
call_id: function_request.call_id,
output: { amount: 10000, currency: "USD" }
}
],
previous_response_id: first_response.id,
streamer: mock_streamer
)
text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" }
response_chunks = collected_chunks.select { |chunk| chunk.type == "response" }
assert text_chunks.size >= 1
assert_equal 1, response_chunks.size
assert_includes response_chunks.first.data.messages.first.output_text, "$10,000"
end
end
test "provider_name returns OpenAI for standard provider" do
assert_equal "OpenAI", @subject.provider_name
end
test "provider_name returns custom info for custom provider" do
custom_provider = Provider::Openai.new(
"test-token",
uri_base: "https://custom-api.example.com/v1",
model: "custom-model"
)
assert_equal "Custom OpenAI-compatible (https://custom-api.example.com/v1)", custom_provider.provider_name
end
test "supported_models_description returns model prefixes for standard provider" do
expected = "models starting with: gpt-4, gpt-5, o1, o3"
assert_equal expected, @subject.supported_models_description
end
test "supported_models_description returns configured model for custom provider" do
custom_provider = Provider::Openai.new(
"test-token",
uri_base: "https://custom-api.example.com/v1",
model: "custom-model"
)
assert_equal "configured model: custom-model", custom_provider.supported_models_description
end
test "upsert_langfuse_trace uses client trace upsert" do
trace = Struct.new(:id).new("trace_123")
fake_client = mock
fake_client.expects(:trace).with(id: "trace_123", output: { ok: true }, level: "ERROR")
@subject.stubs(:langfuse_client).returns(fake_client)
@subject.send(:upsert_langfuse_trace, trace: trace, output: { ok: true }, level: "ERROR")
end
test "log_langfuse_generation upserts trace through client" do
trace = Struct.new(:id).new("trace_456")
generation = mock
fake_client = mock
@subject.stubs(:langfuse_client).returns(fake_client)
@subject.stubs(:create_langfuse_trace).returns(trace)
fake_client.expects(:trace).with(id: "trace_456", output: "hello")
trace.expects(:generation).returns(generation)
generation.expects(:end).with(output: "hello", usage: { "total_tokens" => 10 })
@subject.send(
:log_langfuse_generation,
name: "chat",
model: "gpt-4.1",
input: { prompt: "Hi" },
output: "hello",
usage: { "total_tokens" => 10 }
)
end
test "create_langfuse_trace logs full error details" do
fake_client = mock
error = StandardError.new("boom")
@subject.stubs(:langfuse_client).returns(fake_client)
fake_client.expects(:trace).raises(error)
Rails.logger.expects(:warn).with(regexp_matches(/Langfuse trace creation failed: boom.*test\/models\/provider\/openai_test\.rb/m))
@subject.send(:create_langfuse_trace, name: "openai.test", input: { foo: "bar" })
end
test "SUPPORTED_MODELS and VISION_CAPABLE_MODEL_PREFIXES are Ruby constants, not YAML-derived" do
assert_kind_of Array, Provider::Openai::SUPPORTED_MODELS
assert Provider::Openai::SUPPORTED_MODELS.all? { |s| s.is_a?(String) }
assert Provider::Openai::SUPPORTED_MODELS.frozen?
assert_kind_of Array, Provider::Openai::VISION_CAPABLE_MODEL_PREFIXES
assert Provider::Openai::VISION_CAPABLE_MODEL_PREFIXES.frozen?
assert_equal "gpt-4.1", Provider::Openai::DEFAULT_MODEL
end
test "budget readers default to conservative values" do
with_env_overrides(
"LLM_CONTEXT_WINDOW" => nil,
"LLM_MAX_RESPONSE_TOKENS" => nil,
"LLM_SYSTEM_PROMPT_RESERVE" => nil,
"LLM_MAX_HISTORY_TOKENS" => nil,
"LLM_MAX_ITEMS_PER_CALL" => nil
) do
subject = Provider::Openai.new("test-token")
assert_equal 2048, subject.context_window
assert_equal 512, subject.max_response_tokens
assert_equal 256, subject.system_prompt_reserve
assert_equal 2048 - 512 - 256, subject.max_history_tokens
assert_equal 2048 - 512 - 256, subject.max_input_tokens
assert_equal 25, subject.max_items_per_call
end
end
test "budget readers respect explicit env overrides" do
with_env_overrides(
"LLM_CONTEXT_WINDOW" => "8192",
"LLM_MAX_RESPONSE_TOKENS" => "1024",
"LLM_SYSTEM_PROMPT_RESERVE" => "512",
"LLM_MAX_HISTORY_TOKENS" => "4096",
"LLM_MAX_ITEMS_PER_CALL" => "50"
) do
subject = Provider::Openai.new("test-token")
assert_equal 8192, subject.context_window
assert_equal 1024, subject.max_response_tokens
assert_equal 512, subject.system_prompt_reserve
assert_equal 4096, subject.max_history_tokens # explicit overrides derived
assert_equal 8192 - 1024 - 512, subject.max_input_tokens
assert_equal 50, subject.max_items_per_call
end
end
test "history budget subtracts the real instructions estimate when given" do
Setting.stubs(:llm_max_response_tokens).returns(nil)
with_env_overrides(
"LLM_CONTEXT_WINDOW" => "8192",
"LLM_MAX_RESPONSE_TOKENS" => nil,
"LLM_SYSTEM_PROMPT_RESERVE" => nil,
"LLM_MAX_HISTORY_TOKENS" => nil
) do
subject = Provider::Openai.new("test-token")
instructions = "a" * 4000
estimate = Assistant::TokenEstimator.estimate(instructions)
assert_equal 8192 - 512 - estimate, subject.max_history_tokens(instructions: instructions)
# Without instructions the flat reserve still applies
assert_equal 8192 - 512 - 256, subject.max_history_tokens
end
end
test "response cap is only sent when explicitly configured" do
with_env_overrides("LLM_MAX_RESPONSE_TOKENS" => nil) do
Setting.stubs(:llm_max_response_tokens).returns(nil)
subject = Provider::Openai.new("test-token")
assert_nil subject.explicit_max_response_tokens
end
with_env_overrides("LLM_MAX_RESPONSE_TOKENS" => nil) do
Setting.stubs(:llm_max_response_tokens).returns(768)
subject = Provider::Openai.new("test-token")
assert_equal 768, subject.explicit_max_response_tokens
end
with_env_overrides("LLM_MAX_RESPONSE_TOKENS" => "900") do
subject = Provider::Openai.new("test-token")
assert_equal 900, subject.explicit_max_response_tokens
end
end
test "budget readers fall back to Setting when ENV unset" do
with_env_overrides(
"LLM_CONTEXT_WINDOW" => nil,
"LLM_MAX_RESPONSE_TOKENS" => nil,
"LLM_MAX_ITEMS_PER_CALL" => nil
) do
Setting.llm_context_window = 8192
Setting.llm_max_response_tokens = 1024
Setting.llm_max_items_per_call = 40
subject = Provider::Openai.new("test-token")
assert_equal 8192, subject.context_window
assert_equal 1024, subject.max_response_tokens
assert_equal 40, subject.max_items_per_call
end
ensure
Setting.llm_context_window = nil
Setting.llm_max_response_tokens = nil
Setting.llm_max_items_per_call = nil
end
test "budget readers: ENV beats Setting when both present" do
with_env_overrides("LLM_CONTEXT_WINDOW" => "16384") do
Setting.llm_context_window = 4096
subject = Provider::Openai.new("test-token")
assert_equal 16384, subject.context_window
end
ensure
Setting.llm_context_window = nil
end
test "budget readers: zero or negative values fall through to default" do
with_env_overrides(
"LLM_CONTEXT_WINDOW" => "0",
"LLM_MAX_RESPONSE_TOKENS" => nil,
"LLM_MAX_ITEMS_PER_CALL" => nil
) do
Setting.llm_context_window = 0
subject = Provider::Openai.new("test-token")
assert_equal 2048, subject.context_window
end
ensure
Setting.llm_context_window = nil
end
test "auto_categorize fans out oversized batches into sequential sub-calls" do
with_env_overrides("LLM_MAX_ITEMS_PER_CALL" => "10") do
subject = Provider::Openai.new("test-token")
transactions = Array.new(25) { |i| { id: i.to_s, name: "txn#{i}", amount: 10, classification: "expense" } }
user_categories = [ { id: "cat1", name: "Groceries", is_subcategory: false, parent_id: nil, classification: "expense" } ]
# Capture the batch size passed to each AutoCategorizer. `.new` is called
# once per sub-batch; we record each invocation's transactions count.
seen_sizes = []
fake_instance = mock
fake_instance.stubs(:auto_categorize).returns([])
Provider::Openai::AutoCategorizer.stubs(:new).with do |*_args, **kwargs|
seen_sizes << kwargs[:transactions].size
true
end.returns(fake_instance)
response = subject.auto_categorize(transactions: transactions, user_categories: user_categories)
assert response.success?
assert_equal [ 10, 10, 5 ], seen_sizes
end
end
test "streaming surfaces a useful error when the stream ends with response.failed and no completion" do
fake_responses = mock
fake_client = mock
fake_client.stubs(:responses).returns(fake_responses)
@subject.stubs(:client).returns(fake_client)
fake_responses.expects(:create).with do |*_args, **kwargs|
stream = kwargs.dig(:parameters, :stream)
stream.call({
"type" => "response.failed",
"response" => {
"error" => { "message" => "Previous response not found", "code" => "previous_response_not_found" }
}
})
true
end.returns(nil)
response = @subject.chat_response(
"hi",
model: @subject_model,
streamer: proc { |_| }
)
assert_not response.success?
assert_kind_of Provider::Openai::Error, response.error
assert_match(/Previous response not found/, response.error.message)
assert_match(/previous_response_not_found/, response.error.message)
end
test "streaming surfaces a useful error when the stream ends with no response and no error event" do
fake_responses = mock
fake_client = mock
fake_client.stubs(:responses).returns(fake_responses)
@subject.stubs(:client).returns(fake_client)
fake_responses.expects(:create).returns(nil)
response = @subject.chat_response(
"hi",
model: @subject_model,
streamer: proc { |_| }
)
assert_not response.success?
assert_kind_of Provider::Openai::Error, response.error
assert_match(/stream ended without a completion event/i, response.error.message)
end
test "build_input no longer accepts inline messages history" do
config = Provider::Openai::ChatConfig.new(functions: [], function_results: [])
# Positive control: prompt works
result = config.build_input(prompt: "hi")
assert_equal [ { role: "user", content: "hi" } ], result
# `messages:` kwarg is no longer part of the signature — calling with it must raise
assert_raises(ArgumentError) do
config.build_input(prompt: "hi", messages: [ { role: "user", content: "old" } ])
end
end
end