Skip to content
Kward Search API index

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_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
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
CODEX_CONTEXT_WINDOWS =
[
  [/\Agpt-5\.5/, 400_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
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



348
349
350
# File 'lib/kward/model/model_info.rb', line 348

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

.anthropic_reasoning_effort_choices(id) ⇒ Object



389
390
391
392
393
394
395
396
# File 'lib/kward/model/model_info.rb', line 389

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



415
416
417
418
419
420
# File 'lib/kward/model/model_info.rb', line 415

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



200
201
202
203
204
205
206
207
# File 'lib/kward/model/model_info.rb', line 200

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



218
219
220
# File 'lib/kward/model/model_info.rb', line 218

def config_provider_for_provider(provider)
  provider_config_value(provider)
end

.config_values_for_selection(provider, model) ⇒ Object



222
223
224
225
226
227
# File 'lib/kward/model/model_info.rb', line 222

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

.conservative_anthropic_context_window(id) ⇒ Object



319
320
321
# File 'lib/kward/model/model_info.rb', line 319

def conservative_anthropic_context_window(id)
  id.to_s.strip.empty? ? nil : 200_000
end

.conservative_context_window(id) ⇒ Object



312
313
314
315
316
317
# File 'lib/kward/model/model_info.rb', line 312

def conservative_context_window(id)
  text = id.to_s
  return 128_000 if text.match?(/\A(?:gpt-|o\d)/)
  return 200_000 if text.start_with?("claude-")
  return 128_000 if text.start_with?("gemini-")
end

.conservative_openrouter_context_window(id) ⇒ Object



334
335
336
337
338
339
# File 'lib/kward/model/model_info.rb', line 334

def conservative_openrouter_context_window(id)
  text = id.to_s
  return conservative_context_window(text.delete_prefix("openai/")) if text.start_with?("openai/")
  return conservative_context_window(normalize_anthropic_model(text.delete_prefix("anthropic/"))) if text.start_with?("anthropic/")
  return conservative_context_window(text.delete_prefix("google/")) if text.start_with?("google/")
end

.conservative_unknown_context_window(id) ⇒ Object



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

def conservative_unknown_context_window(id)
  id.to_s.strip.empty? ? nil : 128_000
end

.context_window(provider, id, openrouter_models: nil) ⇒ Object



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/kward/model/model_info.rb', line 229

def context_window(provider, id, openrouter_models: nil)
  case provider
  when "Codex"
    pattern_context_window(CODEX_CONTEXT_WINDOWS, id) ||
      openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
      conservative_context_window(id)
  when "OpenRouter"
    openrouter_cached_context_window(openrouter_models, id) ||
      openrouter_context_window(id) ||
      conservative_openrouter_context_window(id) ||
      conservative_unknown_context_window(id)
  when "Copilot"
    copilot_context_window(id) ||
      openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
      conservative_context_window(id) ||
      conservative_unknown_context_window(id)
  when "Anthropic"
    anthropic_context_window(id) ||
      openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
      conservative_context_window(normalize_anthropic_model(id)) ||
      conservative_anthropic_context_window(id)
  end
end

.copilot_context_window(id) ⇒ Object



341
342
343
344
345
346
# File 'lib/kward/model/model_info.rb', line 341

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

.copilot_openrouter_equivalent_ids(id) ⇒ Object



292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/kward/model/model_info.rb', line 292

def copilot_openrouter_equivalent_ids(id)
  return ["openai/#{id.delete_prefix("openai/")}"] if id.start_with?("openai/") || id.match?(/\A(?:gpt-|o\d)/)
  return ["google/#{id.delete_prefix("google/")}"] if id.start_with?("google/") || id.start_with?("gemini-")

  if id.start_with?("anthropic/") || id.start_with?("claude-")
    raw = id.delete_prefix("anthropic/")
    normalized = normalize_anthropic_model(raw)
    return ["anthropic/#{raw}", "anthropic/#{normalized}"].uniq
  end

  []
end

.model_for(provider, config:, override_model: nil, env: ENV) ⇒ 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 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")
  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



398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/kward/model/model_info.rb', line 398

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



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

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



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

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



382
383
384
385
386
387
# File 'lib/kward/model/model_info.rb', line 382

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_cached_context_window(models, id) ⇒ Object



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

def openrouter_cached_context_window(models, id)
  model = Array(models).find do |entry|
    next false unless entry.respond_to?(:key?)

    entry["id"].to_s == id.to_s || entry[:id].to_s == id.to_s
  end
  value = model && (model["contextWindow"] || model[:contextWindow] || model["context_window"] || model[:context_window])
  positive_integer(value)
end

.openrouter_context_window(id) ⇒ Object



327
328
329
330
331
332
# File 'lib/kward/model/model_info.rb', line 327

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/")
end

.openrouter_equivalent_ids(provider, id) ⇒ Object



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/kward/model/model_info.rb', line 274

def openrouter_equivalent_ids(provider, id)
  text = id.to_s.strip
  return [] if text.empty?

  case provider
  when "Codex"
    ["openai/#{text.delete_prefix("openai/")}"]
  when "Anthropic"
    raw = text.delete_prefix("anthropic/")
    normalized = normalize_anthropic_model(raw)
    ["anthropic/#{raw}", "anthropic/#{normalized}"].uniq
  when "Copilot"
    copilot_openrouter_equivalent_ids(text)
  else
    []
  end
end

.openrouter_inferred_context_window(provider, id, openrouter_models: nil) ⇒ Object



268
269
270
271
272
# File 'lib/kward/model/model_info.rb', line 268

def openrouter_inferred_context_window(provider, id, openrouter_models: nil)
  openrouter_equivalent_ids(provider, id).filter_map do |candidate|
    openrouter_cached_context_window(openrouter_models, candidate)
  end.min
end

.pattern_context_window(patterns, id) ⇒ Object



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

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

.positive_integer(value) ⇒ Object



305
306
307
308
309
310
# File 'lib/kward/model/model_info.rb', line 305

def positive_integer(value)
  integer = Integer(value)
  integer.positive? ? integer : nil
rescue ArgumentError, TypeError
  nil
end

.provider_config_value(provider) ⇒ Object



191
192
193
194
195
196
197
198
# File 'lib/kward/model/model_info.rb', line 191

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



181
182
183
184
185
186
187
188
189
# File 'lib/kward/model/model_info.rb', line 181

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



209
210
211
212
213
214
215
216
# File 'lib/kward/model/model_info.rb', line 209

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



168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/kward/model/model_info.rb', line 168

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



369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/kward/model/model_info.rb', line 369

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)


356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/kward/model/model_info.rb', line 356

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



422
423
424
# File 'lib/kward/model/model_info.rb', line 422

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

.supports_images?(_provider, id) ⇒ Boolean

Returns:

  • (Boolean)


352
353
354
# File 'lib/kward/model/model_info.rb', line 352

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