Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,8 @@ pub struct ModelMetadata {
pub model_description: String,
#[serde(rename = "modelIcon", skip_serializing_if = "Option::is_none")]
pub model_icon: Option<String>,
#[serde(rename = "ownedBy")]
pub owned_by: String,

#[serde(rename = "aliases", skip_serializing_if = "Vec::is_empty", default)]
pub aliases: Vec<String>,
Expand All @@ -1535,6 +1537,8 @@ pub struct UpdateModelApiRequest {
#[serde(rename = "isActive")]
pub is_active: Option<bool>,
pub aliases: Option<Vec<String>>,
#[serde(rename = "ownedBy")]
pub owned_by: Option<String>,
}

/// Batch update request format - Array of model name to update data
Expand Down
5 changes: 5 additions & 0 deletions crates/api/src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pub async fn batch_upsert_models(
}

// Convert API request to service request
// Note: Default owned_by value is applied in the repository layer during INSERT,
// not here, so we can distinguish between CREATE (apply default) and UPDATE (preserve old)
let models = batch_request
.iter()
.map(|(model_name, request)| {
Expand All @@ -87,6 +89,7 @@ pub async fn batch_upsert_models(
verifiable: request.verifiable,
is_active: request.is_active,
aliases: request.aliases.clone(),
owned_by: request.owned_by.clone(),
},
)
})
Expand Down Expand Up @@ -143,6 +146,7 @@ pub async fn batch_upsert_models(
model_display_name: updated_model.model_display_name,
model_description: updated_model.model_description,
model_icon: updated_model.model_icon,
owned_by: updated_model.owned_by,
aliases: updated_model.aliases,
},
})
Expand Down Expand Up @@ -221,6 +225,7 @@ pub async fn list_models(
model_description: model.model_description,
model_icon: model.model_icon,
aliases: model.aliases,
owned_by: model.owned_by,
},
is_active: model.is_active,
created_at: model.created_at,
Expand Down
2 changes: 1 addition & 1 deletion crates/api/src/routes/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ pub async fn models(
id: model.model_name,
object: "model".to_string(),
created: 0,
owned_by: "system".to_string(),
owned_by: model.owned_by,
})
.collect(),
};
Expand Down
2 changes: 2 additions & 0 deletions crates/api/src/routes/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pub async fn list_models(
model_display_name: model.model_display_name.clone(),
model_description: model.model_description.clone(),
model_icon: model.model_icon.clone(),
owned_by: model.owned_by.clone(),
aliases: model.aliases.clone(),
},
})
Expand Down Expand Up @@ -173,6 +174,7 @@ pub async fn get_model_by_name(
model_display_name: model.model_display_name,
model_description: model.model_description,
model_icon: model.model_icon,
owned_by: model.owned_by,
aliases: model.aliases,
},
};
Expand Down
163 changes: 163 additions & 0 deletions crates/api/tests/e2e_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ async fn test_models_api() {
let response = list_models(&server, api_key).await;

assert!(!response.data.is_empty());

// Verify that all models have owned_by field populated
for model in &response.data {
assert!(
!model.owned_by.is_empty(),
"owned_by field should not be empty"
);
}
}

#[tokio::test]
Expand Down Expand Up @@ -157,6 +165,8 @@ async fn test_admin_update_model() {
);
assert_eq!(1000000, retrieved_model.input_cost_per_token.amount);
assert_eq!(2000000, retrieved_model.output_cost_per_token.amount);
// Verify that owned_by defaults to "nearai" when not explicitly provided
assert_eq!(retrieved_model.metadata.owned_by, "nearai");
}

#[tokio::test]
Expand Down Expand Up @@ -200,6 +210,8 @@ async fn test_get_model_by_name() {
assert_eq!(2000000, model_resp.output_cost_per_token.amount);
assert_eq!(9, model_resp.output_cost_per_token.scale);
assert_eq!("USD", model_resp.output_cost_per_token.currency);
// Verify that owned_by defaults to "nearai" when not explicitly provided
assert_eq!(model_resp.metadata.owned_by, "nearai");

// Test retrieving the same model again by canonical name to verify consistency
let response_by_name_again = server
Expand Down Expand Up @@ -250,6 +262,157 @@ async fn test_get_model_by_name() {
}
}

#[tokio::test]
async fn test_admin_model_custom_owned_by() {
let server = setup_test_server().await;

// Test 1: Create model with custom owned_by value
let mut batch1 = BatchUpdateModelApiRequest::new();
batch1.insert(
"custom-model-1".to_string(),
serde_json::from_value(serde_json::json!({
"inputCostPerToken": {
"amount": 1000000,
"currency": "USD"
},
"outputCostPerToken": {
"amount": 2000000,
"currency": "USD"
},
"modelDisplayName": "Custom Model",
"modelDescription": "Test custom owned_by",
"contextLength": 128000,
"verifiable": true,
"ownedBy": "openai"
}))
.unwrap(),
);

let created_models = admin_batch_upsert_models(&server, batch1, get_session_id()).await;
assert_eq!(created_models.len(), 1);
let created_model = &created_models[0];
assert_eq!(
created_model.metadata.owned_by, "openai",
"Created model should have custom owned_by"
);

// Test 2: Retrieve model and verify custom owned_by persisted
let model_name = created_model.model_id.clone();
let encoded_name =
url::form_urlencoded::byte_serialize(model_name.as_bytes()).collect::<String>();
let response = server
.get(format!("/v1/model/{encoded_name}").as_str())
.await;
assert_eq!(response.status_code(), 200);
let retrieved_model = response.json::<api::models::ModelWithPricing>();
assert_eq!(
retrieved_model.metadata.owned_by, "openai",
"Retrieved model should have custom owned_by"
);

// Test 3: Update model WITHOUT specifying ownedBy (should preserve)
let mut batch2 = BatchUpdateModelApiRequest::new();
batch2.insert(
"custom-model-1".to_string(),
serde_json::from_value(serde_json::json!({
"inputCostPerToken": {
"amount": 5000000,
"currency": "USD"
}
}))
.unwrap(),
);

let updated_models = admin_batch_upsert_models(&server, batch2, get_session_id()).await;
assert_eq!(updated_models.len(), 1);
assert_eq!(
updated_models[0].metadata.owned_by, "openai",
"Updated model should preserve owned_by when not explicitly set"
);

// Test 4: Override owned_by during update
let mut batch3 = BatchUpdateModelApiRequest::new();
batch3.insert(
"custom-model-1".to_string(),
serde_json::from_value(serde_json::json!({
"inputCostPerToken": {
"amount": 5000000,
"currency": "USD"
},
"ownedBy": "anthropic"
}))
.unwrap(),
);

let overridden_models = admin_batch_upsert_models(&server, batch3, get_session_id()).await;
assert_eq!(overridden_models.len(), 1);
assert_eq!(
overridden_models[0].metadata.owned_by, "anthropic",
"Updated model should have new owned_by when explicitly provided"
);

// Test 5: Verify the override persisted in retrieval
let response = server
.get(format!("/v1/model/{encoded_name}").as_str())
.await;
assert_eq!(response.status_code(), 200);
let final_model = response.json::<api::models::ModelWithPricing>();
assert_eq!(
final_model.metadata.owned_by, "anthropic",
"Final retrieval should confirm override"
);
}

#[tokio::test]
async fn test_admin_model_default_owned_by() {
let server = setup_test_server().await;

// Create a unique model name to ensure we test INSERT path, not UPDATE path
let unique_model_name = format!("test-model-{}", uuid::Uuid::new_v4());

// Create model WITHOUT specifying ownedBy - should default to "nearai"
let mut batch = BatchUpdateModelApiRequest::new();
batch.insert(
unique_model_name.clone(),
serde_json::from_value(serde_json::json!({
"inputCostPerToken": {
"amount": 1000000,
"currency": "USD"
},
"outputCostPerToken": {
"amount": 2000000,
"currency": "USD"
},
"modelDisplayName": "Test Default Owned By",
"modelDescription": "Testing default owned_by value",
"contextLength": 128000,
"verifiable": true
// NOTE: No ownedBy field - should default to "nearai"
}))
.unwrap(),
);

let created_models = admin_batch_upsert_models(&server, batch, get_session_id()).await;
assert_eq!(created_models.len(), 1);
assert_eq!(
created_models[0].metadata.owned_by, "nearai",
"Model created without ownedBy should default to 'nearai'"
);

// Verify the default persists in retrieval
let encoded_name =
url::form_urlencoded::byte_serialize(unique_model_name.as_bytes()).collect::<String>();
let response = server
.get(format!("/v1/model/{encoded_name}").as_str())
.await;
assert_eq!(response.status_code(), 200);
let retrieved_model = response.json::<api::models::ModelWithPricing>();
assert_eq!(
retrieved_model.metadata.owned_by, "nearai",
"Retrieved model should have default owned_by value"
);
}

#[tokio::test]
async fn test_admin_update_organization_limits() {
let server = setup_test_server().await;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add owned_by field to models table
-- This migration adds an owned_by field to track which entity owns/manages the model

-- Add owned_by column to models table with default value
-- DEFAULT applies to existing rows during migration and to future direct SQL inserts
ALTER TABLE models
ADD COLUMN owned_by TEXT NOT NULL DEFAULT 'nearai';
2 changes: 2 additions & 0 deletions crates/database/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ pub struct Model {

// Tracking fields
pub is_active: bool,
pub owned_by: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Expand All @@ -432,6 +433,7 @@ pub struct UpdateModelPricingRequest {
pub verifiable: Option<bool>,
pub is_active: Option<bool>,
pub aliases: Option<Vec<String>>,
pub owned_by: Option<String>,
}

/// Model pricing history - stores historical pricing data for models
Expand Down
3 changes: 3 additions & 0 deletions crates/database/src/repositories/admin_composite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ impl AdminRepository for AdminCompositeRepository {
verifiable: request.verifiable,
is_active: request.is_active,
aliases: request.aliases.clone(),
owned_by: request.owned_by,
};

let model = self
Expand All @@ -75,6 +76,7 @@ impl AdminRepository for AdminCompositeRepository {
verifiable: model.verifiable,
is_active: model.is_active,
aliases: model.aliases,
owned_by: model.owned_by,
})
}

Expand Down Expand Up @@ -268,6 +270,7 @@ impl AdminRepository for AdminCompositeRepository {
context_length: m.context_length,
verifiable: m.verifiable,
is_active: m.is_active,
owned_by: m.owned_by,
aliases: m.aliases,
created_at: m.created_at,
updated_at: m.updated_at,
Expand Down
Loading