use super::supplement::{ClaudeModelSupplement, Converted};
use crate::transform::{Report, TransformError};
use crate::wire::{claude::models as claude_models, openai::models as openai_models};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
pub(crate) fn converted<T>(value: T, report: Report) -> Converted<T> {
Converted { value, report }
}
pub(crate) fn require<T>(value: Option<T>, field: &str) -> Result<T, TransformError> {
value.ok_or_else(|| TransformError::missing_metadata(field))
}
pub(crate) fn non_empty(value: &str, field: &str) -> Result<String, TransformError> {
if value.is_empty() {
Err(TransformError::missing_metadata(field))
} else {
Ok(value.to_owned())
}
}
pub(crate) fn choose<T: PartialEq>(
source: Option<T>,
supplement: Option<T>,
field: &str,
) -> Result<T, TransformError> {
match (source, supplement) {
(Some(source), Some(supplement)) if source != supplement => Err(TransformError::new(
crate::transform::TransformErrorKind::Conflict,
field,
"source and supplied metadata disagree",
)),
(Some(source), _) => Ok(source),
(None, Some(supplement)) => Ok(supplement),
(None, None) => Err(TransformError::missing_metadata(field)),
}
}
pub(crate) fn choose_optional<T: PartialEq>(
source: Option<T>,
supplement: Option<T>,
field: &str,
) -> Result<Option<T>, TransformError> {
match (source, supplement) {
(Some(source), Some(supplement)) if source != supplement => Err(TransformError::new(
crate::transform::TransformErrorKind::Conflict,
field,
"source model representations disagree",
)),
(Some(source), _) => Ok(Some(source)),
(None, supplement) => Ok(supplement),
}
}
pub(crate) fn openai_identity(model: &openai_models::Model) -> Result<&str, TransformError> {
if !model.id.is_empty() {
return Ok(&model.id);
}
model
.slug
.as_deref()
.filter(|slug| !slug.is_empty())
.ok_or_else(|| TransformError::shape("model.id", "model id and slug are empty"))
}
pub(crate) fn openai_thinking(model: &openai_models::Model) -> Option<bool> {
model.thinking_supported.or_else(|| {
model
.supported_reasoning_levels
.as_ref()
.and_then(|levels| {
levels
.iter()
.any(|level| level.effort != openai_models::ModelReasoningEffort::None)
.then_some(true)
})
})
}
pub(crate) fn iso_to_epoch_with_loss(value: &str) -> Result<(i64, bool), TransformError> {
OffsetDateTime::parse(value, &Rfc3339)
.map(|date| {
(
date.unix_timestamp(),
date.unix_timestamp_nanos() % 1_000_000_000 != 0,
)
})
.map_err(|error| {
TransformError::shape(
"model.created_at",
format!("invalid RFC3339 timestamp: {error}"),
)
})
}
pub(crate) fn parse_iso(value: &str) -> Result<OffsetDateTime, TransformError> {
OffsetDateTime::parse(value, &Rfc3339).map_err(|error| {
TransformError::shape(
"model.created_at",
format!("invalid RFC3339 timestamp: {error}"),
)
})
}
pub(crate) fn epoch_to_iso(value: i64) -> Result<String, TransformError> {
OffsetDateTime::from_unix_timestamp(value)
.map_err(|error| TransformError::shape("model.created", error.to_string()))?
.format(&Rfc3339)
.map_err(|error| TransformError::shape("model.created", error.to_string()))
}
pub(crate) fn openai_model_id(value: &str) -> Result<String, TransformError> {
if value.is_empty() {
Err(TransformError::shape("model.id", "model id is empty"))
} else {
Ok(value.to_owned())
}
}
pub(crate) fn gemini_name(value: &str) -> Result<String, TransformError> {
if value.is_empty() {
return Err(TransformError::shape("model.name", "model id is empty"));
}
if value.starts_with("models/") {
if value.len() == "models/".len() {
return Err(TransformError::shape("model.name", "empty models resource"));
}
Ok(value.to_owned())
} else {
Ok(format!("models/{value}"))
}
}
pub(crate) fn bare_gemini_id(value: &str) -> Result<String, TransformError> {
let Some(id) = value.strip_prefix("models/") else {
return Err(TransformError::shape(
"model.name",
"Gemini model resource must begin with models/",
));
};
if id.is_empty() {
Err(TransformError::shape("model.name", "empty models resource"))
} else {
Ok(id.to_owned())
}
}
pub(crate) fn openai_model(id: String, created: i64, owned_by: String) -> openai_models::Model {
openai_models::Model::builder(
id,
Some(created),
Some(openai_models::ModelObject::Model),
Some(owned_by),
)
.build()
}
pub(crate) fn claude_model(
id: String,
created_at: String,
display_name: String,
max_input_tokens: Option<i64>,
max_tokens: Option<i64>,
supplement: ClaudeModelSupplement,
) -> claude_models::ModelInfo {
claude_models::ModelInfo {
id,
allowed_fallback_models: Some(supplement.allowed_fallback_models),
capabilities: Some(supplement.capabilities.into_wire()),
created_at,
display_name,
line: Some(supplement.line),
lifecycle: supplement.lifecycle,
deprecated_at: supplement.deprecated_at.map(Some),
retires_at: supplement.retires_at.map(Some),
max_input_tokens,
max_tokens,
type_: claude_models::ModelType::Model,
rest: Default::default(),
}
}
pub(crate) fn merge_report(target: &mut Report, source: Report) {
target.diagnostics.extend(source.diagnostics);
}
pub(crate) fn report_openai_loss(report: &mut Report) {
report.omitted("model.owned_by", "target dialect has no owner field");
}
pub(crate) fn report_claude_loss(report: &mut Report) {
for field in [
"batch",
"citations",
"code_execution",
"server_tools",
"context_management",
"compaction",
"pdf_input",
"structured_outputs",
"thinking.types",
] {
report.omitted(
format!("model.capabilities.{field}"),
"target has no corresponding Claude capability field",
);
}
report.omitted("model.line", "target dialect has no model line field");
report.omitted(
"model.allowed_fallback_models",
"target dialect has no fallback list",
);
}
pub(crate) fn report_gemini_loss(report: &mut Report) {
report.omitted(
"model.base_model_id",
"OpenAI model objects have no base-model id",
);
report.omitted(
"model.version",
"OpenAI model objects have no model version",
);
report.omitted(
"model.temperature",
"OpenAI model objects have no temperature field",
);
report.omitted(
"model.max_temperature",
"OpenAI model objects have no max_temperature field",
);
report.omitted("model.top_p", "OpenAI model objects have no top_p field");
report.omitted("model.top_k", "OpenAI model objects have no top_k field");
}
pub(crate) fn report_openai_extensions(model: &openai_models::Model, report: &mut Report) {
macro_rules! omit {
($($field:ident),* $(,)?) => {$(
if model.$field.is_some() {
report.omitted(concat!("model.", stringify!($field)),
"target model schema has no corresponding catalog field");
}
)*};
}
omit!(
shutdown_date,
instructions,
output_modalities,
supported_parameters,
default_reasoning_level,
service_tiers,
default_service_tier,
supported_actions,
guardian,
shell_type,
visibility,
supported_in_api,
priority,
additional_speed_tiers,
available_access_programs,
availability_nux,
upgrade,
model_messages,
include_skills_usage_instructions,
include_plugin_usage_instructions,
include_apps_usage_instructions,
supports_reasoning_summary_parameter,
default_reasoning_summary,
support_verbosity,
default_verbosity,
apply_patch_tool_type,
web_search_tool_type,
truncation_policy,
supports_image_detail_original,
auto_compact_token_limit,
comp_hash,
effective_context_window_percent,
experimental_supported_tools,
supports_search_tool,
supports_experimental_context,
use_responses_lite,
supports_reasoning_effort_updates,
node_repl_auto_review_required,
node_repl_disabled,
auto_review_model_override,
model_specialty,
tool_mode,
multi_agent_version,
multi_agent_reasoning_effort,
base_instructions,
prefer_websockets,
supports_parallel_tool_calls,
supports_reasoning_summaries,
requires_sandboxed_review,
available_in_plans,
minimal_client_version,
);
if model.id.is_empty() && model.slug.is_some() {
report.changed("model.slug", "slug used as target model identity");
} else if let Some(slug) = &model.slug
&& slug != &model.id
{
report.omitted("model.slug", "target has only one model identity");
}
if model.max_context_window.is_some() {
if model.context_window.is_none() {
report.changed(
"model.max_context_window",
"maximum context window used as input token limit",
);
} else {
report.omitted(
"model.max_context_window",
"target cannot distinguish the default window from its override ceiling",
);
}
}
}
pub(crate) fn reconcile_openai_capabilities(
model: &openai_models::Model,
mut supplement: super::supplement::ClaudeModelSupplement,
) -> Result<super::supplement::ClaudeModelSupplement, TransformError> {
supplement.capabilities.thinking.supported = choose(
openai_thinking(model),
Some(supplement.capabilities.thinking.supported),
"model.thinking_supported",
)?;
if let Some(modalities) = &model.input_modalities {
supplement.capabilities.image_input = choose(
Some(modalities.iter().any(|modality| modality == "image")),
Some(supplement.capabilities.image_input),
"model.input_modalities.image",
)?;
}
if let Some(levels) = &model.supported_reasoning_levels {
use openai_models::ModelReasoningEffort as Effort;
let has = |effort| levels.iter().any(|level| level.effort == effort);
macro_rules! reconcile {
($($field:ident => $effort:ident),* $(,)?) => {$(
supplement.capabilities.effort.$field = choose(
Some(has(Effort::$effort)), Some(supplement.capabilities.effort.$field),
concat!("model.supported_reasoning_levels.", stringify!($field)),
)?;
)*};
}
reconcile!(low => Low, medium => Medium, high => High, xhigh => Xhigh, max => Max);
supplement.capabilities.effort.supported = choose(
Some(
[
Effort::Low,
Effort::Medium,
Effort::High,
Effort::Xhigh,
Effort::Max,
]
.into_iter()
.any(has),
),
Some(supplement.capabilities.effort.supported),
"model.supported_reasoning_levels",
)?;
}
Ok(supplement)
}
pub(crate) fn report_claude_lifecycle(
model: &claude_models::ModelInfo,
keeps_retirement: bool,
report: &mut Report,
) {
for (field, present) in [
("lifecycle", model.lifecycle.is_some()),
("deprecated_at", model.deprecated_at.is_some()),
(
"retires_at",
!keeps_retirement && model.retires_at.is_some(),
),
] {
if present {
report.omitted(
format!("model.{field}"),
"target has no Claude lifecycle field",
);
}
}
}
pub(crate) fn openai_shutdown_date(
model: &claude_models::ModelInfo,
report: &mut Report,
) -> Result<Option<Option<String>>, TransformError> {
let Some(retires_at) = &model.retires_at else {
return Ok(None);
};
let Some(retires_at) = retires_at else {
return Ok(Some(None));
};
let instant = OffsetDateTime::parse(retires_at, &Rfc3339).map_err(|error| {
TransformError::shape(
"model.retires_at",
format!("invalid RFC3339 timestamp: {error}"),
)
})?;
let date = instant.to_offset(time::UtcOffset::UTC).date();
if instant.to_offset(time::UtcOffset::UTC).time() != time::Time::MIDNIGHT {
report.changed(
"model.retires_at",
"OpenAI shutdown_date keeps only the UTC calendar date",
);
}
Ok(Some(Some(date.to_string())))
}