Files
cursor-byok/server/src/store/models.rs
T
kevin9327andClaude Opus 4.8 38a4c3f516 chore: restore a green make check on main
`make check` currently fails on main before any change is made: one
`cargo fmt --all -- --check` diff and four `cargo clippy --workspace
--all-targets -- -D warnings` errors. All five are pre-existing and
none of them change behaviour.

- `server/tests/knowledge_rules.rs:125` — rustfmt wants the long
  `assert!` split across lines. Applied `cargo fmt --all` verbatim.
- `server/src/plugin/data.rs:206,215` — `path` is only read under
  `#[cfg(unix)]`, so every other target sees an unused binding. Added
  a `#[cfg(not(unix))] { let _ = path; }` arm, matching the
  `let _ = error;` idiom already used at line 189 of the same file.
  Windows behaviour is unchanged: these helpers stay no-ops there.
- `server/src/provider/openai_responses.rs:158` — `collapsible_match`.
  Applied clippy's own suggestion (move `thinking_open` into a match
  guard). The match ends in `_ => {}`, so a failed guard falls through
  to a no-op exactly as the inner `if` did.
- `server/src/store/models.rs:327` — `items_after_test_module`. Moved
  `optional_u64` and `to_i64` above `mod tests`; the bodies are
  untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 19:59:28 +09:00

402 lines
16 KiB
Rust

//! Persists model and provider configuration.
use std::{collections::HashSet, str::FromStr};
use sqlx::{Row, Sqlite, Transaction};
use crate::{
model::{model_hash, normalize_model_input, ModelConfig, ModelConfigInput, ModelType},
Error, Result,
};
use super::{now_ms, Store};
const MODEL_COLUMNS: &str = r#"
model_hash, sort_order, display_name, group_name, model_type, base_url, use_full_url, api_key, tooltip_data,
model_id, reasoning_effort, openai_endpoint, openai_extra_params_enabled,
openai_extra_params_json, custom_headers_enabled, custom_headers_json,
anthropic_extra_params_enabled, anthropic_extra_params_json, context_window_tokens,
max_completion_tokens, anthropic_max_tokens, anthropic_thinking_effort,
thinking_budget_tokens, created_at_ms, updated_at_ms
"#;
impl Store {
pub async fn models(&self) -> Result<Vec<ModelConfig>> {
let query =
format!("SELECT {MODEL_COLUMNS} FROM model_configs ORDER BY sort_order, display_name");
sqlx::query(&query)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(model_from_row)
.collect()
}
pub async fn model(&self, hash: &str) -> Result<Option<ModelConfig>> {
let query = format!("SELECT {MODEL_COLUMNS} FROM model_configs WHERE model_hash = ?");
sqlx::query(&query)
.bind(hash)
.fetch_optional(&self.pool)
.await?
.map(model_from_row)
.transpose()
}
pub async fn create_model(&self, input: &ModelConfigInput) -> Result<ModelConfig> {
let mut models = self.create_models(std::slice::from_ref(input)).await?;
Ok(models.remove(0))
}
pub async fn create_models(&self, inputs: &[ModelConfigInput]) -> Result<Vec<ModelConfig>> {
if inputs.is_empty() {
return Err(Error::Config("at least one model is required".into()));
}
let mut normalized = Vec::with_capacity(inputs.len());
let mut hashes = HashSet::with_capacity(inputs.len());
for input in inputs {
let input = normalize_model_input(input)?;
let hash = model_hash(&input)?;
if !hashes.insert(hash.clone()) {
return Err(Error::Config("model configurations must be unique".into()));
}
normalized.push((hash, input));
}
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
for (hash, input) in &normalized {
insert_model(&mut transaction, hash, input, now).await?;
}
transaction.commit().await?;
let mut saved = Vec::with_capacity(normalized.len());
for (hash, _) in normalized {
saved.push(self.model(&hash).await?.expect("inserted model must exist"));
}
Ok(saved)
}
pub(super) async fn create_models_if_missing(
&self,
inputs: &[ModelConfigInput],
) -> Result<usize> {
let mut normalized = Vec::with_capacity(inputs.len());
let mut hashes = HashSet::with_capacity(inputs.len());
for input in inputs {
let input = normalize_model_input(input)?;
let hash = model_hash(&input)?;
if hashes.insert(hash.clone()) {
normalized.push((hash, input));
}
}
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
let mut inserted = 0;
for (hash, input) in &normalized {
inserted += usize::from(
insert_model_with_conflict(&mut transaction, hash, input, now, true).await?,
);
}
transaction.commit().await?;
Ok(inserted)
}
pub async fn update_model(
&self,
current_hash: &str,
input: &ModelConfigInput,
) -> Result<ModelConfig> {
let current = self
.model(current_hash)
.await?
.ok_or_else(|| Error::RunNotFound(format!("model {current_hash}")))?;
let input = normalize_model_input(input)?;
let next_hash = model_hash(&input)?;
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
if next_hash != current.model_hash {
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
.bind(&current.model_hash)
.execute(&mut *transaction)
.await?;
}
let result = sqlx::query(
r#"UPDATE model_configs SET
model_hash = ?, sort_order = ?, display_name = ?, group_name = ?, model_type = ?, base_url = ?,
use_full_url = ?, api_key = ?, tooltip_data = ?, model_id = ?, reasoning_effort = ?,
openai_endpoint = ?, openai_extra_params_enabled = ?, openai_extra_params_json = ?,
custom_headers_enabled = ?, custom_headers_json = ?,
anthropic_extra_params_enabled = ?, anthropic_extra_params_json = ?,
context_window_tokens = ?, max_completion_tokens = ?, anthropic_max_tokens = ?,
anthropic_thinking_effort = ?, thinking_budget_tokens = ?, updated_at_ms = ?
WHERE model_hash = ?"#,
)
.bind(&next_hash)
.bind(input.sort_order)
.bind(&input.display_name)
.bind(&input.group_name)
.bind(input.model_type.as_str())
.bind(&input.base_url)
.bind(input.use_full_url)
.bind(&input.api_key)
.bind(&input.tooltip_data)
.bind(&input.model_id)
.bind(&input.reasoning_effort)
.bind(&input.openai_endpoint)
.bind(input.openai_extra_params_enabled)
.bind(serde_json::to_string(&input.openai_extra_params)?)
.bind(input.custom_headers_enabled)
.bind(serde_json::to_string(&input.custom_headers)?)
.bind(input.anthropic_extra_params_enabled)
.bind(serde_json::to_string(&input.anthropic_extra_params)?)
.bind(input.context_window_tokens.map(to_i64).transpose()?)
.bind(input.max_completion_tokens.map(to_i64).transpose()?)
.bind(input.anthropic_max_tokens.map(to_i64).transpose()?)
.bind(&input.anthropic_thinking_effort)
.bind(input.thinking_budget_tokens.map(to_i64).transpose()?)
.bind(now)
.bind(current_hash)
.execute(&mut *transaction)
.await?;
if result.rows_affected() != 1 {
return Err(Error::RunNotFound(format!("model {current_hash}")));
}
transaction.commit().await?;
Ok(self
.model(&next_hash)
.await?
.expect("updated model must exist"))
}
pub async fn delete_model(&self, hash: &str) -> Result<()> {
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
.bind(hash)
.execute(&mut *transaction)
.await?;
let result = sqlx::query("DELETE FROM model_configs WHERE model_hash = ?")
.bind(hash)
.execute(&mut *transaction)
.await?;
if result.rows_affected() != 1 {
return Err(Error::RunNotFound(format!("model {hash}")));
}
transaction.commit().await?;
Ok(())
}
pub async fn reorder_models(&self, model_hashes: &[String]) -> Result<Vec<ModelConfig>> {
let current = self.models().await?;
let current_hashes = current
.iter()
.map(|model| model.model_hash.as_str())
.collect::<HashSet<_>>();
let requested_hashes = model_hashes
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
if model_hashes.len() != current.len()
|| requested_hashes.len() != current.len()
|| requested_hashes != current_hashes
{
return Err(Error::Config(
"model configuration changed; refresh and try sorting again".into(),
));
}
let now = now_ms();
let _write = self.writes.lock().await;
let mut transaction = self.pool.begin().await?;
for (index, hash) in model_hashes.iter().enumerate() {
sqlx::query(
"UPDATE model_configs SET sort_order = ?, updated_at_ms = ? WHERE model_hash = ?",
)
.bind(i64::try_from(index + 1).expect("model order fits in i64"))
.bind(now)
.bind(hash)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
self.models().await
}
}
async fn insert_model(
transaction: &mut Transaction<'_, Sqlite>,
hash: &str,
input: &ModelConfigInput,
now: i64,
) -> Result<()> {
insert_model_with_conflict(transaction, hash, input, now, false).await?;
Ok(())
}
async fn insert_model_with_conflict(
transaction: &mut Transaction<'_, Sqlite>,
hash: &str,
input: &ModelConfigInput,
now: i64,
ignore_existing: bool,
) -> Result<bool> {
let mut statement = String::from(
r#"INSERT INTO model_configs(
model_hash, sort_order, display_name, group_name, model_type, base_url, use_full_url, api_key, tooltip_data,
model_id, reasoning_effort, openai_endpoint, openai_extra_params_enabled,
openai_extra_params_json, custom_headers_enabled, custom_headers_json,
anthropic_extra_params_enabled, anthropic_extra_params_json, context_window_tokens,
max_completion_tokens, anthropic_max_tokens, anthropic_thinking_effort,
thinking_budget_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
);
if ignore_existing {
statement.push_str(" ON CONFLICT(model_hash) DO NOTHING");
}
let result = sqlx::query(&statement)
.bind(hash)
.bind(input.sort_order)
.bind(&input.display_name)
.bind(&input.group_name)
.bind(input.model_type.as_str())
.bind(&input.base_url)
.bind(input.use_full_url)
.bind(&input.api_key)
.bind(&input.tooltip_data)
.bind(&input.model_id)
.bind(&input.reasoning_effort)
.bind(&input.openai_endpoint)
.bind(input.openai_extra_params_enabled)
.bind(serde_json::to_string(&input.openai_extra_params)?)
.bind(input.custom_headers_enabled)
.bind(serde_json::to_string(&input.custom_headers)?)
.bind(input.anthropic_extra_params_enabled)
.bind(serde_json::to_string(&input.anthropic_extra_params)?)
.bind(input.context_window_tokens.map(to_i64).transpose()?)
.bind(input.max_completion_tokens.map(to_i64).transpose()?)
.bind(input.anthropic_max_tokens.map(to_i64).transpose()?)
.bind(&input.anthropic_thinking_effort)
.bind(input.thinking_budget_tokens.map(to_i64).transpose()?)
.bind(now)
.bind(now)
.execute(&mut **transaction)
.await?;
Ok(result.rows_affected() == 1)
}
fn model_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ModelConfig> {
Ok(ModelConfig {
model_hash: row.try_get("model_hash")?,
sort_order: row.try_get("sort_order")?,
display_name: row.try_get("display_name")?,
group_name: row.try_get("group_name")?,
model_type: ModelType::from_str(row.try_get("model_type")?)?,
base_url: row.try_get("base_url")?,
use_full_url: row.try_get("use_full_url")?,
api_key: row.try_get("api_key")?,
tooltip_data: row.try_get("tooltip_data")?,
model_id: row.try_get("model_id")?,
reasoning_effort: row.try_get("reasoning_effort")?,
openai_endpoint: row.try_get("openai_endpoint")?,
openai_extra_params_enabled: row.try_get("openai_extra_params_enabled")?,
openai_extra_params: serde_json::from_str(
row.try_get::<String, _>("openai_extra_params_json")?
.as_str(),
)?,
custom_headers_enabled: row.try_get("custom_headers_enabled")?,
custom_headers: serde_json::from_str(
row.try_get::<String, _>("custom_headers_json")?.as_str(),
)?,
anthropic_extra_params_enabled: row.try_get("anthropic_extra_params_enabled")?,
anthropic_extra_params: serde_json::from_str(
row.try_get::<String, _>("anthropic_extra_params_json")?
.as_str(),
)?,
context_window_tokens: optional_u64(&row, "context_window_tokens")?,
max_completion_tokens: optional_u64(&row, "max_completion_tokens")?,
anthropic_max_tokens: optional_u64(&row, "anthropic_max_tokens")?,
anthropic_thinking_effort: row.try_get("anthropic_thinking_effort")?,
thinking_budget_tokens: optional_u64(&row, "thinking_budget_tokens")?,
created_at_ms: row.try_get("created_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
}
fn optional_u64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<Option<u64>> {
row.try_get::<Option<i64>, _>(column)?
.map(|value| {
u64::try_from(value).map_err(|_| Error::Config(format!("{column} cannot be negative")))
})
.transpose()
}
fn to_i64(value: u64) -> Result<i64> {
i64::try_from(value).map_err(|_| Error::Config("token value is too large".into()))
}
#[cfg(test)]
mod tests {
use super::*;
fn model_input(group_name: Option<&str>) -> ModelConfigInput {
ModelConfigInput {
sort_order: 0,
display_name: "Test Model".into(),
group_name: group_name.map(String::from),
model_type: ModelType::OpenAi,
base_url: "https://example.com/v1/chat/completions".into(),
use_full_url: true,
api_key: "test-key".into(),
tooltip_data: "Test Model".into(),
model_id: "test-model".into(),
reasoning_effort: None,
openai_endpoint: crate::model::OPENAI_CHAT_ENDPOINT.into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
}
}
/// 分组名是纯展示字段:入库时去除首尾空白、空串归一为 NULL,
/// 更新分组名不得改变模型身份哈希。
#[tokio::test]
async fn group_name_round_trips_without_changing_model_identity() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("test.db").display()
))
.await
.unwrap();
let created = store
.create_model(&model_input(Some(" My Group ")))
.await
.unwrap();
assert_eq!(created.group_name.as_deref(), Some("My Group"));
let renamed = store
.update_model(&created.model_hash, &model_input(Some("Renamed")))
.await
.unwrap();
assert_eq!(renamed.model_hash, created.model_hash);
assert_eq!(renamed.group_name.as_deref(), Some("Renamed"));
let cleared = store
.update_model(&created.model_hash, &model_input(Some(" ")))
.await
.unwrap();
assert_eq!(cleared.model_hash, created.model_hash);
assert_eq!(cleared.group_name, None);
}
}