Compare commits

...
Author SHA1 Message Date
aakashb95 93d4e1ca0e feat(llm): add Responses API client 2026-07-10 23:19:07 +05:30
aakashb95 090ca53bde feat(llm): configure GPT-5.6 reasoning defaults 2026-07-10 23:11:25 +05:30
16 changed files with 713 additions and 37 deletions
+1 -1
View File
@@ -189,7 +189,7 @@
type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-5.6-luna'
locked: false
- name: CAPTAIN_OPEN_AI_ENDPOINT
display_title: 'OpenAI API Endpoint (optional)'
+67 -12
View File
@@ -35,6 +35,18 @@ models:
provider: openai
display_name: 'GPT-5.2'
credit_multiplier: 3
gpt-5.6-luna:
provider: openai
display_name: 'GPT-5.6 Luna'
credit_multiplier: 2
gpt-5.6-terra:
provider: openai
display_name: 'GPT-5.6 Terra'
credit_multiplier: 3
gpt-5.6-sol:
provider: openai
display_name: 'GPT-5.6 Sol'
credit_multiplier: 5
claude-haiku-4.5:
provider: anthropic
display_name: 'Claude Haiku 4.5'
@@ -78,11 +90,14 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
claude-haiku-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
default: gpt-5.6-luna
reasoning_effort: low
assistant:
models:
[
@@ -91,12 +106,16 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.6-terra
reasoning_effort: medium
copilot:
models:
[
@@ -105,16 +124,28 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.6-terra
reasoning_effort: medium
label_suggestion:
models:
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
default: gpt-4.1-mini
[
gpt-4.1-nano,
gpt-4.1-mini,
gpt-5-mini,
gpt-5.6-luna,
gemini-3-flash,
claude-haiku-4.5,
]
default: gpt-5.6-luna
reasoning_effort: low
document_faq_generation:
models:
[
@@ -123,12 +154,16 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
default: gpt-5.6-terra
reasoning_effort: medium
conversation_faq_generation:
models:
[
@@ -137,15 +172,20 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
default: gpt-5.6-terra
reasoning_effort: medium
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
reasoning_effort: medium
help_center_article_generation:
models:
[
@@ -154,19 +194,34 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
default: gpt-5.6-terra
reasoning_effort: medium
onboarding_content_generation:
models:
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
default: gpt-4.1
[
gpt-4.1,
gpt-4.1-mini,
gpt-5-mini,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
]
default: gpt-5.6-terra
reasoning_effort: medium
help_center_query_translation:
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
default: gpt-4.1-nano
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gpt-5.6-luna]
default: gpt-5.6-luna
reasoning_effort: low
audio_transcription:
models: [gpt-4o-mini-transcribe, whisper-1]
default: gpt-4o-mini-transcribe
+160 -1
View File
@@ -27970,6 +27970,165 @@
"owned_by": "system"
}
},
{
"id": "gpt-5.6-luna",
"name": "GPT-5.6 Luna",
"provider": "openai",
"family": "gpt",
"created_at": null,
"context_window": 1050000,
"max_output_tokens": 128000,
"knowledge_cutoff": "2026-02-16",
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"capabilities": [
"function_calling",
"structured_output",
"reasoning",
"vision"
],
"pricing": {
"text_tokens": {
"standard": {
"input_per_million": 1,
"output_per_million": 6
}
}
},
"metadata": {
"object": "model",
"owned_by": "system",
"source": "openai_docs",
"provider_id": "openai",
"open_weights": false,
"attachment": true,
"temperature": false,
"last_updated": "2026-07-10",
"cost": {
"input": 1,
"output": 6
},
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"knowledge": "2026-02-16"
}
},
{
"id": "gpt-5.6-terra",
"name": "GPT-5.6 Terra",
"provider": "openai",
"family": "gpt",
"created_at": null,
"context_window": 1050000,
"max_output_tokens": 128000,
"knowledge_cutoff": "2026-02-16",
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"capabilities": [
"function_calling",
"structured_output",
"reasoning",
"vision"
],
"pricing": {
"text_tokens": {
"standard": {
"input_per_million": 2.5,
"output_per_million": 15
}
}
},
"metadata": {
"object": "model",
"owned_by": "system",
"source": "openai_docs",
"provider_id": "openai",
"open_weights": false,
"attachment": true,
"temperature": false,
"last_updated": "2026-07-10",
"cost": {
"input": 2.5,
"output": 15
},
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"knowledge": "2026-02-16"
}
},
{
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"provider": "openai",
"family": "gpt",
"created_at": null,
"context_window": 1050000,
"max_output_tokens": 128000,
"knowledge_cutoff": "2026-02-16",
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"capabilities": [
"function_calling",
"structured_output",
"reasoning",
"vision"
],
"pricing": {
"text_tokens": {
"standard": {
"input_per_million": 5,
"output_per_million": 30
}
}
},
"metadata": {
"object": "model",
"owned_by": "system",
"source": "openai_docs",
"provider_id": "openai",
"open_weights": false,
"attachment": true,
"temperature": false,
"last_updated": "2026-07-10",
"cost": {
"input": 5,
"output": 30
},
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"knowledge": "2026-02-16"
}
},
{
"id": "gpt-audio",
"name": "gpt-audio",
@@ -61509,4 +61668,4 @@
"owned_by": "xai"
}
}
]
]
+1 -1
View File
@@ -1,7 +1,7 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_MODEL = 'gpt-5.6-luna'.freeze
class << self
def initialized?
+2 -1
View File
@@ -1,7 +1,7 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.6-terra'.freeze
class << self
def resolve(feature:, account: nil)
@@ -17,6 +17,7 @@ module Llm::FeatureRouter
feature: feature_key,
provider: Llm::Models.provider_for(model),
model: model,
reasoning_effort: Llm::Models.reasoning_effort_for(feature_key),
source: source
}
end
+6 -1
View File
@@ -15,6 +15,10 @@ module Llm::Models
features.dig(feature.to_s, 'default')
end
def reasoning_effort_for(feature)
features.dig(feature.to_s, 'reasoning_effort')
end
def models_for(feature)
features.dig(feature.to_s, 'models') || []
end
@@ -46,7 +50,8 @@ module Llm::Models
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
default: feature['default'],
reasoning_effort: feature['reasoning_effort']
}
end
end
+209
View File
@@ -0,0 +1,209 @@
require 'openai'
require 'ruby_llm/tool'
class Llm::ResponsesClient
DEFAULT_STORE = false
DEFAULT_TEXT_FORMAT = { type: 'text' }.freeze
SYSTEM_ROLES = %w[system developer].freeze
def initialize(api_key:, api_base: nil, client: nil)
@client = client || OpenAI::Client.new(access_token: api_key, uri_base: normalized_api_base(api_base))
end
def create(model:, messages:, reasoning_effort: nil, schema: nil, tools: [], metadata: {}, store: DEFAULT_STORE, **options)
payload = build_payload(
model: model,
messages: messages,
reasoning_effort: reasoning_effort,
schema: schema,
tools: tools,
metadata: metadata,
store: store,
options: options
)
return payload if payload[:error]
build_response(@client.json_post(path: '/responses', parameters: payload), request_messages: messages)
end
def build_payload(model:, messages:, reasoning_effort: nil, schema: nil, tools: [], metadata: {}, store: DEFAULT_STORE, options: {})
input_messages = conversation_messages(messages)
return no_conversation_payload(messages) if input_messages.empty?
payload = {
model: model,
input: input_messages,
store: store,
text: { format: text_format(schema) }
}
instructions = instructions_from(messages)
payload[:instructions] = instructions if instructions.present?
payload[:reasoning] = { effort: reasoning_effort } if reasoning_effort.present?
payload[:tools] = normalize_tools(tools) if tools.present?
payload[:metadata] = metadata if metadata.present?
payload.merge!(options.compact)
payload
end
def self.extract_output_text(response)
Array(response['output']).filter_map do |item|
next unless item['type'] == 'message'
Array(item['content']).filter_map do |content|
content['text'] if content['type'] == 'output_text'
end.join
end.join
end
def self.extract_function_calls(response)
Array(response['output']).filter_map do |item|
next unless item['type'] == 'function_call'
{
'id' => item['id'],
'call_id' => item['call_id'],
'name' => item['name'],
'arguments' => parse_arguments(item['arguments']),
'status' => item['status']
}
end
end
def self.usage_from(response)
usage = response['usage'] || {}
{
'prompt_tokens' => usage['input_tokens'],
'completion_tokens' => usage['output_tokens'],
'total_tokens' => usage['total_tokens'],
'reasoning_tokens' => usage.dig('output_tokens_details', 'reasoning_tokens')
}.compact
end
def self.parse_arguments(arguments)
return arguments unless arguments.is_a?(String)
JSON.parse(arguments)
rescue JSON::ParserError
arguments
end
private
def build_response(response, request_messages:)
{
message: self.class.extract_output_text(response),
usage: self.class.usage_from(response),
response_id: response['id'],
model: response['model'],
status: response['status'],
function_calls: self.class.extract_function_calls(response),
raw_response: response,
request_messages: request_messages
}
end
def conversation_messages(messages)
messages.reject { |message| SYSTEM_ROLES.include?(message[:role].to_s) }.map do |message|
{
role: message[:role].to_s,
content: format_content(message[:content])
}
end
end
def instructions_from(messages)
messages.filter_map do |message|
message[:content] if SYSTEM_ROLES.include?(message[:role].to_s)
end.join("\n\n")
end
def format_content(content)
return content unless content.is_a?(Array)
content.map do |part|
normalized_part = part.deep_symbolize_keys
case normalized_part[:type]
when 'text'
{ type: 'input_text', text: normalized_part[:text] }
when 'image_url'
{ type: 'input_image', image_url: normalized_part.dig(:image_url, :url) || normalized_part[:image_url] }
else
normalized_part
end
end
end
def text_format(schema)
return DEFAULT_TEXT_FORMAT unless schema
schema_payload = normalize_schema_payload(schema)
{
type: 'json_schema',
name: schema_payload[:name],
schema: schema_payload[:schema],
strict: schema_payload[:strict]
}
end
def normalize_schema_payload(schema)
schema_instance = schema.is_a?(Class) ? schema.new : schema
raw_schema = schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
raw_schema = raw_schema.deep_symbolize_keys
schema_def = (raw_schema[:schema] || raw_schema).deep_dup
strict = raw_schema.key?(:strict) ? raw_schema[:strict] : schema_def.delete(:strict)
{
name: sanitize_schema_name(raw_schema[:name] || 'response'),
schema: schema_def,
strict: strict.nil? || strict
}
end
def normalize_tools(tools)
tools.map do |tool|
if tool.is_a?(Hash)
normalized_tool = tool.deep_symbolize_keys
function = normalized_tool[:function]
next normalized_tool unless function
next {
type: 'function',
name: function[:name],
description: function[:description],
parameters: function[:parameters],
strict: function.fetch(:strict, true)
}.compact
end
tool_instance = tool.is_a?(Class) ? tool.new : tool
{
type: 'function',
name: tool_instance.name,
description: tool_instance.description,
parameters: tool_instance.params_schema || RubyLLM::Tool::SchemaDefinition.from_parameters(tool_instance.parameters)&.json_schema,
strict: true
}.compact
end
end
def no_conversation_payload(messages)
{
error: 'No conversation messages provided',
error_code: 400,
request_messages: messages
}
end
def sanitize_schema_name(name)
sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
sanitized.presence || 'response'
end
def normalized_api_base(api_base)
endpoint = api_base.presence || 'https://api.openai.com'
endpoint = endpoint.chomp('/')
endpoint.delete_suffix('/v1')
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1'
DEFAULT_MODEL = 'gpt-5.6-luna'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
@@ -44,10 +44,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(json_response).to have_key(:providers)
expect(json_response).to have_key(:models)
expect(json_response).to have_key(:features)
expect(json_response[:models].keys).to include(:'gpt-5.6-luna', :'gpt-5.6-terra', :'gpt-5.6-sol')
expect(json_response.dig(:features, :assistant, :models).pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
end
it 'returns effective model provider and source for each feature' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
@@ -55,15 +57,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :editor)).to include(
model: 'gpt-4.1',
selected: 'gpt-4.1',
model: 'gpt-5.6-terra',
selected: 'gpt-5.6-terra',
provider: 'openai',
reasoning_effort: 'low',
source: 'account_override'
)
expect(json_response.dig(:features, :label_suggestion)).to include(
model: Llm::Models.default_model_for('label_suggestion'),
selected: Llm::Models.default_model_for('label_suggestion'),
provider: 'openai',
reasoning_effort: 'low',
source: 'default'
)
end
@@ -81,7 +85,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
)
end
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
it 'returns the Captain V2 assistant default for V2 accounts' do
account.enable_features!('captain_integration_v2')
get "/api/v1/accounts/#{account.id}/captain/preferences",
+1 -1
View File
@@ -258,7 +258,7 @@ RSpec.describe Account, type: :model do
expect(account).to be_feature_enabled('captain_integration')
expect(account).to be_feature_enabled('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.captain_models).to be_nil
end
@@ -183,7 +183,7 @@ RSpec.describe Concerns::Agentable do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
expect(dummy_instance.send(:agent_model)).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
@@ -34,7 +34,7 @@ RSpec.describe Llm::BaseAiService do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
+12 -7
View File
@@ -12,25 +12,27 @@ RSpec.describe Llm::FeatureRouter do
expect(resolved).to eq(
feature: 'editor',
provider: 'openai',
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
it 'uses a valid account model override' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
feature: 'editor',
provider: 'openai',
model: 'gpt-4.1',
model: 'gpt-5.6-terra',
reasoning_effort: 'low',
source: :account_override
)
end
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
it 'resolves the Captain V2 assistant default without storing an account override' do
account.enable_features!('captain_integration_v2')
resolved = described_class.resolve(feature: 'assistant', account: account)
@@ -38,7 +40,8 @@ RSpec.describe Llm::FeatureRouter do
expect(resolved).to include(
feature: 'assistant',
provider: 'openai',
model: 'gpt-5.2',
model: described_class::CAPTAIN_V2_ASSISTANT_MODEL,
reasoning_effort: 'medium',
source: :default
)
expect(account.reload.captain_models).to be_nil
@@ -62,7 +65,8 @@ RSpec.describe Llm::FeatureRouter do
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
@@ -73,7 +77,8 @@ RSpec.describe Llm::FeatureRouter do
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
+26 -3
View File
@@ -27,12 +27,33 @@ RSpec.describe Llm::Models do
end
it 'routes document and conversation FAQ generation independently' do
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-5.6-terra')
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.6-terra')
end
it 'sets reasoning effort for every text generation feature' do
features_without_reasoning = %w[audio_transcription help_center_search]
described_class.features.each_key do |feature_key|
if features_without_reasoning.include?(feature_key)
expect(described_class.reasoning_effort_for(feature_key)).to be_nil
else
expect(described_class.reasoning_effort_for(feature_key)).to be_present
end
end
end
it 'exposes GPT-5.6 models for Captain V2 workflows without changing the legacy PDF path' do
expect(described_class.models_for('assistant')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
expect(described_class.models_for('pdf_faq_generation')).not_to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
end
end
describe '.models' do
it 'includes the GPT-5.6 model family' do
expect(described_class.models.keys).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
end
it 'references existing providers from every model' do
missing_providers = described_class.models.filter_map do |model_name, config|
provider = config['provider']
@@ -49,7 +70,9 @@ RSpec.describe Llm::Models do
it 'returns model metadata for a feature' do
config = described_class.feature_config('editor')
expect(config[:default]).to eq('gpt-4.1-mini')
expect(config[:default]).to eq('gpt-5.6-luna')
expect(config[:reasoning_effort]).to eq('low')
expect(config[:models].pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra')
expect(config[:models].first).to include(
id: 'gpt-4.1-mini',
display_name: 'GPT-4.1 Mini',
+215
View File
@@ -0,0 +1,215 @@
require 'rails_helper'
RSpec.describe Llm::ResponsesClient do
let(:openai_client) { instance_double(OpenAI::Client) }
let(:client) { described_class.new(api_key: 'test-key', api_base: 'https://api.openai.com/v1', client: openai_client) }
let(:response_body) do
{
'id' => 'resp_123',
'status' => 'completed',
'model' => 'gpt-5.6-terra',
'output' => [
{
'type' => 'message',
'content' => [
{ 'type' => 'output_text', 'text' => 'Hello' }
]
},
{
'type' => 'function_call',
'id' => 'fc_123',
'call_id' => 'call_123',
'name' => 'lookup_faq',
'arguments' => '{"query":"billing"}',
'status' => 'completed'
}
],
'usage' => {
'input_tokens' => 10,
'output_tokens' => 5,
'total_tokens' => 15,
'output_tokens_details' => { 'reasoning_tokens' => 2 }
}
}
end
before do
allow(openai_client).to receive(:json_post).and_return(response_body)
end
describe '#create' do
it 'sends a Responses API payload with reasoning effort and store disabled' do
messages = [
{ role: 'system', content: 'Answer as a support assistant.' },
{ role: 'user', content: 'Hi' }
]
result = client.create(
model: 'gpt-5.6-terra',
messages: messages,
reasoning_effort: 'medium',
metadata: { feature: 'assistant' }
)
expect(openai_client).to have_received(:json_post).with(
path: '/responses',
parameters: {
model: 'gpt-5.6-terra',
input: [{ role: 'user', content: 'Hi' }],
store: false,
text: { format: { type: 'text' } },
instructions: 'Answer as a support assistant.',
reasoning: { effort: 'medium' },
metadata: { feature: 'assistant' }
}
)
expect(result).to include(
message: 'Hello',
response_id: 'resp_123',
model: 'gpt-5.6-terra',
status: 'completed',
request_messages: messages
)
expect(result[:usage]).to eq(
'prompt_tokens' => 10,
'completion_tokens' => 5,
'total_tokens' => 15,
'reasoning_tokens' => 2
)
expect(result[:function_calls]).to contain_exactly(
'id' => 'fc_123',
'call_id' => 'call_123',
'name' => 'lookup_faq',
'arguments' => { 'query' => 'billing' },
'status' => 'completed'
)
end
it 'returns a local error when no conversation messages are present' do
result = client.create(
model: 'gpt-5.6-terra',
messages: [{ role: 'system', content: 'Only instructions' }],
reasoning_effort: 'low'
)
expect(openai_client).not_to have_received(:json_post)
expect(result).to eq(
error: 'No conversation messages provided',
error_code: 400,
request_messages: [{ role: 'system', content: 'Only instructions' }]
)
end
end
describe '#build_payload' do
class TestResponsesSchema
def to_json_schema
{
name: 'captain.response',
schema: {
type: 'object',
properties: {
response: { type: 'string' }
},
required: ['response'],
additionalProperties: false
},
strict: true
}
end
end
it 'uses Responses text.format for structured outputs' do
payload = client.build_payload(
model: 'gpt-5.6-terra',
messages: [{ role: 'user', content: 'Summarize' }],
schema: TestResponsesSchema
)
expect(payload[:text]).to eq(
format: {
type: 'json_schema',
name: 'captain_response',
schema: {
type: 'object',
properties: {
response: { type: 'string' }
},
required: ['response'],
additionalProperties: false
},
strict: true
}
)
end
it 'normalizes chat-style function tools to Responses function tools' do
payload = client.build_payload(
model: 'gpt-5.6-terra',
messages: [{ role: 'user', content: 'Find docs' }],
tools: [
{
type: 'function',
function: {
name: 'lookup_faq',
description: 'Search FAQs',
parameters: {
type: 'object',
properties: {
query: { type: 'string' }
},
required: ['query']
}
}
}
]
)
expect(payload[:tools]).to eq(
[
{
type: 'function',
name: 'lookup_faq',
description: 'Search FAQs',
parameters: {
type: 'object',
properties: {
query: { type: 'string' }
},
required: ['query']
},
strict: true
}
]
)
end
it 'normalizes multimodal chat content to Responses input blocks' do
payload = client.build_payload(
model: 'gpt-5.6-terra',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'What is in this image?' },
{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }
]
}
]
)
expect(payload[:input]).to eq(
[
{
role: 'user',
content: [
{ type: 'input_text', text: 'What is in this image?' },
{ type: 'input_image', image_url: 'https://example.com/image.png' }
]
}
]
)
end
end
end
+2 -2
View File
@@ -393,10 +393,10 @@ RSpec.describe Account do
end
end
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
it 'returns the Captain V2 default for assistant when Captain V2 is enabled' do
account.enable_features!('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
end