Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3171312cdd | ||
|
|
96d1320aa5 | ||
|
|
9bfc54ea0c | ||
|
|
93d4e1ca0e | ||
|
|
090ca53bde |
@@ -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)'
|
||||
|
||||
+69
-14
@@ -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
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, 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_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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -10,7 +10,8 @@ module Concerns::Agentable
|
||||
tools: agent_tools,
|
||||
model: agent_model,
|
||||
temperature: temperature.presence&.to_f || DEFAULT_TEMPERATURE,
|
||||
response_schema: agent_response_schema
|
||||
response_schema: agent_response_schema,
|
||||
params: agent_params
|
||||
)
|
||||
end
|
||||
|
||||
@@ -52,6 +53,13 @@ module Concerns::Agentable
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return {} if route[:reasoning_effort].blank?
|
||||
|
||||
{ reasoning_effort: route[:reasoning_effort] }
|
||||
end
|
||||
|
||||
def installation_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
end
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
module Enterprise::Concerns::Article
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
SEARCH_TERMS_FEATURE = 'help_center_article_generation'
|
||||
SEARCH_TERMS_SCHEMA = {
|
||||
name: 'article_search_terms',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
search_terms: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: %w[search_terms],
|
||||
additionalProperties: false
|
||||
},
|
||||
strict: true
|
||||
}.freeze
|
||||
|
||||
included do
|
||||
after_save :add_article_embedding, if: -> { saved_change_to_title? || saved_change_to_description? || saved_change_to_content? }
|
||||
|
||||
@@ -67,23 +84,38 @@ module Enterprise::Concerns::Article
|
||||
{ role: 'system', content: article_to_search_terms_prompt },
|
||||
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
|
||||
]
|
||||
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{openai_api_key}" }
|
||||
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
|
||||
Rails.logger.info "Requesting Chat GPT with body: #{body}"
|
||||
response = HTTParty.post(openai_api_url, headers: headers, body: body)
|
||||
Rails.logger.info "Chat GPT response: #{response.body}"
|
||||
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
|
||||
|
||||
response = responses_client.create(
|
||||
model: search_terms_route[:model],
|
||||
messages: messages,
|
||||
schema: SEARCH_TERMS_SCHEMA,
|
||||
reasoning_effort: search_terms_route[:reasoning_effort],
|
||||
metadata: {
|
||||
account_id: account_id,
|
||||
article_id: id,
|
||||
feature: 'article_search_terms'
|
||||
}
|
||||
)
|
||||
|
||||
JSON.parse(response[:message])['search_terms']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_terms_route
|
||||
@search_terms_route ||= Llm::FeatureRouter.resolve(feature: SEARCH_TERMS_FEATURE, account: account)
|
||||
end
|
||||
|
||||
def responses_client
|
||||
@responses_client ||= Llm::ResponsesClient.new(api_key: openai_api_key, api_base: openai_api_base)
|
||||
end
|
||||
|
||||
def openai_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || raise(I18n.t('captain.api_key_missing'))
|
||||
end
|
||||
|
||||
def openai_api_url
|
||||
def openai_api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1/chat/completions"
|
||||
endpoint.chomp('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,30 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
# Default pages per chunk - easily configurable
|
||||
DEFAULT_PAGES_PER_CHUNK = 10
|
||||
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
|
||||
FAQ_RESPONSE_SCHEMA = {
|
||||
name: 'pdf_faq_generation',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
faqs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string' },
|
||||
answer: { type: 'string' }
|
||||
},
|
||||
required: %w[question answer],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
has_content: { type: 'boolean' }
|
||||
},
|
||||
required: %w[faqs has_content],
|
||||
additionalProperties: false
|
||||
},
|
||||
strict: true
|
||||
}.freeze
|
||||
|
||||
attr_reader :total_pages_processed, :iterations_completed
|
||||
|
||||
@@ -15,7 +39,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
@max_pages = options[:max_pages] # Optional limit from UI
|
||||
@total_pages_processed = 0
|
||||
@iterations_completed = 0
|
||||
@model = Llm::FeatureRouter.resolve(feature: 'pdf_faq_generation', account: document.account)[:model]
|
||||
@route = Llm::FeatureRouter.resolve(feature: 'pdf_faq_generation', account: document.account)
|
||||
@model = @route[:model]
|
||||
end
|
||||
|
||||
def generate
|
||||
@@ -98,12 +123,17 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
end
|
||||
|
||||
def process_page_chunk(start_page, end_page)
|
||||
params = build_chunk_parameters(start_page, end_page)
|
||||
|
||||
instrumentation_params = build_instrumentation_params(params, start_page, end_page)
|
||||
messages = build_chunk_messages(start_page, end_page)
|
||||
instrumentation_params = build_instrumentation_params(messages, start_page, end_page)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(parameters: params)
|
||||
responses_client.create(
|
||||
model: @model,
|
||||
messages: messages,
|
||||
schema: FAQ_RESPONSE_SCHEMA,
|
||||
reasoning_effort: @route[:reasoning_effort],
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page)
|
||||
)
|
||||
end
|
||||
|
||||
result = parse_chunk_response(response)
|
||||
@@ -113,17 +143,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
{ faqs: [], has_content: false }
|
||||
end
|
||||
|
||||
def build_chunk_parameters(start_page, end_page)
|
||||
{
|
||||
model: @model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: build_user_content(start_page, end_page)
|
||||
}
|
||||
]
|
||||
}
|
||||
def build_chunk_messages(start_page, end_page)
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: build_user_content(start_page, end_page)
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def build_user_content(start_page, end_page)
|
||||
@@ -171,7 +197,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
end
|
||||
|
||||
def parse_chunk_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
content = response[:message]
|
||||
return { 'faqs' => [], 'has_content' => false } if content.nil?
|
||||
|
||||
JSON.parse(sanitize_json_response(content))
|
||||
@@ -208,13 +234,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
common_words.size.to_f / total_words
|
||||
end
|
||||
|
||||
def build_instrumentation_params(params, start_page, end_page)
|
||||
def build_instrumentation_params(messages, start_page, end_page)
|
||||
{
|
||||
span_name: 'llm.paginated_faq_generation',
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'paginated_faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages],
|
||||
messages: messages,
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
|
||||
}
|
||||
end
|
||||
@@ -222,4 +248,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
|
||||
def responses_client
|
||||
@responses_client ||= Llm::ResponsesClient.new(api_key: nil, client: @client)
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -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?
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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] }
|
||||
when 'file'
|
||||
input_file_part(normalized_part)
|
||||
else
|
||||
normalized_part
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def input_file_part(part)
|
||||
file = part[:file] || {}
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: file[:file_id] || part[:file_id],
|
||||
file_url: file[:file_url] || part[:file_url],
|
||||
detail: part[:detail]
|
||||
}.compact
|
||||
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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ RSpec.describe Concerns::Agentable do
|
||||
tools: [],
|
||||
model: Llm::Models.default_model_for('assistant'),
|
||||
temperature: 0.8,
|
||||
response_schema: Captain::ResponseSchema
|
||||
response_schema: Captain::ResponseSchema,
|
||||
params: { reasoning_effort: Llm::Models.reasoning_effort_for('assistant') }
|
||||
)
|
||||
|
||||
dummy_instance.agent
|
||||
@@ -183,7 +184,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
|
||||
|
||||
@@ -194,6 +195,14 @@ RSpec.describe Concerns::Agentable do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_params' do
|
||||
it 'returns reasoning effort for the assistant feature' do
|
||||
expect(dummy_instance.send(:agent_params)).to eq(
|
||||
reasoning_effort: Llm::Models.reasoning_effort_for('assistant')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_response_schema' do
|
||||
it 'returns Captain::ResponseSchema' do
|
||||
expect(dummy_instance.send(:agent_response_schema)).to eq(Captain::ResponseSchema)
|
||||
|
||||
@@ -35,28 +35,38 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
context 'when generating FAQs from PDF pages' do
|
||||
let(:faq_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [
|
||||
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
|
||||
],
|
||||
'has_content' => true
|
||||
})
|
||||
}
|
||||
'id' => 'resp-123',
|
||||
'status' => 'completed',
|
||||
'model' => service.model,
|
||||
'output' => [{
|
||||
'type' => 'message',
|
||||
'content' => [{
|
||||
'type' => 'output_text',
|
||||
'text' => JSON.generate({
|
||||
'faqs' => [
|
||||
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
|
||||
],
|
||||
'has_content' => true
|
||||
})
|
||||
}]
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
let(:empty_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [],
|
||||
'has_content' => false
|
||||
})
|
||||
}
|
||||
'id' => 'resp-456',
|
||||
'status' => 'completed',
|
||||
'model' => service.model,
|
||||
'output' => [{
|
||||
'type' => 'message',
|
||||
'content' => [{
|
||||
'type' => 'output_text',
|
||||
'text' => JSON.generate({
|
||||
'faqs' => [],
|
||||
'has_content' => false
|
||||
})
|
||||
}]
|
||||
}]
|
||||
}
|
||||
end
|
||||
@@ -66,16 +76,38 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
end
|
||||
|
||||
it 'generates FAQs from paginated content' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response, empty_response)
|
||||
allow(openai_client).to receive(:json_post).and_return(faq_response, empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
expect(faqs).to have_attributes(size: 1)
|
||||
expect(faqs.first['question']).to eq('What is this document about?')
|
||||
expect(openai_client).to have_received(:json_post).with(
|
||||
path: '/responses',
|
||||
parameters: hash_including(
|
||||
model: service.model,
|
||||
reasoning: { effort: Llm::Models.reasoning_effort_for('pdf_faq_generation') },
|
||||
text: {
|
||||
format: hash_including(
|
||||
type: 'json_schema',
|
||||
name: 'pdf_faq_generation',
|
||||
strict: true
|
||||
)
|
||||
},
|
||||
input: [
|
||||
hash_including(
|
||||
content: array_including(
|
||||
{ type: 'input_file', file_id: 'file-123' },
|
||||
hash_including(type: 'input_text')
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
).at_least(:once)
|
||||
end
|
||||
|
||||
it 'stops when no more content' do
|
||||
allow(openai_client).to receive(:chat).and_return(empty_response)
|
||||
allow(openai_client).to receive(:json_post).and_return(empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
@@ -83,7 +115,7 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
end
|
||||
|
||||
it 'respects max iterations limit' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response)
|
||||
allow(openai_client).to receive(:json_post).and_return(faq_response)
|
||||
|
||||
# Force max iterations
|
||||
service.instance_variable_set(:@iterations_completed, 19)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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')).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',
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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' } },
|
||||
{ type: 'file', file: { file_id: 'file-123' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
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' },
|
||||
{ type: 'input_file', file_id: 'file-123' }
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -208,6 +208,58 @@ RSpec.describe Article do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_article_search_terms' do
|
||||
let(:responses_client) { instance_double(Llm::ResponsesClient) }
|
||||
let(:article) do
|
||||
create(
|
||||
:article,
|
||||
account: account,
|
||||
category: category_1,
|
||||
content: 'How to configure billing invoices and payment reminders',
|
||||
description: 'Billing setup guide',
|
||||
portal: portal_1,
|
||||
author: user,
|
||||
title: 'Configure billing'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_API_KEY').tap do |config|
|
||||
config.value = 'sk-test'
|
||||
config.save!
|
||||
end
|
||||
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').tap do |config|
|
||||
config.value = 'https://api.openai.test/v1'
|
||||
config.save!
|
||||
end
|
||||
allow(Llm::ResponsesClient).to receive(:new).and_return(responses_client)
|
||||
allow(responses_client).to receive(:create).and_return(
|
||||
{ message: { search_terms: ['billing setup', 'invoice reminders'] }.to_json }
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses Responses API with the help center article model route' do
|
||||
expect(article.generate_article_search_terms).to eq(['billing setup', 'invoice reminders'])
|
||||
|
||||
expect(Llm::ResponsesClient).to have_received(:new).with(
|
||||
api_key: 'sk-test',
|
||||
api_base: 'https://api.openai.test/v1'
|
||||
)
|
||||
expect(responses_client).to have_received(:create).with(
|
||||
hash_including(
|
||||
model: Llm::Models.default_model_for('help_center_article_generation'),
|
||||
reasoning_effort: Llm::Models.reasoning_effort_for('help_center_article_generation'),
|
||||
schema: Enterprise::Concerns::Article::SEARCH_TERMS_SCHEMA,
|
||||
metadata: hash_including(
|
||||
account_id: account.id,
|
||||
article_id: article.id,
|
||||
feature: 'article_search_terms'
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.update_positions' do
|
||||
let!(:article_a) { create(:article, portal: portal_1, category: category_1, author: user, position: 10) }
|
||||
let!(:article_b) { create(:article, portal: portal_1, category: category_1, author: user, position: 11) }
|
||||
|
||||
Reference in New Issue
Block a user