core-agent-ide/codex-rs/protocol/src/openai_models.rs
Ahmed Ibrahim a09711332a
Add migration_markdown in model_info (#9219)
Next step would be to clean Model Upgrade in model presets

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: aibrahim-oai <219906144+aibrahim-oai@users.noreply.github.com>
2026-01-15 01:55:22 +00:00

337 lines
10 KiB
Rust

use std::collections::HashMap;
use std::collections::HashSet;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use strum::IntoEnumIterator;
use strum_macros::Display;
use strum_macros::EnumIter;
use ts_rs::TS;
use crate::config_types::Verbosity;
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(
Debug,
Serialize,
Deserialize,
Default,
Clone,
Copy,
PartialEq,
Eq,
Display,
JsonSchema,
TS,
EnumIter,
Hash,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningEffort {
None,
Minimal,
Low,
#[default]
Medium,
High,
XHigh,
}
/// A reasoning effort option that can be surfaced for a model.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct ReasoningEffortPreset {
/// Effort level that the model supports.
pub effort: ReasoningEffort,
/// Short human description shown next to the effort in UIs.
pub description: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct ModelUpgrade {
pub id: String,
pub reasoning_effort_mapping: Option<HashMap<ReasoningEffort, ReasoningEffort>>,
pub migration_config_key: String,
pub model_link: Option<String>,
pub upgrade_copy: Option<String>,
pub migration_markdown: Option<String>,
}
/// Metadata describing a Codex-supported model.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct ModelPreset {
/// Stable identifier for the preset.
pub id: String,
/// Model slug (e.g., "gpt-5").
pub model: String,
/// Display name shown in UIs.
pub display_name: String,
/// Short human description shown in UIs.
pub description: String,
/// Reasoning effort applied when none is explicitly chosen.
pub default_reasoning_effort: ReasoningEffort,
/// Supported reasoning effort options.
pub supported_reasoning_efforts: Vec<ReasoningEffortPreset>,
/// Whether this is the default model for new users.
pub is_default: bool,
/// recommended upgrade model
pub upgrade: Option<ModelUpgrade>,
/// Whether this preset should appear in the picker UI.
pub show_in_picker: bool,
/// whether this model is supported in the api
pub supported_in_api: bool,
}
/// Visibility of a model in the picker or APIs.
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ModelVisibility {
List,
Hide,
None,
}
/// Shell execution capability for a model.
#[derive(
Debug,
Serialize,
Deserialize,
Clone,
Copy,
PartialEq,
Eq,
TS,
JsonSchema,
EnumIter,
Display,
Hash,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ConfigShellToolType {
Default,
Local,
UnifiedExec,
Disabled,
ShellCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ApplyPatchToolType {
Freeform,
Function,
}
/// Server-provided truncation policy metadata for a model.
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TruncationMode {
Bytes,
Tokens,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
pub struct TruncationPolicyConfig {
pub mode: TruncationMode,
pub limit: i64,
}
impl TruncationPolicyConfig {
pub const fn bytes(limit: i64) -> Self {
Self {
mode: TruncationMode::Bytes,
limit,
}
}
pub const fn tokens(limit: i64) -> Self {
Self {
mode: TruncationMode::Tokens,
limit,
}
}
}
/// Semantic version triple encoded as an array in JSON (e.g. [0, 62, 0]).
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
pub struct ClientVersion(pub i32, pub i32, pub i32);
const fn default_effective_context_window_percent() -> i64 {
95
}
/// Model metadata returned by the Codex backend `/models` endpoint.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelInfo {
pub slug: String,
pub display_name: String,
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_reasoning_level: Option<ReasoningEffort>,
pub supported_reasoning_levels: Vec<ReasoningEffortPreset>,
pub shell_type: ConfigShellToolType,
pub visibility: ModelVisibility,
pub supported_in_api: bool,
pub priority: i32,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
pub supports_reasoning_summaries: bool,
pub support_verbosity: bool,
pub default_verbosity: Option<Verbosity>,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub truncation_policy: TruncationPolicyConfig,
pub supports_parallel_tool_calls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<i64>,
/// Token threshold for automatic compaction. When omitted, core derives it
/// from `context_window` (90%).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_compact_token_limit: Option<i64>,
/// Percentage of the context window considered usable for inputs, after
/// reserving headroom for system prompts, tool overhead, and model output.
#[serde(default = "default_effective_context_window_percent")]
pub effective_context_window_percent: i64,
pub experimental_supported_tools: Vec<String>,
}
impl ModelInfo {
pub fn auto_compact_token_limit(&self) -> Option<i64> {
self.auto_compact_token_limit.or_else(|| {
self.context_window
.map(|context_window| (context_window * 9) / 10)
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelInfoUpgrade {
pub model: String,
pub migration_markdown: String,
}
impl From<&ModelUpgrade> for ModelInfoUpgrade {
fn from(upgrade: &ModelUpgrade) -> Self {
ModelInfoUpgrade {
model: upgrade.id.clone(),
migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(),
}
}
}
/// Response wrapper for `/models`.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
pub struct ModelsResponse {
pub models: Vec<ModelInfo>,
}
// convert ModelInfo to ModelPreset
impl From<ModelInfo> for ModelPreset {
fn from(info: ModelInfo) -> Self {
ModelPreset {
id: info.slug.clone(),
model: info.slug.clone(),
display_name: info.display_name,
description: info.description.unwrap_or_default(),
default_reasoning_effort: info
.default_reasoning_level
.unwrap_or(ReasoningEffort::None),
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
reasoning_effort_mapping: reasoning_effort_mapping_from_presets(
&info.supported_reasoning_levels,
),
migration_config_key: info.slug.clone(),
// todo(aibrahim): add the model link here.
model_link: None,
upgrade_copy: None,
migration_markdown: Some(upgrade.migration_markdown.clone()),
}),
show_in_picker: info.visibility == ModelVisibility::List,
supported_in_api: info.supported_in_api,
}
}
}
impl ModelPreset {
/// Filter models based on authentication mode.
///
/// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown.
pub fn filter_by_auth(models: Vec<ModelPreset>, chatgpt_mode: bool) -> Vec<ModelPreset> {
models
.into_iter()
.filter(|model| chatgpt_mode || model.supported_in_api)
.collect()
}
/// Merge remote presets with existing presets, preferring remote when slugs match.
///
/// Remote presets take precedence. Existing presets not in remote are appended with `is_default` set to false.
pub fn merge(
remote_presets: Vec<ModelPreset>,
existing_presets: Vec<ModelPreset>,
) -> Vec<ModelPreset> {
if remote_presets.is_empty() {
return existing_presets;
}
let remote_slugs: HashSet<&str> = remote_presets
.iter()
.map(|preset| preset.model.as_str())
.collect();
let mut merged_presets = remote_presets.clone();
for mut preset in existing_presets {
if remote_slugs.contains(preset.model.as_str()) {
continue;
}
preset.is_default = false;
merged_presets.push(preset);
}
merged_presets
}
}
fn reasoning_effort_mapping_from_presets(
presets: &[ReasoningEffortPreset],
) -> Option<HashMap<ReasoningEffort, ReasoningEffort>> {
if presets.is_empty() {
return None;
}
// Map every canonical effort to the closest supported effort for the new model.
let supported: Vec<ReasoningEffort> = presets.iter().map(|p| p.effort).collect();
let mut map = HashMap::new();
for effort in ReasoningEffort::iter() {
let nearest = nearest_effort(effort, &supported);
map.insert(effort, nearest);
}
Some(map)
}
fn effort_rank(effort: ReasoningEffort) -> i32 {
match effort {
ReasoningEffort::None => 0,
ReasoningEffort::Minimal => 1,
ReasoningEffort::Low => 2,
ReasoningEffort::Medium => 3,
ReasoningEffort::High => 4,
ReasoningEffort::XHigh => 5,
}
}
fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort {
let target_rank = effort_rank(target);
supported
.iter()
.copied()
.min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs())
.unwrap_or(target)
}