Module: Kward::ModelInfo

Defined in:
lib/kward/model/model_info.rb

Overview

Static and configured model metadata helpers.

Constant Summary collapse

DEFAULT_OPENAI_MODEL =
"gpt-5.5"
DEFAULT_OPENROUTER_MODEL =
"openai/gpt-5.5"
DEFAULT_COPILOT_MODEL =
"gpt-5-mini"
DEFAULT_ANTHROPIC_MODEL =
"claude-sonnet-4-6"
DEFAULT_REASONING_EFFORT =
"medium"
OPENAI_MODEL_CHOICES =
%w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
OPENROUTER_MODEL_CHOICES =
OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" }.freeze
ANTHROPIC_MODEL_CHOICES =
%w[
  claude-opus-4-8
  claude-sonnet-4-6
  claude-haiku-4-5
  claude-opus-4-7
  claude-opus-4-6
  claude-opus-4-5
  claude-sonnet-4-5
].freeze
COPILOT_MODEL_CHOICES =
%w[
  gpt-5-mini
  gpt-5.3-codex
  gpt-5.4
  gpt-5.4-mini
  gpt-5.5
  claude-haiku-4.5
  claude-opus-4.5
  claude-opus-4.7
  claude-opus-4.8
  claude-sonnet-4.5
  claude-sonnet-4.6
  gemini-2.5-pro
  gemini-3-flash-preview
  gemini-3.1-pro-preview
  gemini-3.5-flash
  oswe-vscode-prime
].freeze
REASONING_EFFORT_CHOICES =
[
  ["low", "Low"],
  ["medium", "Medium"],
  ["high", "High"],
  ["xhigh", "Extra High"]
].freeze
OPENAI_REASONING_EFFORT_CHOICES =
[
  ["none", "None"],
  *REASONING_EFFORT_CHOICES
].freeze
ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES =
[
  ["low", "Low"],
  ["medium", "Medium"],
  ["high", "High"],
  ["xhigh", "Extra High"],
  ["max", "Max"]
].freeze
ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES =
[
  ["low", "Low"],
  ["medium", "Medium"],
  ["high", "High"],
  ["max", "Max"]
].freeze
ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES =
[
  ["low", "Low"],
  ["medium", "Medium"],
  ["high", "High"]
].freeze
IMAGE_UNSUPPORTED_MODELS =
[
  /(?:\A|\/)gpt-5\.3-codex-spark\z/
].freeze
OPENAI_CONTEXT_WINDOWS =
[
  [/\Agpt-5\.5/, 1_050_000],
  [/\Agpt-5\.4-mini/, 400_000],
  [/\Agpt-5\.4/, 1_050_000],
  [/\Agpt-5-mini/, 400_000],
  [/\Agpt-5-codex/, 400_000],
  [/\Agpt-5\.3-codex-spark/, 128_000],
  [/\Agpt-5\.3-codex/, 400_000],
  [/\Agpt-5\.2-codex/, 400_000],
  [/\Agpt-5/, 400_000],
  [/\Agpt-4\.1/, 1_047_576],
  [/\Agpt-4o/, 128_000],
  [/\Ao3/, 200_000],
  [/\Ao4/, 200_000],
  [/\Agpt-4/, 128_000],
  [/\Agpt-3\.5-turbo/, 16_385]
].freeze
ANTHROPIC_CONTEXT_WINDOWS =
[
  [/\Aclaude-(?:fable|mythos)-5(?:\z|-)/, 1_000_000],
  [/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
  [/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
  [/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
  [/\Aclaude-(?:haiku|opus|sonnet)-4(?:\z|-)/, 200_000]
].freeze
GEMINI_CONTEXT_WINDOWS =
[
  [/\Agemini-(?:2\.5-pro|3(?:\.1)?-pro|3(?:\.5)?-flash)/, 1_048_576]
].freeze

Class Method Summary collapse

Class Method Details

.anthropic_context_window(id) ⇒ Object



248
249
250
# File 'lib/kward/model/model_info.rb', line 248

def anthropic_context_window(id)
  pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
end

.anthropic_reasoning_effort_choices(id) ⇒ Object



289
290
291
292
293
294
295
296
# File 'lib/kward/model/model_info.rb', line 289

def anthropic_reasoning_effort_choices(id)
  text = normalize_anthropic_model(id)
  return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
  return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
  return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)

  []
end

.boolean_value(value, default: false) ⇒ Object



315
316
317
318
319
320
# File 'lib/kward/model/model_info.rb', line 315

def boolean_value(value, default: false)
  return default if value.nil?
  return value if value == true || value == false

  value.to_s == "true"
end

.config_key_for_provider(provider) ⇒ Object



185
186
187
188
189
190
191
192
# File 'lib/kward/model/model_info.rb', line 185

def config_key_for_provider(provider)
  case provider.to_s.downcase
  when "openrouter" then "openrouter_model"
  when "copilot" then "copilot_model"
  when "anthropic", "claude" then "anthropic_model"
  else "openai_model"
  end
end

.config_provider_for_provider(provider) ⇒ Object



203
204
205
# File 'lib/kward/model/model_info.rb', line 203

def config_provider_for_provider(provider)
  provider_config_value(provider)
end

.config_values_for_selection(provider, model) ⇒ Object



207
208
209
210
211
212
# File 'lib/kward/model/model_info.rb', line 207

def config_values_for_selection(provider, model)
  {
    config_key_for_provider(provider) => model,
    "provider" => config_provider_for_provider(provider)
  }
end

.context_window(provider, id) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/kward/model/model_info.rb', line 214

def context_window(provider, id)
  case provider
  when "Codex"
    pattern_context_window(OPENAI_CONTEXT_WINDOWS, id)
  when "OpenRouter"
    openrouter_context_window(id)
  when "Copilot"
    copilot_context_window(id)
  when "Anthropic"
    anthropic_context_window(id)
  end
end

.copilot_context_window(id) ⇒ Object



241
242
243
244
245
246
# File 'lib/kward/model/model_info.rb', line 241

def copilot_context_window(id)
  text = id.to_s
  pattern_context_window(OPENAI_CONTEXT_WINDOWS, text) ||
    anthropic_context_window(normalize_anthropic_model(text)) ||
    pattern_context_window(GEMINI_CONTEXT_WINDOWS, text)
end

.model_for(provider, config:, override_model: nil, env: ENV) ⇒ Object



104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/kward/model/model_info.rb', line 104

def model_for(provider, config:, override_model: nil, env: ENV)
  return override_model if override_model

  case provider
  when "OpenRouter"
    env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
  when "Copilot"
    normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
  when "Anthropic"
    normalize_anthropic_model(env["ANTHROPIC_MODEL"] || ConfigFiles.config_value(config, "anthropic_model", "model") || DEFAULT_ANTHROPIC_MODEL)
  else
    env["OPENAI_MODEL"] || ConfigFiles.config_value(config, "openai_model", "model") || DEFAULT_OPENAI_MODEL
  end
end

.normalize(model, current_provider: nil, current_model: nil, current_reasoning_effort: nil) ⇒ Object



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/kward/model/model_info.rb', line 298

def normalize(model, current_provider: nil, current_model: nil, current_reasoning_effort: nil)
  model = stringify_keys(model || {})
  provider = model["provider"]
  id = model["id"] || model["model"]
  reasoning = boolean_value(model["reasoning"], default: reasoning_supported?(provider, id))
  reasoning_effort = model["reasoningEffort"] || model["reasoning_effort"] || (current_reasoning_effort if reasoning)
  {
    provider: provider,
    id: id,
    name: model["name"] || id,
    reasoning: reasoning,
    reasoningEffort: reasoning_effort,
    contextWindow: model["contextWindow"] || model["context_window"] || context_window(provider, id),
    current: boolean_value(model["current"], default: provider == current_provider && id == current_model)
  }.compact
end

.normalize_anthropic_model(id) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/kward/model/model_info.rb', line 119

def normalize_anthropic_model(id)
  text = id.to_s.strip
  return DEFAULT_ANTHROPIC_MODEL if text.empty?
  text = text.delete_prefix("anthropic/").delete_prefix("claude/")
  {
    "claude-sonnet-4.6" => "claude-sonnet-4-6",
    "claude-sonnet-4.5" => "claude-sonnet-4-5",
    "claude-opus-4.8" => "claude-opus-4-8",
    "claude-opus-4.7" => "claude-opus-4-7",
    "claude-opus-4.6" => "claude-opus-4-6",
    "claude-opus-4.5" => "claude-opus-4-5",
    "claude-haiku-4.5" => "claude-haiku-4-5"
  }.fetch(text, text)
end

.normalize_copilot_model(id) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/kward/model/model_info.rb', line 134

def normalize_copilot_model(id)
  text = id.to_s.strip
  return DEFAULT_COPILOT_MODEL if text.empty?
  text = text.delete_prefix("copilot/")

  {
    "claude-haiku-4-5" => "claude-haiku-4.5",
    "claude-opus-4-5" => "claude-opus-4.5",
    "claude-opus-4-6" => "claude-opus-4.6",
    "claude-opus-4-6-fast" => "claude-opus-4.6-fast",
    "claude-opus-4-7" => "claude-opus-4.7",
    "claude-opus-4-8" => "claude-opus-4.8",
    "claude-sonnet-4-5" => "claude-sonnet-4.5",
    "claude-sonnet-4-6" => "claude-sonnet-4.6",
    "gemini-3.1-pro" => "gemini-3.1-pro-preview",
    "gemini-3-flash" => "gemini-3-flash-preview"
  }.fetch(text, text)
end

.openai_reasoning_effort_choices(id) ⇒ Object



282
283
284
285
286
287
# File 'lib/kward/model/model_info.rb', line 282

def openai_reasoning_effort_choices(id)
  text = id.to_s.delete_prefix("openai/")
  return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)

  OPENAI_REASONING_EFFORT_CHOICES
end

.openrouter_context_window(id) ⇒ Object



232
233
234
235
236
237
238
239
# File 'lib/kward/model/model_info.rb', line 232

def openrouter_context_window(id)
  text = id.to_s
  return pattern_context_window(OPENAI_CONTEXT_WINDOWS, text.delete_prefix("openai/")) if text.start_with?("openai/")
  return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
  return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")

  nil
end

.pattern_context_window(patterns, id) ⇒ Object



227
228
229
230
# File 'lib/kward/model/model_info.rb', line 227

def pattern_context_window(patterns, id)
  match = patterns.find { |pattern, _window| id.to_s.match?(pattern) }
  match&.last
end

.provider_config_value(provider) ⇒ Object



176
177
178
179
180
181
182
183
# File 'lib/kward/model/model_info.rb', line 176

def provider_config_value(provider)
  case provider.to_s.downcase
  when "openrouter" then "openrouter"
  when "copilot" then "copilot"
  when "anthropic", "claude" then "anthropic"
  else "codex"
  end
end

.provider_label(provider) ⇒ Object



166
167
168
169
170
171
172
173
174
# File 'lib/kward/model/model_info.rb', line 166

def provider_label(provider)
  case provider.to_s.downcase
  when "openrouter" then "OpenRouter"
  when "copilot" then "Copilot"
  when "anthropic", "claude" then "Anthropic"
  when "codex", "openai" then "Codex"
  else provider.to_s
  end
end

.reasoning_config_key_for_provider(provider) ⇒ Object



194
195
196
197
198
199
200
201
# File 'lib/kward/model/model_info.rb', line 194

def reasoning_config_key_for_provider(provider)
  case provider.to_s.downcase
  when "openrouter" then "openrouter_reasoning_effort"
  when "copilot" then "copilot_reasoning_effort"
  when "anthropic", "claude" then "anthropic_reasoning_effort"
  else "openai_reasoning_effort"
  end
end

.reasoning_effort(config:, env: ENV, provider: nil) ⇒ Object



153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/kward/model/model_info.rb', line 153

def reasoning_effort(config:, env: ENV, provider: nil)
  case provider.to_s
  when "OpenRouter"
    env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
  when "Copilot"
    env["COPILOT_REASONING_EFFORT"] || ConfigFiles.config_value(config, "copilot_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
  when "Anthropic"
    env["ANTHROPIC_REASONING_EFFORT"] || ConfigFiles.config_value(config, "anthropic_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
  else
    env["OPENAI_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openai_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
  end
end

.reasoning_effort_choices(provider, id) ⇒ Object



269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/kward/model/model_info.rb', line 269

def reasoning_effort_choices(provider, id)
  case provider
  when "Codex", "OpenRouter"
    openai_reasoning_effort_choices(id)
  when "Anthropic"
    anthropic_reasoning_effort_choices(id)
  when "Copilot"
    id.to_s.match?(/\Agpt-5(?:\.|-|\z)/) ? openai_reasoning_effort_choices(id) : []
  else
    []
  end
end

.reasoning_supported?(provider, id) ⇒ Boolean

Returns:

  • (Boolean)


256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/kward/model/model_info.rb', line 256

def reasoning_supported?(provider, id)
  case provider
  when "Codex", "OpenRouter"
    true
  when "Anthropic"
    !reasoning_effort_choices(provider, id).empty?
  when "Copilot"
    id.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
  else
    false
  end
end

.stringify_keys(value) ⇒ Object



322
323
324
# File 'lib/kward/model/model_info.rb', line 322

def stringify_keys(value)
  value.each_with_object({}) { |(key, item), result| result[key.to_s] = item }
end

.supports_images?(_provider, id) ⇒ Boolean

Returns:

  • (Boolean)


252
253
254
# File 'lib/kward/model/model_info.rb', line 252

def supports_images?(_provider, id)
  IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
end