mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## Why Using Responses Lite does not establish whether a model accepts reasoning-effort `configuration_update` items. Unsupported models need to use the selected request-level effort, including when resuming history containing saved updates. ## What changed - Add `supports_reasoning_effort_updates` model metadata, defaulting to `false`. - Require the override feature, an OpenAI provider, and explicit model support for reasoning effort updates, independently of `use_responses_lite`. - Filter saved configuration updates from unsupported requests without changing persisted history. - Clear the pinned effort baseline when sampling with an unsupported model so returning to a supported model establishes a fresh baseline. Compaction uses the selected effort without clearing the pin. ## Testing Add coverage for supported models with either Responses mode, unsupported-model history filtering over HTTP and WebSocket, switching to an unsupported model and back, and compaction versus sampling pin behavior. Verify missing capability metadata defaults to `false`. GitOrigin-RevId: 8a0fbc141964bd854dbcdd6f31e1c8e97697cf22
1911 lines
70 KiB
Rust
1911 lines
70 KiB
Rust
//! Shared model metadata types exchanged between Codex services and clients.
|
|
//!
|
|
//! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults
|
|
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
|
|
|
|
use std::fmt;
|
|
use std::str::FromStr;
|
|
|
|
use chrono::DateTime;
|
|
use chrono::Utc;
|
|
use schemars::JsonSchema;
|
|
use schemars::r#gen::SchemaGenerator;
|
|
use schemars::schema::InstanceType;
|
|
use schemars::schema::Metadata;
|
|
use schemars::schema::Schema;
|
|
use schemars::schema::SchemaObject;
|
|
use schemars::schema::StringValidation;
|
|
use serde::Deserialize;
|
|
use serde::Deserializer;
|
|
use serde::Serialize;
|
|
use serde::Serializer;
|
|
use serde::de::DeserializeOwned;
|
|
use serde::de::Error;
|
|
use strum_macros::Display;
|
|
use strum_macros::EnumIter;
|
|
use tracing::warn;
|
|
use ts_rs::TS;
|
|
|
|
use crate::config_types::ReasoningSummary;
|
|
use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
|
use crate::config_types::ServiceTier;
|
|
use crate::config_types::Verbosity;
|
|
use crate::protocol::MultiAgentVersion;
|
|
|
|
mod access_programs;
|
|
#[path = "openai_models/guardian.rs"]
|
|
mod guardian;
|
|
pub use guardian::GuardianModelPolicy;
|
|
pub use guardian::GuardianReviewMode;
|
|
pub use guardian::GuardianScope;
|
|
pub use guardian::GuardianUnscoredAction;
|
|
|
|
#[path = "openai_models/guardian_v2.rs"]
|
|
mod guardian_v2;
|
|
#[path = "openai_models/reasoning_effort.rs"]
|
|
mod reasoning_effort;
|
|
|
|
pub use access_programs::ModelAccessPrograms;
|
|
pub use guardian_v2::GuardianV2ModelConfig;
|
|
pub use guardian_v2::GuardianV2TranscriptModelConfig;
|
|
|
|
/// Backend model-catalog specialty identifying cybersecurity-focused models.
|
|
pub const MODEL_SPECIALTY_CYBER: &str = "cyber";
|
|
pub const SPEED_TIER_FAST: &str = "fast";
|
|
|
|
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)]
|
|
#[ts(type = "string")]
|
|
pub enum ReasoningEffort {
|
|
None,
|
|
Minimal,
|
|
Low,
|
|
#[default]
|
|
Medium,
|
|
High,
|
|
XHigh,
|
|
Max,
|
|
Ultra,
|
|
Persistent,
|
|
/// A model-defined effort value that this client does not know yet.
|
|
Custom(String),
|
|
}
|
|
|
|
impl ReasoningEffort {
|
|
/// Returns the exact value used on the wire.
|
|
pub fn as_str(&self) -> &str {
|
|
match self {
|
|
Self::None => "none",
|
|
Self::Minimal => "minimal",
|
|
Self::Low => "low",
|
|
Self::Medium => "medium",
|
|
Self::High => "high",
|
|
Self::XHigh => "xhigh",
|
|
Self::Max => "max",
|
|
Self::Ultra => "ultra",
|
|
Self::Persistent => "persistent",
|
|
Self::Custom(effort) => effort,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ReasoningEffort {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
impl JsonSchema for ReasoningEffort {
|
|
fn schema_name() -> String {
|
|
"ReasoningEffort".to_string()
|
|
}
|
|
|
|
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
|
|
Schema::Object(SchemaObject {
|
|
instance_type: Some(InstanceType::String.into()),
|
|
metadata: Some(Box::new(Metadata {
|
|
description: Some(
|
|
"A non-empty reasoning effort value advertised by the model.".to_string(),
|
|
),
|
|
..Default::default()
|
|
})),
|
|
string: Some(Box::new(StringValidation {
|
|
min_length: Some(1),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Serialize for ReasoningEffort {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
serializer.serialize_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for ReasoningEffort {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let effort = String::deserialize(deserializer)?;
|
|
effort.parse().map_err(D::Error::custom)
|
|
}
|
|
}
|
|
|
|
impl FromStr for ReasoningEffort {
|
|
type Err = String;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"none" => Ok(Self::None),
|
|
"minimal" => Ok(Self::Minimal),
|
|
"low" => Ok(Self::Low),
|
|
"medium" => Ok(Self::Medium),
|
|
"high" => Ok(Self::High),
|
|
"xhigh" => Ok(Self::XHigh),
|
|
"max" => Ok(Self::Max),
|
|
"ultra" => Ok(Self::Ultra),
|
|
"persistent" => Ok(Self::Persistent),
|
|
"" => Err("reasoning_effort must not be empty".to_string()),
|
|
effort => Ok(Self::Custom(effort.to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Canonical user-input modality tags advertised by a model.
|
|
#[derive(
|
|
Debug,
|
|
Serialize,
|
|
Deserialize,
|
|
Clone,
|
|
Copy,
|
|
PartialEq,
|
|
Eq,
|
|
Display,
|
|
JsonSchema,
|
|
TS,
|
|
EnumIter,
|
|
Hash,
|
|
)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum InputModality {
|
|
/// Plain text turns and tool payloads.
|
|
Text,
|
|
/// Image attachments included in user turns.
|
|
Image,
|
|
/// Audio attachments included in user turns.
|
|
Audio,
|
|
}
|
|
|
|
/// Backward-compatible default when `input_modalities` is omitted on the wire.
|
|
///
|
|
/// Legacy payloads predate modality metadata, so we conservatively assume both text and images are
|
|
/// accepted unless a preset explicitly narrows support.
|
|
pub fn default_input_modalities() -> Vec<InputModality> {
|
|
vec![InputModality::Text, InputModality::Image]
|
|
}
|
|
|
|
/// 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 migration_config_key: String,
|
|
pub model_link: Option<String>,
|
|
pub upgrade_copy: Option<String>,
|
|
pub migration_markdown: Option<String>,
|
|
/// Informational time when the model associated with this upgrade is scheduled to retire.
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
deserialize_with = "deserialize_optional_rfc3339_timestamp"
|
|
)]
|
|
#[schemars(with = "Option<String>")]
|
|
#[ts(type = "string", optional)]
|
|
pub retirement_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
pub struct ModelAvailabilityNux {
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
pub struct ModelServiceTier {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub description: 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,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model_specialty: Option<String>,
|
|
/// Reasoning effort applied when none is explicitly chosen.
|
|
pub default_reasoning_effort: ReasoningEffort,
|
|
/// Supported reasoning effort options.
|
|
pub supported_reasoning_efforts: Vec<ReasoningEffortPreset>,
|
|
/// Deprecated catalog field, always false for new model presets.
|
|
#[serde(default)]
|
|
pub supports_personality: bool,
|
|
/// Deprecated: use `service_tiers` instead.
|
|
#[serde(default)]
|
|
pub additional_speed_tiers: Vec<String>,
|
|
/// Service tiers this model can run with.
|
|
#[serde(default)]
|
|
pub service_tiers: Vec<ModelServiceTier>,
|
|
/// Catalog default service tier id for this model.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub default_service_tier: Option<String>,
|
|
/// Caller-specific explicit access programs, when discovery resolved them.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub available_access_programs: Option<ModelAccessPrograms>,
|
|
/// 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,
|
|
/// Multi-agent backend selected when this model starts a new thread.
|
|
#[serde(default, skip_serializing, skip_deserializing)]
|
|
#[schemars(skip)]
|
|
#[ts(skip)]
|
|
pub multi_agent_version: Option<MultiAgentVersion>,
|
|
/// Availability NUX shown when this preset becomes accessible to the user.
|
|
pub availability_nux: Option<ModelAvailabilityNux>,
|
|
/// whether this model is supported in the api
|
|
pub supported_in_api: bool,
|
|
/// Input modalities accepted when composing user turns for this preset.
|
|
#[serde(default = "default_input_modalities")]
|
|
pub input_modalities: Vec<InputModality>,
|
|
}
|
|
|
|
/// 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 {
|
|
#[serde(alias = "default", alias = "local", alias = "shell_command")]
|
|
UnifiedExec,
|
|
Disabled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ApplyPatchToolType {
|
|
Freeform,
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema, Default,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum WebSearchToolType {
|
|
#[default]
|
|
Text,
|
|
TextAndImage,
|
|
}
|
|
|
|
/// 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)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolMode {
|
|
Direct,
|
|
CodeMode,
|
|
CodeModeOnly,
|
|
}
|
|
|
|
fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
T: DeserializeOwned,
|
|
{
|
|
let Some(value) = Option::<String>::deserialize(deserializer)? else {
|
|
return Ok(None);
|
|
};
|
|
Ok(serde_json::from_value(serde_json::Value::String(value)).ok())
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
const fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
#[allow(clippy::trivially_copy_pass_by_ref)]
|
|
const fn is_true(value: &bool) -> bool {
|
|
*value
|
|
}
|
|
|
|
/// Model metadata returned by the Codex backend `/models` endpoint.
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ModelInfo {
|
|
/// Model-owned approval coverage. Absent preserves legacy settings; an empty map disables
|
|
/// ordinary Guardian review. Keys are computer_use, shell, file_changes, mcp, network,
|
|
/// and permissions. This does not override mandatory safety or administrator requirements.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub guardian: Option<GuardianModelPolicy>,
|
|
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,
|
|
#[serde(default)]
|
|
pub additional_speed_tiers: Vec<String>,
|
|
#[serde(default)]
|
|
pub service_tiers: Vec<ModelServiceTier>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub default_service_tier: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub available_access_programs: Option<ModelAccessPrograms>,
|
|
pub availability_nux: Option<ModelAvailabilityNux>,
|
|
pub upgrade: Option<ModelInfoUpgrade>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model_messages: Option<ModelMessages>,
|
|
#[serde(default)]
|
|
pub include_skills_usage_instructions: bool,
|
|
#[serde(default)]
|
|
pub include_plugin_usage_instructions: bool,
|
|
#[serde(default = "default_true")]
|
|
pub include_apps_usage_instructions: bool,
|
|
/// Whether the model accepts the Responses API `reasoning.summary` parameter.
|
|
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
|
pub supports_reasoning_summary_parameter: bool,
|
|
#[serde(default)]
|
|
pub default_reasoning_summary: ReasoningSummary,
|
|
pub support_verbosity: bool,
|
|
pub default_verbosity: Option<Verbosity>,
|
|
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
|
#[serde(default)]
|
|
pub web_search_tool_type: WebSearchToolType,
|
|
pub truncation_policy: TruncationPolicyConfig,
|
|
#[serde(default)]
|
|
pub supports_image_detail_original: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub context_window: Option<i64>,
|
|
/// Maximum context window allowed for config overrides.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_context_window: Option<i64>,
|
|
/// Token threshold for automatic compaction. When omitted, core derives it
|
|
/// from `context_window` (90%). When provided, core clamps it to 90% of the
|
|
/// context window when available.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub auto_compact_token_limit: Option<i64>,
|
|
/// Opaque identifier for compaction-compatible model configurations.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub comp_hash: Option<String>,
|
|
/// 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>,
|
|
/// Input modalities accepted by the backend for this model.
|
|
#[serde(default = "default_input_modalities")]
|
|
pub input_modalities: Vec<InputModality>,
|
|
/// Internal-only marker set by core when a model slug resolved to fallback metadata.
|
|
#[serde(default, skip_serializing, skip_deserializing)]
|
|
#[schemars(skip)]
|
|
#[ts(skip)]
|
|
pub used_fallback_model_metadata: bool,
|
|
#[serde(default)]
|
|
pub supports_search_tool: bool,
|
|
/// Whether experimental context management may be activated at session startup.
|
|
#[serde(default)]
|
|
pub supports_experimental_context: bool,
|
|
#[serde(default)]
|
|
pub use_responses_lite: bool,
|
|
/// Whether the model accepts reasoning-effort `configuration_update` items.
|
|
/// Missing metadata keeps effort changes on the ordinary request parameter.
|
|
#[serde(default)]
|
|
pub supports_reasoning_effort_updates: bool,
|
|
#[serde(default)]
|
|
pub node_repl_auto_review_required: bool,
|
|
#[serde(default)]
|
|
pub node_repl_disabled: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub auto_review_model_override: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model_specialty: Option<String>,
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
deserialize_with = "deserialize_optional_model_selector"
|
|
)]
|
|
pub tool_mode: Option<ToolMode>,
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
deserialize_with = "deserialize_optional_model_selector"
|
|
)]
|
|
pub multi_agent_version: Option<MultiAgentVersion>,
|
|
/// Reasoning effort used for multi-agent work when the user selects Ultra.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub multi_agent_reasoning_effort: Option<ReasoningEffort>,
|
|
}
|
|
|
|
impl ModelInfo {
|
|
pub fn resolved_context_window(&self) -> Option<i64> {
|
|
self.context_window.or(self.max_context_window)
|
|
}
|
|
|
|
/// Context available to inference after reserving this model's configured headroom.
|
|
pub fn usable_context_window(&self) -> Option<i64> {
|
|
self.resolved_context_window().map(|context_window| {
|
|
context_window.saturating_mul(self.effective_context_window_percent) / 100
|
|
})
|
|
}
|
|
|
|
pub fn auto_compact_token_limit(&self) -> Option<i64> {
|
|
let context_limit = self
|
|
.resolved_context_window()
|
|
.map(|context_window| (context_window * 9) / 10);
|
|
let config_limit = self.auto_compact_token_limit;
|
|
if let Some(context_limit) = context_limit {
|
|
return Some(
|
|
config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
|
|
);
|
|
}
|
|
config_limit
|
|
}
|
|
}
|
|
|
|
/// A strongly-typed template for assembling model instructions and developer messages.
|
|
///
|
|
/// `instructions_template` is literal text. The deprecated `instructions_variables` field is
|
|
/// retained to decode catalogs produced before personality selection was removed.
|
|
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ModelMessages {
|
|
/// Additional developer instructions for persistent mode. Missing or null uses the built-in
|
|
/// instructions; an empty string disables them.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub persistent_instructions: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tools: Option<ToolMessages>,
|
|
pub instructions_template: Option<String>,
|
|
pub instructions_variables: Option<ModelInstructionsVariables>,
|
|
pub approvals: Option<ApprovalMessages>,
|
|
pub collaboration_modes: Option<CollaborationModeMessages>,
|
|
pub auto_review: Option<AutoReviewMessages>,
|
|
pub permissions: Option<PermissionMessages>,
|
|
pub multi_agent: Option<MultiAgentMessages>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub token_budget: Option<ModelTokenBudgetConfig>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub guardian_v2: Option<GuardianV2ModelConfig>,
|
|
/// Replacement confirmation-policy documents forwarded in actor MCP request metadata.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub confirmation_policies: Option<ConfirmationPolicies>,
|
|
}
|
|
|
|
/// Model-owned confirmation-policy Markdown, forwarded unchanged to actor tools.
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ConfirmationPolicies {
|
|
/// Replacement Markdown for the Browser Use confirmation-policy document.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub browser_use: Option<String>,
|
|
/// Replacement Markdown for the native Computer Use confirmation policy.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub computer_use: Option<String>,
|
|
}
|
|
|
|
/// Model-owned messages for built-in tools.
|
|
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ToolMessages {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub send_user_message_async: Option<ToolMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub multi_agent: Option<MultiAgentToolMessages>,
|
|
}
|
|
|
|
/// Model-owned messages for a built-in tool.
|
|
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ToolMessage {
|
|
/// Missing or null uses the built-in description; an empty string suppresses its static
|
|
/// text without disabling the tool. Tool-owned runtime guidance is retained.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
/// Complete JSON Schema encoded as a string. Consumed by Multi-Agent V2 tools only.
|
|
/// Uses the harness's supported schema subset; unrecognized keywords are ignored.
|
|
/// Missing, null, invalid or unsupported structures, or a root without `type: "object"`
|
|
/// retains the harness parameters. Schema semantics must remain API-compatible.
|
|
/// Overrides must declare harness-encrypted properties so their annotations can be retained.
|
|
/// Argument handling is unchanged.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub parameters: Option<String>,
|
|
}
|
|
|
|
/// Model-owned descriptions and parameters for Multi-Agent V2 tools, independent of their namespace.
|
|
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct MultiAgentToolMessages {
|
|
/// Replaces the static description. Missing or null uses the bundled text; an empty string
|
|
/// suppresses it. Generated model information and local usage hints are retained.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub spawn_agent: Option<ToolMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub send_message: Option<ToolMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub followup_task: Option<ToolMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub wait_agent: Option<ToolMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub interrupt_agent: Option<ToolMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub list_agents: Option<ToolMessage>,
|
|
}
|
|
|
|
/// Model-owned defaults for the context-window token-budget feature.
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ModelTokenBudgetConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub use_history_notes_extension: bool,
|
|
pub reminder_threshold_tokens: i64,
|
|
pub reminder_message_template: String,
|
|
pub guidance_message: String,
|
|
pub auto_compact_fallback_prompt: String,
|
|
pub auto_compact_fallback_buffer_tokens: i64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ApprovalMessages {
|
|
pub on_request: Option<String>,
|
|
pub on_request_auto_review: Option<String>,
|
|
pub never: Option<String>,
|
|
pub unless_trusted: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct CollaborationModeMessages {
|
|
pub default: Option<String>,
|
|
pub plan: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct AutoReviewMessages {
|
|
pub policy: Option<String>,
|
|
pub policy_template: Option<String>,
|
|
/// Extra developer policy for `node_repl` and `cua_repl` reviews.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub node_repl_policy: Option<String>,
|
|
pub rejection_instructions: Option<String>,
|
|
pub timeout_instructions: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct PermissionMessages {
|
|
pub danger_full_access: Option<String>,
|
|
pub workspace_write: Option<String>,
|
|
pub read_only: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct MultiAgentMessages {
|
|
pub role: Option<MultiAgentRoleMessages>,
|
|
pub mode: Option<MultiAgentModeMessages>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct MultiAgentRoleMessages {
|
|
pub root: Option<String>,
|
|
pub subagent: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct MultiAgentModeMessages {
|
|
pub explicit: Option<String>,
|
|
/// Ultra-only mode instructions. Missing or null uses the built-in proactive hint;
|
|
/// an empty string suppresses the mode message. `hint_text` takes precedence.
|
|
pub proactive: Option<String>,
|
|
pub hint_text: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ModelInstructionsVariables {
|
|
pub personality_default: Option<String>,
|
|
pub personality_friendly: Option<String>,
|
|
pub personality_pragmatic: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
|
pub struct ModelInfoUpgrade {
|
|
pub model: String,
|
|
pub migration_markdown: String,
|
|
/// Informational time when the model associated with this upgrade is scheduled to retire.
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
deserialize_with = "deserialize_optional_rfc3339_timestamp"
|
|
)]
|
|
#[schemars(with = "Option<String>")]
|
|
#[ts(type = "string", optional)]
|
|
pub retirement_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
fn deserialize_optional_rfc3339_timestamp<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Option<DateTime<Utc>>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
|
|
Ok(value
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_str)
|
|
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
|
.map(|value| value.with_timezone(&Utc)))
|
|
}
|
|
|
|
impl From<&ModelUpgrade> for ModelInfoUpgrade {
|
|
fn from(upgrade: &ModelUpgrade) -> Self {
|
|
ModelInfoUpgrade {
|
|
model: upgrade.id.clone(),
|
|
migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(),
|
|
retirement_at: upgrade.retirement_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Response wrapper for `/models`.
|
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
|
|
pub struct ModelsResponse {
|
|
#[serde(
|
|
serialize_with = "serialize_model_infos_with_legacy_base",
|
|
deserialize_with = "deserialize_model_infos_with_legacy_base"
|
|
)]
|
|
pub models: Vec<ModelInfo>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ModelInfoWithLegacyBaseInstructionsRef<'a> {
|
|
#[serde(flatten)]
|
|
model: &'a ModelInfo,
|
|
base_instructions: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ModelInfoWithLegacyBaseInstructions {
|
|
#[serde(default)]
|
|
base_instructions: Option<String>,
|
|
#[serde(flatten)]
|
|
model: ModelInfo,
|
|
}
|
|
|
|
/// Serializes catalog models with the deprecated top-level instruction field required by older
|
|
/// clients.
|
|
#[doc(hidden)]
|
|
pub fn serialize_model_infos_with_legacy_base<S>(
|
|
models: &[ModelInfo],
|
|
serializer: S,
|
|
) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
models
|
|
.iter()
|
|
.map(|model| {
|
|
// Mirror the literal instruction template for clients using the deprecated wire field.
|
|
let base_instructions = if let Some(template) = model
|
|
.model_messages
|
|
.as_ref()
|
|
.and_then(|messages| messages.instructions_template.as_deref())
|
|
{
|
|
template.to_owned()
|
|
} else {
|
|
warn!(
|
|
model = %model.slug,
|
|
"Model has no instruction template; returning empty instructions."
|
|
);
|
|
String::new()
|
|
};
|
|
ModelInfoWithLegacyBaseInstructionsRef {
|
|
model,
|
|
base_instructions,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.serialize(serializer)
|
|
}
|
|
|
|
/// Deserializes catalog models while promoting the legacy top-level instruction field into Model
|
|
/// Messages V2 when no canonical instruction template is present.
|
|
#[doc(hidden)]
|
|
pub fn deserialize_model_infos_with_legacy_base<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Vec<ModelInfo>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let models = Vec::<ModelInfoWithLegacyBaseInstructions>::deserialize(deserializer)?;
|
|
models
|
|
.into_iter()
|
|
.map(|legacy_model| {
|
|
let ModelInfoWithLegacyBaseInstructions {
|
|
base_instructions,
|
|
mut model,
|
|
} = legacy_model;
|
|
if let Some(base_instructions) = base_instructions
|
|
&& model
|
|
.model_messages
|
|
.as_ref()
|
|
.and_then(|messages| messages.instructions_template.as_ref())
|
|
.is_none()
|
|
{
|
|
let messages = model.model_messages.get_or_insert_default();
|
|
messages.instructions_template = Some(base_instructions);
|
|
}
|
|
if model
|
|
.model_messages
|
|
.as_ref()
|
|
.and_then(|messages| messages.instructions_template.as_ref())
|
|
.is_none()
|
|
{
|
|
let model_slug = &model.slug;
|
|
return Err(D::Error::custom(format!(
|
|
"model `{model_slug}` is missing both `base_instructions` and \
|
|
`model_messages.instructions_template`"
|
|
)));
|
|
}
|
|
Ok(model)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// 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(),
|
|
model_specialty: info.model_specialty,
|
|
default_reasoning_effort: info
|
|
.default_reasoning_level
|
|
.unwrap_or(ReasoningEffort::None),
|
|
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
|
|
supports_personality: false,
|
|
additional_speed_tiers: info.additional_speed_tiers,
|
|
service_tiers: info.service_tiers,
|
|
default_service_tier: info.default_service_tier,
|
|
available_access_programs: info.available_access_programs,
|
|
is_default: false, // default is the highest priority available model
|
|
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
|
|
id: upgrade.model.clone(),
|
|
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()),
|
|
retirement_at: upgrade.retirement_at,
|
|
}),
|
|
show_in_picker: info.visibility == ModelVisibility::List,
|
|
multi_agent_version: info.multi_agent_version,
|
|
availability_nux: info.availability_nux,
|
|
supported_in_api: info.supported_in_api,
|
|
input_modalities: info.input_modalities,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ModelPreset {
|
|
pub fn supports_fast_mode(&self) -> bool {
|
|
self.service_tiers
|
|
.iter()
|
|
.any(|tier| tier.id == ServiceTier::Fast.request_value())
|
|
|| self
|
|
.additional_speed_tiers
|
|
.iter()
|
|
.any(|tier| tier == SPEED_TIER_FAST)
|
|
}
|
|
}
|
|
|
|
impl ModelInfo {
|
|
pub fn supports_service_tier(&self, service_tier: &str) -> bool {
|
|
// Flex is an API request option, even when the Codex catalog does not advertise it.
|
|
service_tier == ServiceTier::Flex.request_value()
|
|
|| self
|
|
.service_tiers
|
|
.iter()
|
|
.any(|tier| tier.id == service_tier)
|
|
}
|
|
|
|
pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> {
|
|
service_tier.filter(|service_tier| {
|
|
service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE
|
|
&& self.supports_service_tier(service_tier)
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// Recompute the single default preset using picker visibility.
|
|
///
|
|
/// The first picker-visible model wins; if none are picker-visible, the first model wins.
|
|
pub fn mark_default_by_picker_visibility(models: &mut [ModelPreset]) {
|
|
for preset in models.iter_mut() {
|
|
preset.is_default = false;
|
|
}
|
|
if let Some(default) = models.iter_mut().find(|preset| preset.show_in_picker) {
|
|
default.is_default = true;
|
|
} else if let Some(default) = models.first_mut() {
|
|
default.is_default = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::from_str;
|
|
use serde_json::to_string;
|
|
|
|
#[test]
|
|
fn legacy_shell_model_metadata_deserializes_as_unified_exec() {
|
|
for legacy_shell_type in ["default", "local", "shell_command"] {
|
|
assert_eq!(
|
|
from_str::<ConfigShellToolType>(&format!("\"{legacy_shell_type}\""))
|
|
.expect("legacy shell type"),
|
|
ConfigShellToolType::UnifiedExec
|
|
);
|
|
}
|
|
assert_eq!(
|
|
to_string(&ConfigShellToolType::UnifiedExec).expect("serialize unified shell type"),
|
|
"\"unified_exec\""
|
|
);
|
|
}
|
|
|
|
pub(super) fn test_model(spec: Option<ModelMessages>) -> ModelInfo {
|
|
ModelInfo {
|
|
slug: "test-model".to_string(),
|
|
display_name: "Test Model".to_string(),
|
|
description: None,
|
|
default_reasoning_level: None,
|
|
supported_reasoning_levels: vec![],
|
|
shell_type: ConfigShellToolType::UnifiedExec,
|
|
visibility: ModelVisibility::List,
|
|
supported_in_api: true,
|
|
priority: 1,
|
|
additional_speed_tiers: Vec::new(),
|
|
service_tiers: Vec::new(),
|
|
default_service_tier: None,
|
|
available_access_programs: None,
|
|
availability_nux: None,
|
|
upgrade: None,
|
|
model_messages: spec,
|
|
include_skills_usage_instructions: false,
|
|
include_plugin_usage_instructions: false,
|
|
include_apps_usage_instructions: false,
|
|
supports_reasoning_summary_parameter: true,
|
|
default_reasoning_summary: ReasoningSummary::Auto,
|
|
support_verbosity: false,
|
|
default_verbosity: None,
|
|
apply_patch_tool_type: None,
|
|
web_search_tool_type: WebSearchToolType::Text,
|
|
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
|
|
supports_image_detail_original: false,
|
|
context_window: None,
|
|
max_context_window: None,
|
|
auto_compact_token_limit: None,
|
|
comp_hash: None,
|
|
effective_context_window_percent: 95,
|
|
experimental_supported_tools: vec![],
|
|
input_modalities: default_input_modalities(),
|
|
used_fallback_model_metadata: false,
|
|
supports_search_tool: false,
|
|
supports_experimental_context: false,
|
|
use_responses_lite: false,
|
|
supports_reasoning_effort_updates: false,
|
|
guardian: None,
|
|
node_repl_auto_review_required: false,
|
|
node_repl_disabled: false,
|
|
auto_review_model_override: None,
|
|
model_specialty: None,
|
|
tool_mode: None,
|
|
multi_agent_version: None,
|
|
multi_agent_reasoning_effort: None,
|
|
}
|
|
}
|
|
#[test]
|
|
fn model_messages_deserialize_without_optional_sections() {
|
|
let messages: ModelMessages = from_str(
|
|
r#"{"instructions_template":null,"instructions_variables":null,"persistent_instructions":null}"#,
|
|
)
|
|
.expect("model messages should deserialize");
|
|
|
|
assert_eq!(messages, ModelMessages::default());
|
|
}
|
|
|
|
#[test]
|
|
fn send_user_message_async_description_preserves_missing_null_and_empty_values() {
|
|
for (value, expected) in [
|
|
(serde_json::json!({}), None),
|
|
(serde_json::json!({"tools": null}), None),
|
|
(serde_json::json!({"tools": {}}), Some(None)),
|
|
(
|
|
serde_json::json!({"tools": {"send_user_message_async": null}}),
|
|
Some(None),
|
|
),
|
|
(
|
|
serde_json::json!({"tools": {"send_user_message_async": {}}}),
|
|
Some(Some(ToolMessage::default())),
|
|
),
|
|
(
|
|
serde_json::json!({"tools": {"send_user_message_async": {"description": null}}}),
|
|
Some(Some(ToolMessage::default())),
|
|
),
|
|
(
|
|
serde_json::json!({"tools": {"send_user_message_async": {"description": ""}}}),
|
|
Some(Some(ToolMessage {
|
|
description: Some(String::new()),
|
|
..Default::default()
|
|
})),
|
|
),
|
|
(
|
|
serde_json::json!({"tools": {"send_user_message_async": {"description": "Catalog description"}}}),
|
|
Some(Some(ToolMessage {
|
|
description: Some("Catalog description".to_string()),
|
|
..Default::default()
|
|
})),
|
|
),
|
|
] {
|
|
let messages: ModelMessages =
|
|
serde_json::from_value(value).expect("model messages should deserialize");
|
|
let serialized =
|
|
serde_json::to_value(&messages).expect("model messages should serialize");
|
|
assert_eq!(
|
|
(
|
|
messages.tools,
|
|
serialized
|
|
.get("tools")
|
|
.map(|tools| tools.get("send_user_message_async").cloned()),
|
|
),
|
|
(
|
|
expected.as_ref().map(|tool| ToolMessages {
|
|
send_user_message_async: tool.clone(),
|
|
..Default::default()
|
|
}),
|
|
expected.map(|tool| tool.map(|tool| match tool.description {
|
|
Some(description) => serde_json::json!({"description": description}),
|
|
None => serde_json::json!({}),
|
|
})),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_agent_messages_preserve_sparse_and_empty_values() {
|
|
for (value, expected) in [
|
|
(serde_json::json!({}), serde_json::json!({})),
|
|
(
|
|
serde_json::json!({"multi_agent": null}),
|
|
serde_json::json!({}),
|
|
),
|
|
(
|
|
serde_json::json!({"multi_agent": {"spawn_agent": null}}),
|
|
serde_json::json!({"multi_agent": {}}),
|
|
),
|
|
(
|
|
serde_json::json!({"multi_agent": {"spawn_agent": {"description": null}}}),
|
|
serde_json::json!({"multi_agent": {"spawn_agent": {}}}),
|
|
),
|
|
(
|
|
serde_json::json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn"}}}),
|
|
serde_json::json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn"}}}),
|
|
),
|
|
(
|
|
serde_json::json!({"multi_agent": {"spawn_agent": {"description": ""}}}),
|
|
serde_json::json!({"multi_agent": {"spawn_agent": {"description": ""}}}),
|
|
),
|
|
] {
|
|
let tools: ToolMessages =
|
|
serde_json::from_value(value).expect("deserialize tool messages");
|
|
assert_eq!(
|
|
serde_json::to_value(&tools).expect("serialize tool messages"),
|
|
expected
|
|
);
|
|
let restored: ToolMessages =
|
|
serde_json::from_value(expected).expect("restore tool messages");
|
|
assert_eq!(restored, tools);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn approval_messages_preserve_missing_and_empty_values() {
|
|
let messages: ModelMessages = from_str(
|
|
r#"{
|
|
"instructions_template": null,
|
|
"instructions_variables": null,
|
|
"approvals": {
|
|
"on_request": "",
|
|
"never": ""
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("approval messages should deserialize");
|
|
|
|
assert_eq!(
|
|
messages.approvals,
|
|
Some(ApprovalMessages {
|
|
on_request: Some(String::new()),
|
|
on_request_auto_review: None,
|
|
never: Some(String::new()),
|
|
unless_trusted: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_review_messages_preserve_missing_and_empty_values() {
|
|
let missing_template: ModelMessages = from_str(
|
|
r#"{
|
|
"instructions_template": null,
|
|
"instructions_variables": null,
|
|
"auto_review": {
|
|
"policy": "policy"
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("auto-review messages should deserialize without a policy template");
|
|
let empty_template: ModelMessages = from_str(
|
|
r#"{
|
|
"instructions_template": null,
|
|
"instructions_variables": null,
|
|
"auto_review": {
|
|
"policy": "policy",
|
|
"policy_template": "",
|
|
"node_repl_policy": "",
|
|
"rejection_instructions": "",
|
|
"timeout_instructions": ""
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("auto-review messages should deserialize with an empty policy template");
|
|
|
|
assert_eq!(
|
|
missing_template.auto_review,
|
|
Some(AutoReviewMessages {
|
|
policy: Some("policy".to_string()),
|
|
policy_template: None,
|
|
node_repl_policy: None,
|
|
rejection_instructions: None,
|
|
timeout_instructions: None,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
empty_template.auto_review,
|
|
Some(AutoReviewMessages {
|
|
policy: Some("policy".to_string()),
|
|
policy_template: Some(String::new()),
|
|
node_repl_policy: Some(String::new()),
|
|
rejection_instructions: Some(String::new()),
|
|
timeout_instructions: Some(String::new()),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_messages_preserve_missing_and_empty_values() {
|
|
let messages: ModelMessages = from_str(
|
|
r#"{
|
|
"instructions_template": null,
|
|
"instructions_variables": null,
|
|
"permissions": {
|
|
"workspace_write": ""
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("permission messages should deserialize");
|
|
|
|
assert_eq!(
|
|
messages.permissions,
|
|
Some(PermissionMessages {
|
|
danger_full_access: None,
|
|
workspace_write: Some(String::new()),
|
|
read_only: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_agent_messages_preserve_missing_and_empty_values() {
|
|
let messages: ModelMessages = from_str(
|
|
r#"{"instructions_template":null,"instructions_variables":null,"multi_agent":{"role":{"root":"","subagent":"subagent base"},"mode":{"explicit":"explicit mode","proactive":"","hint_text":""}}}"#,
|
|
)
|
|
.expect("multi-agent messages should deserialize");
|
|
|
|
assert_eq!(
|
|
messages.multi_agent,
|
|
Some(MultiAgentMessages {
|
|
role: Some(MultiAgentRoleMessages {
|
|
root: Some(String::new()),
|
|
subagent: Some("subagent base".to_string()),
|
|
}),
|
|
mode: Some(MultiAgentModeMessages {
|
|
explicit: Some("explicit mode".to_string()),
|
|
proactive: Some(String::new()),
|
|
hint_text: Some(String::new()),
|
|
}),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collaboration_mode_messages_preserve_missing_and_empty_values() {
|
|
let messages: ModelMessages = from_str(
|
|
r#"{
|
|
"instructions_template": null,
|
|
"instructions_variables": null,
|
|
"collaboration_modes": {
|
|
"default": ""
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("collaboration mode messages should deserialize");
|
|
|
|
assert_eq!(
|
|
messages,
|
|
ModelMessages {
|
|
collaboration_modes: Some(CollaborationModeMessages {
|
|
default: Some(String::new()),
|
|
plan: None,
|
|
}),
|
|
..Default::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_effort_accepts_known_and_custom_values() {
|
|
let custom = ReasoningEffort::Custom("future".to_string());
|
|
let deserialized = from_str::<ReasoningEffort>(r#""future""#)
|
|
.expect("custom reasoning effort should deserialize");
|
|
let serialized = to_string(&custom).expect("custom reasoning effort should serialize");
|
|
let serialized_max = to_string(&ReasoningEffort::Max).expect("Max should serialize");
|
|
let serialized_ultra = to_string(&ReasoningEffort::Ultra).expect("Ultra should serialize");
|
|
let serialized_persistent =
|
|
to_string(&ReasoningEffort::Persistent).expect("Persistent should serialize");
|
|
|
|
assert_eq!(
|
|
(
|
|
"high".parse(),
|
|
"max".parse(),
|
|
"ultra".parse(),
|
|
"persistent".parse(),
|
|
"future".parse(),
|
|
deserialized,
|
|
serialized,
|
|
serialized_max,
|
|
serialized_ultra,
|
|
serialized_persistent,
|
|
custom.to_string(),
|
|
),
|
|
(
|
|
Ok(ReasoningEffort::High),
|
|
Ok(ReasoningEffort::Max),
|
|
Ok(ReasoningEffort::Ultra),
|
|
Ok(ReasoningEffort::Persistent),
|
|
Ok(custom.clone()),
|
|
custom,
|
|
r#""future""#.to_string(),
|
|
r#""max""#.to_string(),
|
|
r#""ultra""#.to_string(),
|
|
r#""persistent""#.to_string(),
|
|
"future".to_string(),
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_effort_rejects_empty_values() {
|
|
assert_eq!(
|
|
"".parse::<ReasoningEffort>(),
|
|
Err("reasoning_effort must not be empty".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_effort_json_schema_is_an_open_string() {
|
|
let mut effort_generator = SchemaGenerator::default();
|
|
|
|
assert_eq!(
|
|
ReasoningEffort::json_schema(&mut effort_generator),
|
|
Schema::Object(SchemaObject {
|
|
instance_type: Some(InstanceType::String.into()),
|
|
metadata: Some(Box::new(Metadata {
|
|
description: Some(
|
|
"A non-empty reasoning effort value advertised by the model.".to_string(),
|
|
),
|
|
..Default::default()
|
|
})),
|
|
string: Some(Box::new(StringValidation {
|
|
min_length: Some(1),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_response_promotes_legacy_base_instructions() {
|
|
let mut value = serde_json::to_value(ModelsResponse {
|
|
models: vec![test_model(/*spec*/ None)],
|
|
})
|
|
.expect("serialize models response");
|
|
value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions");
|
|
|
|
let response: ModelsResponse =
|
|
serde_json::from_value(value).expect("deserialize legacy models response");
|
|
let model = &response.models[0];
|
|
|
|
assert_eq!(
|
|
model.model_messages,
|
|
Some(ModelMessages {
|
|
instructions_template: Some("legacy instructions".to_string()),
|
|
..Default::default()
|
|
})
|
|
);
|
|
let serialized = serde_json::to_value(response).expect("serialize canonical response");
|
|
assert_eq!(
|
|
serialized["models"][0]["base_instructions"],
|
|
"legacy instructions"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_response_rejects_model_without_instruction_source() {
|
|
let mut value = serde_json::to_value(ModelsResponse {
|
|
models: vec![test_model(/*spec*/ None)],
|
|
})
|
|
.expect("serialize models response");
|
|
value["models"][0]
|
|
.as_object_mut()
|
|
.expect("model should serialize as an object")
|
|
.remove("base_instructions")
|
|
.expect("serialized model should include legacy base instructions");
|
|
|
|
let error = serde_json::from_value::<ModelsResponse>(value)
|
|
.expect_err("model without instructions should be rejected");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"model `test-model` is missing both `base_instructions` and \
|
|
`model_messages.instructions_template`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_response_serializes_literal_legacy_base_instructions() {
|
|
for (template, expected) in [
|
|
(
|
|
Some("before {{ personality }} after"),
|
|
"before {{ personality }} after",
|
|
),
|
|
(Some(""), ""),
|
|
(None, ""),
|
|
] {
|
|
let response = ModelsResponse {
|
|
models: vec![test_model(Some(ModelMessages {
|
|
instructions_template: template.map(str::to_owned),
|
|
instructions_variables: Some(ModelInstructionsVariables {
|
|
personality_default: Some("default".to_string()),
|
|
personality_friendly: Some("friendly".to_string()),
|
|
personality_pragmatic: Some("pragmatic".to_string()),
|
|
}),
|
|
..Default::default()
|
|
}))],
|
|
};
|
|
let serialized = serde_json::to_value(response).expect("serialize models response");
|
|
assert_eq!(serialized["models"][0]["base_instructions"], expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn models_response_prefers_template_and_preserves_message_siblings() {
|
|
let messages = ModelMessages {
|
|
persistent_instructions: Some("Persistent catalog instructions".to_string()),
|
|
tools: Some(ToolMessages {
|
|
send_user_message_async: Some(ToolMessage {
|
|
description: Some("Catalog description".to_string()),
|
|
..Default::default()
|
|
}),
|
|
multi_agent: Some(MultiAgentToolMessages {
|
|
spawn_agent: Some(ToolMessage {
|
|
description: Some("Catalog spawn description".to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
}),
|
|
instructions_template: None,
|
|
instructions_variables: None,
|
|
approvals: Some(ApprovalMessages {
|
|
on_request: Some("approval".to_string()),
|
|
on_request_auto_review: None,
|
|
never: Some("never approval".to_string()),
|
|
unless_trusted: Some("unless-trusted approval".to_string()),
|
|
}),
|
|
collaboration_modes: Some(CollaborationModeMessages {
|
|
default: Some("default collaboration".to_string()),
|
|
plan: Some("plan collaboration".to_string()),
|
|
}),
|
|
auto_review: Some(AutoReviewMessages {
|
|
policy: Some("policy".to_string()),
|
|
policy_template: None,
|
|
node_repl_policy: None,
|
|
rejection_instructions: Some("rejection instructions".to_string()),
|
|
timeout_instructions: Some("timeout instructions".to_string()),
|
|
}),
|
|
permissions: Some(PermissionMessages {
|
|
danger_full_access: None,
|
|
workspace_write: Some("workspace".to_string()),
|
|
read_only: None,
|
|
}),
|
|
multi_agent: None,
|
|
token_budget: None,
|
|
confirmation_policies: Some(ConfirmationPolicies {
|
|
browser_use: Some(
|
|
"# Browser confirmations\n\nKeep {{literal_markdown}}.\n".to_string(),
|
|
),
|
|
computer_use: Some(
|
|
" # Native confirmations\r\n\nKeep ${native_markdown}.\n".to_string(),
|
|
),
|
|
}),
|
|
guardian_v2: Some(GuardianV2ModelConfig {
|
|
classifier_instructions: Some("Guardian classification".to_string()),
|
|
review_threshold_basis_points: Some(7_500),
|
|
reasoning_effort: Some(ReasoningEffort::Minimal),
|
|
transcript: Some(GuardianV2TranscriptModelConfig {
|
|
sources: Some(vec!["reasoning".to_string()]),
|
|
max_tool_entry_tokens: Some(500),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let mut value = serde_json::to_value(ModelsResponse {
|
|
models: vec![test_model(Some(messages.clone()))],
|
|
})
|
|
.expect("serialize models response");
|
|
assert_eq!(
|
|
value["models"][0]["model_messages"]["confirmation_policies"],
|
|
serde_json::json!({
|
|
"browser_use": "# Browser confirmations\n\nKeep {{literal_markdown}}.\n",
|
|
"computer_use": " # Native confirmations\r\n\nKeep ${native_markdown}.\n",
|
|
})
|
|
);
|
|
value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions");
|
|
|
|
let response: ModelsResponse =
|
|
serde_json::from_value(value).expect("deserialize legacy models response");
|
|
let mut expected_messages = messages;
|
|
expected_messages.instructions_template = Some("legacy instructions".to_string());
|
|
assert_eq!(response.models[0].model_messages, Some(expected_messages));
|
|
|
|
let canonical_messages = ModelMessages {
|
|
persistent_instructions: Some(String::new()),
|
|
tools: Some(ToolMessages {
|
|
send_user_message_async: Some(ToolMessage {
|
|
description: Some(String::new()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
instructions_template: Some("canonical instructions".to_string()),
|
|
..Default::default()
|
|
};
|
|
let mut value = serde_json::to_value(ModelsResponse {
|
|
models: vec![test_model(Some(canonical_messages.clone()))],
|
|
})
|
|
.expect("serialize models response");
|
|
value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions");
|
|
|
|
let response: ModelsResponse =
|
|
serde_json::from_value(value).expect("deserialize mixed models response");
|
|
assert_eq!(response.models[0].model_messages, Some(canonical_messages));
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_defaults_availability_nux_to_none_when_omitted() {
|
|
let model: ModelInfo = serde_json::from_value(serde_json::json!({
|
|
"slug": "test-model",
|
|
"display_name": "Test Model",
|
|
"description": null,
|
|
"supported_reasoning_levels": [],
|
|
"shell_type": "unified_exec",
|
|
"visibility": "list",
|
|
"supported_in_api": true,
|
|
"priority": 1,
|
|
"upgrade": null,
|
|
"model_messages": null,
|
|
"default_reasoning_summary": "auto",
|
|
"support_verbosity": false,
|
|
"default_verbosity": null,
|
|
"apply_patch_tool_type": null,
|
|
"truncation_policy": {
|
|
"mode": "bytes",
|
|
"limit": 10000
|
|
},
|
|
"supports_image_detail_original": false,
|
|
"context_window": null,
|
|
"auto_compact_token_limit": null,
|
|
"effective_context_window_percent": 95,
|
|
"experimental_supported_tools": []
|
|
}))
|
|
.expect("deserialize model info");
|
|
|
|
assert_eq!(model.availability_nux, None);
|
|
assert_eq!(
|
|
model.input_modalities,
|
|
vec![InputModality::Text, InputModality::Image]
|
|
);
|
|
assert!(!model.include_skills_usage_instructions);
|
|
assert!(!model.include_plugin_usage_instructions);
|
|
assert!(model.include_apps_usage_instructions);
|
|
assert!(model.supports_reasoning_summary_parameter);
|
|
assert!(!model.supports_image_detail_original);
|
|
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
|
|
assert!(!model.supports_search_tool);
|
|
assert!(!model.supports_experimental_context);
|
|
assert!(!model.use_responses_lite);
|
|
assert!(!model.supports_reasoning_effort_updates);
|
|
assert!(!model.node_repl_auto_review_required);
|
|
assert!(!model.node_repl_disabled);
|
|
assert_eq!(model.comp_hash, None);
|
|
assert_eq!(model.auto_review_model_override, None);
|
|
assert_eq!(model.tool_mode, None);
|
|
assert_eq!(model.multi_agent_reasoning_effort, None);
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_deserializes_multi_agent_reasoning_effort() {
|
|
let mut value =
|
|
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
|
value
|
|
.as_object_mut()
|
|
.expect("model info should be an object")
|
|
.insert(
|
|
"multi_agent_reasoning_effort".to_string(),
|
|
serde_json::Value::String("high".to_string()),
|
|
);
|
|
|
|
let model = serde_json::from_value::<ModelInfo>(value)
|
|
.expect("deserialize multi-agent reasoning effort");
|
|
|
|
assert_eq!(
|
|
model.multi_agent_reasoning_effort,
|
|
Some(ReasoningEffort::High)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_deserializes_optional_upgrade_retirement_at() {
|
|
let base = serde_json::to_value(test_model(/*spec*/ None))
|
|
.expect("serialize test model without retirement time");
|
|
|
|
let mut absent = base.clone();
|
|
absent
|
|
.as_object_mut()
|
|
.expect("model info should be an object")
|
|
.insert(
|
|
"upgrade".to_string(),
|
|
serde_json::json!({
|
|
"model": "replacement-model",
|
|
"migration_markdown": "Use the replacement model."
|
|
}),
|
|
);
|
|
let absent = serde_json::from_value::<ModelInfo>(absent)
|
|
.expect("deserialize model info without upgrade retirement time");
|
|
assert_eq!(
|
|
absent
|
|
.upgrade
|
|
.as_ref()
|
|
.and_then(|upgrade| upgrade.retirement_at.as_ref())
|
|
.map(DateTime::timestamp),
|
|
None
|
|
);
|
|
|
|
let mut null = base.clone();
|
|
null.as_object_mut()
|
|
.expect("model info should be an object")
|
|
.insert(
|
|
"upgrade".to_string(),
|
|
serde_json::json!({
|
|
"model": "replacement-model",
|
|
"migration_markdown": "Use the replacement model.",
|
|
"retirement_at": null
|
|
}),
|
|
);
|
|
let null = serde_json::from_value::<ModelInfo>(null)
|
|
.expect("deserialize model info with null upgrade retirement time");
|
|
assert_eq!(
|
|
null.upgrade
|
|
.as_ref()
|
|
.and_then(|upgrade| upgrade.retirement_at.as_ref())
|
|
.map(DateTime::timestamp),
|
|
None
|
|
);
|
|
|
|
let mut populated = base;
|
|
populated
|
|
.as_object_mut()
|
|
.expect("model info should be an object")
|
|
.insert(
|
|
"upgrade".to_string(),
|
|
serde_json::json!({
|
|
"model": "replacement-model",
|
|
"migration_markdown": "Use the replacement model.",
|
|
"retirement_at": "2030-01-01T00:00:00Z"
|
|
}),
|
|
);
|
|
let populated = serde_json::from_value::<ModelInfo>(populated)
|
|
.expect("deserialize model info with upgrade retirement time");
|
|
assert_eq!(
|
|
populated
|
|
.upgrade
|
|
.as_ref()
|
|
.and_then(|upgrade| upgrade.retirement_at.as_ref())
|
|
.map(DateTime::timestamp),
|
|
Some(1_893_456_000)
|
|
);
|
|
|
|
let mut malformed = serde_json::to_value(test_model(/*spec*/ None))
|
|
.expect("serialize test model for malformed retirement time");
|
|
malformed
|
|
.as_object_mut()
|
|
.expect("model info should be an object")
|
|
.insert(
|
|
"upgrade".to_string(),
|
|
serde_json::json!({
|
|
"model": "replacement-model",
|
|
"migration_markdown": "Use the replacement model.",
|
|
"retirement_at": "not-a-timestamp"
|
|
}),
|
|
);
|
|
let malformed = serde_json::from_value::<ModelInfo>(malformed)
|
|
.expect("tolerate malformed upgrade retirement time");
|
|
assert_eq!(
|
|
malformed
|
|
.upgrade
|
|
.as_ref()
|
|
.and_then(|upgrade| upgrade.retirement_at.as_ref())
|
|
.map(DateTime::timestamp),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_preserves_explicit_apps_guidance_opt_out() {
|
|
let value = serde_json::to_value(test_model(/*spec*/ None))
|
|
.expect("serialize model info with explicit apps guidance opt-out");
|
|
assert_eq!(value["include_apps_usage_instructions"], false);
|
|
|
|
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
|
assert!(!model.include_apps_usage_instructions);
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_deserializes_known_tool_mode() {
|
|
let mut value =
|
|
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
|
let object = value
|
|
.as_object_mut()
|
|
.expect("model info should be an object");
|
|
object.insert(
|
|
"tool_mode".to_string(),
|
|
serde_json::Value::String("code_mode_only".to_string()),
|
|
);
|
|
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
|
|
|
assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly));
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_treats_unknown_tool_mode_as_omitted() {
|
|
let mut value =
|
|
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
|
let object = value
|
|
.as_object_mut()
|
|
.expect("model info should be an object");
|
|
object.insert(
|
|
"tool_mode".to_string(),
|
|
serde_json::Value::String("future_tool_mode".to_string()),
|
|
);
|
|
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
|
|
|
assert_eq!(model.tool_mode, None);
|
|
let serialized = serde_json::to_value(model).expect("serialize model info");
|
|
let object = serialized
|
|
.as_object()
|
|
.expect("model info should be an object");
|
|
assert!(!object.contains_key("tool_mode"));
|
|
}
|
|
|
|
#[test]
|
|
fn model_info_treats_unknown_multi_agent_version_as_omitted() {
|
|
let mut value =
|
|
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
|
let object = value
|
|
.as_object_mut()
|
|
.expect("model info should be an object");
|
|
object.insert(
|
|
"multi_agent_version".to_string(),
|
|
serde_json::Value::String("future_multi_agent_version".to_string()),
|
|
);
|
|
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
|
|
|
assert_eq!(model.multi_agent_version, None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolved_context_window_prefers_context_window() {
|
|
let model = ModelInfo {
|
|
context_window: Some(273_000),
|
|
max_context_window: Some(400_000),
|
|
..test_model(/*spec*/ None)
|
|
};
|
|
|
|
assert_eq!(model.resolved_context_window(), Some(273_000));
|
|
}
|
|
|
|
#[test]
|
|
fn resolved_context_window_falls_back_to_max_context_window() {
|
|
let model = ModelInfo {
|
|
context_window: None,
|
|
max_context_window: Some(400_000),
|
|
..test_model(/*spec*/ None)
|
|
};
|
|
|
|
assert_eq!(model.resolved_context_window(), Some(400_000));
|
|
assert_eq!(model.usable_context_window(), Some(380_000));
|
|
assert_eq!(model.auto_compact_token_limit(), Some(360_000));
|
|
}
|
|
|
|
#[test]
|
|
fn model_context_window_limits_preserve_their_distinct_meanings() {
|
|
let model = ModelInfo {
|
|
context_window: Some(272_000),
|
|
max_context_window: Some(400_000),
|
|
auto_compact_token_limit: Some(250_000),
|
|
effective_context_window_percent: 95,
|
|
..test_model(/*spec*/ None)
|
|
};
|
|
|
|
assert_eq!(
|
|
(
|
|
model.resolved_context_window(),
|
|
model.usable_context_window(),
|
|
model.auto_compact_token_limit(),
|
|
),
|
|
(Some(272_000), Some(258_400), Some(244_800))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_preset_preserves_availability_nux() {
|
|
let preset = ModelPreset::from(ModelInfo {
|
|
availability_nux: Some(ModelAvailabilityNux {
|
|
message: "Try Spark.".to_string(),
|
|
}),
|
|
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
|
|
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
|
service_tiers: Vec::new(),
|
|
..test_model(/*spec*/ None)
|
|
});
|
|
|
|
assert_eq!(
|
|
preset.availability_nux,
|
|
Some(ModelAvailabilityNux {
|
|
message: "Try Spark.".to_string(),
|
|
})
|
|
);
|
|
assert!(preset.supports_fast_mode());
|
|
assert_eq!(
|
|
preset.default_service_tier,
|
|
Some(ServiceTier::Fast.request_value().to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_access_programs_preserve_explicit_selection_availability() {
|
|
use crate::turn_input::CyberAccessProgram;
|
|
|
|
for (metadata, expected) in [
|
|
(None, None),
|
|
(Some(serde_json::Value::Null), None),
|
|
(
|
|
Some(serde_json::json!({ "cyber": [] })),
|
|
Some(ModelAccessPrograms { cyber: vec![] }),
|
|
),
|
|
(
|
|
Some(serde_json::json!({
|
|
"cyber": ["standard", "daybreak_blue", "future_program", "daybreak_red"]
|
|
})),
|
|
Some(ModelAccessPrograms {
|
|
cyber: vec![
|
|
CyberAccessProgram::Standard,
|
|
CyberAccessProgram::DaybreakBlue,
|
|
CyberAccessProgram::DaybreakRed,
|
|
],
|
|
}),
|
|
),
|
|
] {
|
|
let mut value = serde_json::to_value(test_model(/*spec*/ None)).unwrap();
|
|
if let Some(metadata) = metadata {
|
|
value["available_access_programs"] = metadata;
|
|
}
|
|
let model: ModelInfo = serde_json::from_value(value).unwrap();
|
|
assert_eq!(model.available_access_programs, expected);
|
|
assert_eq!(ModelPreset::from(model).available_access_programs, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn model_preset_supports_fast_mode_from_service_tiers() {
|
|
let preset = ModelPreset::from(ModelInfo {
|
|
service_tiers: vec![ModelServiceTier {
|
|
id: ServiceTier::Fast.request_value().to_string(),
|
|
name: "Fast".to_string(),
|
|
description: "Priority processing.".to_string(),
|
|
}],
|
|
..test_model(/*spec*/ None)
|
|
});
|
|
|
|
assert!(preset.supports_fast_mode());
|
|
}
|
|
|
|
#[test]
|
|
fn service_tier_for_request_omits_explicit_default_tier() {
|
|
let model = ModelInfo {
|
|
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
|
service_tiers: vec![ModelServiceTier {
|
|
id: ServiceTier::Fast.request_value().to_string(),
|
|
name: "Fast".to_string(),
|
|
description: "Priority processing.".to_string(),
|
|
}],
|
|
..test_model(/*spec*/ None)
|
|
};
|
|
|
|
assert_eq!(
|
|
model.service_tier_for_request(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_tier_for_request_preserves_flex_without_catalog_support() {
|
|
let model = ModelInfo {
|
|
service_tiers: Vec::new(),
|
|
..test_model(/*spec*/ None)
|
|
};
|
|
|
|
assert_eq!(
|
|
model.service_tier_for_request(Some(ServiceTier::Flex.request_value().to_string())),
|
|
Some(ServiceTier::Flex.request_value().to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_tier_for_request_filters_unsupported_tiers() {
|
|
let model = ModelInfo {
|
|
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
|
service_tiers: vec![ModelServiceTier {
|
|
id: ServiceTier::Fast.request_value().to_string(),
|
|
name: "Fast".to_string(),
|
|
description: "Priority processing.".to_string(),
|
|
}],
|
|
..test_model(/*spec*/ None)
|
|
};
|
|
|
|
assert_eq!(
|
|
model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())),
|
|
Some(ServiceTier::Fast.request_value().to_string())
|
|
);
|
|
assert_eq!(
|
|
model.service_tier_for_request(Some("unsupported".to_string())),
|
|
None
|
|
);
|
|
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
|
|
}
|
|
}
|