style: Cleaned up comments and organization of quirks.yaml file
This commit is contained in:
3 files changed
+63
-103
No files matched your search
+17
-17
@@ -4,9 +4,9 @@
|
|||||||
# model data: oauth blocks, request patches, wire-name aliases, behavioral
|
# model data: oauth blocks, request patches, wire-name aliases, behavioral
|
||||||
# flags, and models that no public catalog provides.
|
# flags, and models that no public catalog provides.
|
||||||
#
|
#
|
||||||
# `models:` entries are self-complete — `rules` apply ONLY to API-sourced
|
# `models:` entries are self-complete. `rules` apply ONLY to API-sourced
|
||||||
# models, never to hand-owned `models:` entries. Legacy verbatim-preserved
|
# models, never to hand-owned `models:` entries. Legacy verbatim-preserved
|
||||||
# entries are grouped under a `# legacy — pruning candidates` comment.
|
# entries are grouped under a `# legacy -- pruning candidates` comment.
|
||||||
|
|
||||||
- provider: openai
|
- provider: openai
|
||||||
exclude:
|
exclude:
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
max_tokens_per_chunk: 8191
|
max_tokens_per_chunk: 8191
|
||||||
default_chunk_size: 2000
|
default_chunk_size: 2000
|
||||||
max_batch_size: 100
|
max_batch_size: 100
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: o4-mini-high
|
- name: o4-mini-high
|
||||||
max_output_tokens: 100000
|
max_output_tokens: 100000
|
||||||
real_name: o4-mini
|
real_name: o4-mini
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
max_tokens_per_chunk: 2048
|
max_tokens_per_chunk: 2048
|
||||||
default_chunk_size: 1500
|
default_chunk_size: 1500
|
||||||
max_batch_size: 100
|
max_batch_size: 100
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: gemini-3-pro-preview
|
- name: gemini-3-pro-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
type: enabled
|
type: enabled
|
||||||
budget_tokens: 16000
|
budget_tokens: 16000
|
||||||
models:
|
models:
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: claude-opus-4-1-20250805
|
- name: claude-opus-4-1-20250805
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
input_price: 0.1
|
input_price: 0.1
|
||||||
max_tokens_per_chunk: 8092
|
max_tokens_per_chunk: 8092
|
||||||
default_chunk_size: 2000
|
default_chunk_size: 2000
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: magistral-small-latest
|
- name: magistral-small-latest
|
||||||
max_input_tokens: 131072
|
max_input_tokens: 131072
|
||||||
input_price: 0.5
|
input_price: 0.5
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
|
|
||||||
- provider: perplexity
|
- provider: perplexity
|
||||||
models:
|
models:
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: sonar-reasoning
|
- name: sonar-reasoning
|
||||||
max_input_tokens: 128000
|
max_input_tokens: 128000
|
||||||
input_price: 1
|
input_price: 1
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
exclude:
|
exclude:
|
||||||
- "whisper-*"
|
- "whisper-*"
|
||||||
models:
|
models:
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: meta-llama/llama-4-maverick-17b-128e-instruct
|
- name: meta-llama/llama-4-maverick-17b-128e-instruct
|
||||||
max_input_tokens: 131072
|
max_input_tokens: 131072
|
||||||
input_price: 0
|
input_price: 0
|
||||||
@@ -600,7 +600,7 @@
|
|||||||
max_tokens_per_chunk: 2048
|
max_tokens_per_chunk: 2048
|
||||||
default_chunk_size: 1500
|
default_chunk_size: 1500
|
||||||
max_batch_size: 5
|
max_batch_size: 5
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: gemini-3-pro-preview
|
- name: gemini-3-pro-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
@@ -813,7 +813,7 @@
|
|||||||
max_tokens_per_chunk: 512
|
max_tokens_per_chunk: 512
|
||||||
default_chunk_size: 1000
|
default_chunk_size: 1000
|
||||||
max_batch_size: 96
|
max_batch_size: 96
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: us.meta.llama3-3-70b-instruct-v1:0
|
- name: us.meta.llama3-3-70b-instruct-v1:0
|
||||||
max_input_tokens: 131072
|
max_input_tokens: 131072
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -855,7 +855,7 @@
|
|||||||
max_tokens_per_chunk: 512
|
max_tokens_per_chunk: 512
|
||||||
default_chunk_size: 1000
|
default_chunk_size: 1000
|
||||||
max_batch_size: 100
|
max_batch_size: 100
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: '@cf/google/gemma-3-12b-it'
|
- name: '@cf/google/gemma-3-12b-it'
|
||||||
max_input_tokens: 131072
|
max_input_tokens: 131072
|
||||||
max_output_tokens: 2048
|
max_output_tokens: 2048
|
||||||
@@ -939,7 +939,7 @@
|
|||||||
max_tokens_per_chunk: 8192
|
max_tokens_per_chunk: 8192
|
||||||
default_chunk_size: 2000
|
default_chunk_size: 2000
|
||||||
max_batch_size: 10
|
max_batch_size: 10
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: qwen3-vl-flash
|
- name: qwen3-vl-flash
|
||||||
max_input_tokens: 262144
|
max_input_tokens: 262144
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
@@ -1000,7 +1000,7 @@
|
|||||||
|
|
||||||
- provider: deepseek
|
- provider: deepseek
|
||||||
models:
|
models:
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: deepseek-chat
|
- name: deepseek-chat
|
||||||
max_input_tokens: 64000
|
max_input_tokens: 64000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -1040,7 +1040,7 @@
|
|||||||
type: reranker
|
type: reranker
|
||||||
max_input_tokens: 4096
|
max_input_tokens: 4096
|
||||||
input_price: 0.112
|
input_price: 0.112
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: glm-4.6v-flash
|
- name: glm-4.6v-flash
|
||||||
max_input_tokens: 65536
|
max_input_tokens: 65536
|
||||||
input_price: 0
|
input_price: 0
|
||||||
@@ -1049,7 +1049,7 @@
|
|||||||
|
|
||||||
- provider: minimax
|
- provider: minimax
|
||||||
models:
|
models:
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: minimax-m3
|
- name: minimax-m3
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
input_price: 4.2
|
input_price: 4.2
|
||||||
@@ -1166,7 +1166,7 @@
|
|||||||
body:
|
body:
|
||||||
include_reasoning: true
|
include_reasoning: true
|
||||||
models:
|
models:
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: google/gemini-3-pro-preview
|
- name: google/gemini-3-pro-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
@@ -1352,7 +1352,7 @@
|
|||||||
max_tokens_per_chunk: 512
|
max_tokens_per_chunk: 512
|
||||||
default_chunk_size: 1000
|
default_chunk_size: 1000
|
||||||
max_batch_size: 100
|
max_batch_size: 100
|
||||||
# legacy — pruning candidates
|
# legacy -- pruning candidates
|
||||||
- name: Qwen/Qwen3-Max-Thinking
|
- name: Qwen/Qwen3-Max-Thinking
|
||||||
max_input_tokens: 262144
|
max_input_tokens: 262144
|
||||||
input_price: 1.2
|
input_price: 1.2
|
||||||
|
|||||||
+29
-68
@@ -1,5 +1,6 @@
|
|||||||
use super::model::{ModelData, ProviderModels};
|
use super::model::{ModelData, ProviderModels};
|
||||||
use super::oauth::OAuthConfig;
|
use super::oauth::OAuthConfig;
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
use crate::utils::fetch;
|
use crate::utils::fetch;
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ use log::warn;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::{Arc, LazyLock, Mutex};
|
use std::sync::{Arc, LazyLock, Mutex};
|
||||||
|
|
||||||
pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml");
|
pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml");
|
||||||
@@ -19,10 +22,6 @@ pub static ALL_QUIRKS: LazyLock<Vec<ProviderQuirks>> =
|
|||||||
const MODELSDEV_API_URL: &str = "https://models.dev/api.json";
|
const MODELSDEV_API_URL: &str = "https://models.dev/api.json";
|
||||||
const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/models";
|
const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/models";
|
||||||
|
|
||||||
/// Upstream ids for one coyote provider. The table order is the fixed output
|
|
||||||
/// order of the built catalog. Providers with neither upstream id are fully
|
|
||||||
/// quirks-owned; `openrouter` is special-cased in the merge: its models come
|
|
||||||
/// from the OpenRouter list natively instead of via a prefix join.
|
|
||||||
struct SourceIds {
|
struct SourceIds {
|
||||||
provider: &'static str,
|
provider: &'static str,
|
||||||
modelsdev: Option<&'static str>,
|
modelsdev: Option<&'static str>,
|
||||||
@@ -152,15 +151,11 @@ const PROVIDER_SOURCES: [SourceIds; 24] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Raw payloads from the two upstream catalogs. Each source is independently
|
|
||||||
/// fallible; `None` means that fetch failed and the merge degrades gracefully.
|
|
||||||
pub struct FetchedSources {
|
pub struct FetchedSources {
|
||||||
pub modelsdev: Option<String>,
|
pub modelsdev: Option<String>,
|
||||||
pub openrouter: Option<String>,
|
pub openrouter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches both upstream catalogs concurrently. A failed fetch is logged and
|
|
||||||
/// surfaces as `None` rather than an error.
|
|
||||||
pub async fn fetch_sources() -> FetchedSources {
|
pub async fn fetch_sources() -> FetchedSources {
|
||||||
let (modelsdev, openrouter) = tokio::join!(fetch(MODELSDEV_API_URL), fetch(OPENROUTER_API_URL));
|
let (modelsdev, openrouter) = tokio::join!(fetch(MODELSDEV_API_URL), fetch(OPENROUTER_API_URL));
|
||||||
FetchedSources {
|
FetchedSources {
|
||||||
@@ -173,8 +168,6 @@ pub async fn fetch_sources() -> FetchedSources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An API-sourced model plus the release date used for ordering. The date is
|
|
||||||
/// deliberately kept out of [`ModelData`] and never serialized.
|
|
||||||
struct SourcedModel {
|
struct SourcedModel {
|
||||||
data: ModelData,
|
data: ModelData,
|
||||||
release_date: Option<String>,
|
release_date: Option<String>,
|
||||||
@@ -192,9 +185,6 @@ fn clamp_price(value: f64) -> Option<f64> {
|
|||||||
(0.0..=10_000.0).contains(&value).then_some(value)
|
(0.0..=10_000.0).contains(&value).then_some(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the models of one models.dev provider. Tolerant by design:
|
|
||||||
/// missing or malformed fields are skipped while the model is kept, and
|
|
||||||
/// out-of-range numbers are dropped field-wise.
|
|
||||||
fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec<SourcedModel> {
|
fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec<SourcedModel> {
|
||||||
let Some(models) = api[provider_id]["models"].as_object() else {
|
let Some(models) = api[provider_id]["models"].as_object() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -236,15 +226,11 @@ fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec<SourcedModel
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OpenRouter prices are USD per token; the catalog stores USD per million
|
|
||||||
/// tokens, rounded to 4 decimals.
|
|
||||||
fn openrouter_price(value: &Value) -> Option<f64> {
|
fn openrouter_price(value: &Value) -> Option<f64> {
|
||||||
let per_token: f64 = value.as_str()?.parse().ok()?;
|
let per_token: f64 = value.as_str()?.parse().ok()?;
|
||||||
clamp_price((per_token * 1_000_000.0 * 10_000.0).round() / 10_000.0)
|
clamp_price((per_token * 1_000_000.0 * 10_000.0).round() / 10_000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps one OpenRouter list entry onto [`ModelData`], with the same field-wise
|
|
||||||
/// tolerance and clamping as the models.dev adapter.
|
|
||||||
fn openrouter_model_data(name: &str, entry: &Value) -> ModelData {
|
fn openrouter_model_data(name: &str, entry: &Value) -> ModelData {
|
||||||
let mut data = ModelData::new(name);
|
let mut data = ModelData::new(name);
|
||||||
data.max_input_tokens = entry["context_length"]
|
data.max_input_tokens = entry["context_length"]
|
||||||
@@ -276,9 +262,6 @@ fn openrouter_index(payload: &Value) -> HashMap<&str, &Value> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every OpenRouter list entry as a model of the `openrouter` provider, named
|
|
||||||
/// by its full id. OpenRouter publishes no release date, so ordering falls
|
|
||||||
/// back to name-ascending.
|
|
||||||
fn openrouter_native_models(payload: &Value) -> Vec<SourcedModel> {
|
fn openrouter_native_models(payload: &Value) -> Vec<SourcedModel> {
|
||||||
payload["data"]
|
payload["data"]
|
||||||
.as_array()
|
.as_array()
|
||||||
@@ -297,8 +280,6 @@ fn openrouter_native_models(payload: &Value) -> Vec<SourcedModel> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fills fields that models.dev left unset on the same model: OpenRouter data
|
|
||||||
/// never overrides a present models.dev value.
|
|
||||||
fn fill_from_openrouter(data: &mut ModelData, entry: &Value) {
|
fn fill_from_openrouter(data: &mut ModelData, entry: &Value) {
|
||||||
let filler = openrouter_model_data(&data.name, entry);
|
let filler = openrouter_model_data(&data.name, entry);
|
||||||
if data.max_input_tokens.is_none() {
|
if data.max_input_tokens.is_none() {
|
||||||
@@ -427,8 +408,8 @@ pub fn build_catalog(sources: &FetchedSources, quirks: &[ProviderQuirks]) -> Cat
|
|||||||
(Some(a_date), Some(b_date)) => b_date
|
(Some(a_date), Some(b_date)) => b_date
|
||||||
.cmp(a_date)
|
.cmp(a_date)
|
||||||
.then_with(|| a.data.name.cmp(&b.data.name)),
|
.then_with(|| a.data.name.cmp(&b.data.name)),
|
||||||
(Some(_), None) => std::cmp::Ordering::Less,
|
(Some(_), None) => Ordering::Less,
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
(None, Some(_)) => Ordering::Greater,
|
||||||
(None, None) => a.data.name.cmp(&b.data.name),
|
(None, None) => a.data.name.cmp(&b.data.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -474,8 +455,6 @@ pub fn build_catalog(sources: &FetchedSources, quirks: &[ProviderQuirks]) -> Cat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [`build_catalog`], but errors unless every provider was assembled
|
|
||||||
/// from live API data (or is quirks-owned by design).
|
|
||||||
pub fn build_catalog_strict(
|
pub fn build_catalog_strict(
|
||||||
sources: &FetchedSources,
|
sources: &FetchedSources,
|
||||||
quirks: &[ProviderQuirks],
|
quirks: &[ProviderQuirks],
|
||||||
@@ -488,6 +467,7 @@ pub fn build_catalog_strict(
|
|||||||
build.failed.join(", ")
|
build.failed.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(build.providers)
|
Ok(build.providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +508,10 @@ pub fn backfill_embedded_floor(build: &mut CatalogBuild, embedded: &[ProviderMod
|
|||||||
build.providers = providers;
|
build.providers = providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERATED_HEADER: &str = "# GENERATED by 'coyote --generate-models' — DO NOT hand-edit model data.\n\
|
const GENERATED_HEADER: &str = "# GENERATED by 'coyote --generate-models' \n#DO NOT hand-edit model data.\n\
|
||||||
# Hand-owned behavior lives in quirks.yaml; this file is the embedded offline fallback.\n";
|
# Hand-owned behavior lives in quirks.yaml; this file is the embedded offline fallback.\n";
|
||||||
|
|
||||||
/// Builds the catalog from live API data and writes it to `path`. Both
|
pub async fn generate_models_file(path: &Path) -> Result<()> {
|
||||||
/// upstream sources must respond and every provider must fully resolve;
|
|
||||||
/// identical source data always produces identical bytes.
|
|
||||||
pub async fn generate_models_file(path: &std::path::Path) -> Result<()> {
|
|
||||||
let sources = fetch_sources().await;
|
let sources = fetch_sources().await;
|
||||||
if sources.modelsdev.is_none() || sources.openrouter.is_none() {
|
if sources.modelsdev.is_none() || sources.openrouter.is_none() {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -544,25 +521,24 @@ pub async fn generate_models_file(path: &std::path::Path) -> Result<()> {
|
|||||||
let providers = build_catalog_strict(&sources, &ALL_QUIRKS)?;
|
let providers = build_catalog_strict(&sources, &ALL_QUIRKS)?;
|
||||||
let model_count: usize = providers.iter().map(|provider| provider.models.len()).sum();
|
let model_count: usize = providers.iter().map(|provider| provider.models.len()).sum();
|
||||||
let yaml = serde_yaml::to_string(&providers)?;
|
let yaml = serde_yaml::to_string(&providers)?;
|
||||||
std::fs::write(path, format!("{GENERATED_HEADER}{yaml}"))?;
|
|
||||||
|
fs::write(path, format!("{GENERATED_HEADER}{yaml}"))?;
|
||||||
println!(
|
println!(
|
||||||
"Wrote {} models across {} providers to {}",
|
"Wrote {} models across {} providers to {}",
|
||||||
model_count,
|
model_count,
|
||||||
providers.len(),
|
providers.len(),
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fraction of `old` names that also appear in `new`; empty `old` yields 1.0.
|
#[cfg(test)]
|
||||||
/// Used to guard against upstream id-shape drift: a low overlap means the
|
|
||||||
/// upstream catalog renamed its models out from under us.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 {
|
pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 {
|
||||||
if old.is_empty() {
|
if old.is_empty() {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
let new_names: std::collections::HashSet<&str> = new.iter().map(String::as_str).collect();
|
let new_names: HashSet<&str> = new.iter().map(String::as_str).collect();
|
||||||
let kept = old
|
let kept = old
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|name| new_names.contains(name.as_str()))
|
.filter(|name| new_names.contains(name.as_str()))
|
||||||
@@ -570,10 +546,6 @@ pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 {
|
|||||||
kept as f64 / old.len() as f64
|
kept as f64 / old.len() as f64
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overlays a hand-owned quirks entry onto the API-sourced model of the same
|
|
||||||
/// name. Every field the hand-owned entry carries (Some, true, or non-empty)
|
|
||||||
/// wins; absent fields keep the API value. `model_type` is authoritative on
|
|
||||||
/// the hand-owned entry.
|
|
||||||
fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
|
fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
|
||||||
ModelData {
|
ModelData {
|
||||||
name: hand.name,
|
name: hand.name,
|
||||||
@@ -604,9 +576,6 @@ fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand-owned catalog overrides for a single provider: oauth configuration,
|
|
||||||
/// curation excludes, field overlays for API-sourced models, suffixed model
|
|
||||||
/// variants, and fully hand-owned model entries.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ProviderQuirks {
|
pub struct ProviderQuirks {
|
||||||
@@ -623,10 +592,6 @@ pub struct ProviderQuirks {
|
|||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A field overlay applied to every API-sourced model whose name matches the
|
|
||||||
/// glob in `match` (`*` = any run of characters, `?` = exactly one). Only
|
|
||||||
/// explicitly present fields are written; absent fields leave the model
|
|
||||||
/// untouched.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct QuirkRule {
|
pub struct QuirkRule {
|
||||||
@@ -661,9 +626,6 @@ pub struct QuirkRule {
|
|||||||
pub system_prompt_prefix: Option<String>,
|
pub system_prompt_prefix: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clones every model whose name matches the glob in `match`, appends `suffix`
|
|
||||||
/// to the clone's name, and overlays any explicitly present fields onto the
|
|
||||||
/// clone.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct QuirkVariant {
|
pub struct QuirkVariant {
|
||||||
@@ -699,8 +661,6 @@ pub struct QuirkVariant {
|
|||||||
pub system_prompt_prefix: Option<String>,
|
pub system_prompt_prefix: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies every rule whose glob matches `data.name`, in file order; later
|
|
||||||
/// rules overwrite earlier ones on the same field.
|
|
||||||
pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) {
|
pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) {
|
||||||
for rule in &provider_quirks.rules {
|
for rule in &provider_quirks.rules {
|
||||||
if !glob_matches(&rule.r#match, &data.name) {
|
if !glob_matches(&rule.r#match, &data.name) {
|
||||||
@@ -751,8 +711,6 @@ pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overlays a variant's explicitly present fields onto a cloned model; same
|
|
||||||
/// field semantics as [`apply_rules`].
|
|
||||||
fn apply_variant(variant: &QuirkVariant, data: &mut ModelData) {
|
fn apply_variant(variant: &QuirkVariant, data: &mut ModelData) {
|
||||||
if let Some(real_name) = &variant.real_name {
|
if let Some(real_name) = &variant.real_name {
|
||||||
data.real_name = Some(real_name.clone());
|
data.real_name = Some(real_name.clone());
|
||||||
@@ -798,9 +756,6 @@ fn apply_variant(variant: &QuirkVariant, data: &mut ModelData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds the quirks for a client: an exact provider match wins, otherwise a
|
|
||||||
/// client whose name starts with the provider name (openai-compatible clients
|
|
||||||
/// are conventionally named after the provider they wrap).
|
|
||||||
pub fn quirks_for<'a>(
|
pub fn quirks_for<'a>(
|
||||||
quirks: &'a [ProviderQuirks],
|
quirks: &'a [ProviderQuirks],
|
||||||
client_name: &str,
|
client_name: &str,
|
||||||
@@ -815,9 +770,6 @@ pub fn quirks_for<'a>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compiled patterns are memoized: catalog builds evaluate every rule, exclude,
|
|
||||||
/// and variant glob against every model, so recompiling per call would dominate
|
|
||||||
/// the merge as the catalog grows.
|
|
||||||
static GLOB_CACHE: LazyLock<Mutex<HashMap<String, Arc<Regex>>>> =
|
static GLOB_CACHE: LazyLock<Mutex<HashMap<String, Arc<Regex>>>> =
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
@@ -834,8 +786,6 @@ fn glob_matches(pattern: &str, name: &str) -> bool {
|
|||||||
regex.is_match(name).unwrap_or(false)
|
regex.is_match(name).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translates a glob pattern (`*` = any run of characters, `?` = exactly one)
|
|
||||||
/// into an anchored regex. Patterns that fail to compile match nothing.
|
|
||||||
fn compile_glob(pattern: &str) -> Regex {
|
fn compile_glob(pattern: &str) -> Regex {
|
||||||
let translated = format!(
|
let translated = format!(
|
||||||
"^{}$",
|
"^{}$",
|
||||||
@@ -853,6 +803,7 @@ fn compile_glob(pattern: &str) -> Regex {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::client::MODELS_YAML;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
const KNOWN_PROVIDERS: [&str; 24] = [
|
const KNOWN_PROVIDERS: [&str; 24] = [
|
||||||
@@ -934,6 +885,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|source| source.provider)
|
.map(|source| source.provider)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(order, KNOWN_PROVIDERS);
|
assert_eq!(order, KNOWN_PROVIDERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,7 +1074,9 @@ rules:
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|id| modelsdev_provider_models(&api, id))
|
.flat_map(|id| modelsdev_provider_models(&api, id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
all.extend(openrouter_native_models(&payload));
|
all.extend(openrouter_native_models(&payload));
|
||||||
|
|
||||||
assert!(!all.is_empty());
|
assert!(!all.is_empty());
|
||||||
for model in &all {
|
for model in &all {
|
||||||
let data = &model.data;
|
let data = &model.data;
|
||||||
@@ -1154,7 +1108,9 @@ rules:
|
|||||||
token_url: https://example.com/token
|
token_url: https://example.com/token
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
let build = build_catalog(&fixture_sources(), &quirks);
|
let build = build_catalog(&fixture_sources(), &quirks);
|
||||||
|
|
||||||
assert!(find_provider(&build, "xai").oauth.is_some());
|
assert!(find_provider(&build, "xai").oauth.is_some());
|
||||||
assert!(find_provider(&build, "openai").oauth.is_none());
|
assert!(find_provider(&build, "openai").oauth.is_none());
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1308,9 @@ rules:
|
|||||||
models: [{name: jamba}]
|
models: [{name: jamba}]
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
let build = build_catalog(&make_sources(None, None), &quirks);
|
let build = build_catalog(&make_sources(None, None), &quirks);
|
||||||
|
|
||||||
assert_eq!(build.degraded, ["gemini"]);
|
assert_eq!(build.degraded, ["gemini"]);
|
||||||
assert!(build.failed.contains(&"openai".to_string()));
|
assert!(build.failed.contains(&"openai".to_string()));
|
||||||
assert!(build.failed.contains(&"openrouter".to_string()));
|
assert!(build.failed.contains(&"openrouter".to_string()));
|
||||||
@@ -1383,9 +1341,11 @@ rules:
|
|||||||
models: [{name: g-1}]
|
models: [{name: g-1}]
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
let err = build_catalog_strict(&make_sources(None, None), &quirks)
|
let err = build_catalog_strict(&make_sources(None, None), &quirks)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
assert!(err.contains("gemini"), "{err}");
|
assert!(err.contains("gemini"), "{err}");
|
||||||
assert!(err.contains("openai"), "{err}");
|
assert!(err.contains("openai"), "{err}");
|
||||||
}
|
}
|
||||||
@@ -1416,6 +1376,7 @@ rules:
|
|||||||
models: [{name: v}]
|
models: [{name: v}]
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
let providers =
|
let providers =
|
||||||
build_catalog_strict(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks)
|
build_catalog_strict(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1423,6 +1384,7 @@ rules:
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|provider| provider.provider.as_str())
|
.map(|provider| provider.provider.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(order, KNOWN_PROVIDERS);
|
assert_eq!(order, KNOWN_PROVIDERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1430,13 +1392,14 @@ rules:
|
|||||||
fn name_overlap_fraction_of_old_names_still_present() {
|
fn name_overlap_fraction_of_old_names_still_present() {
|
||||||
let old: Vec<String> = ["a", "b", "c", "d"].map(str::to_string).into();
|
let old: Vec<String> = ["a", "b", "c", "d"].map(str::to_string).into();
|
||||||
let new: Vec<String> = ["a", "c", "x"].map(str::to_string).into();
|
let new: Vec<String> = ["a", "c", "x"].map(str::to_string).into();
|
||||||
|
|
||||||
assert_eq!(name_overlap(&old, &new), 0.5);
|
assert_eq!(name_overlap(&old, &new), 0.5);
|
||||||
assert_eq!(name_overlap(&[], &new), 1.0);
|
assert_eq!(name_overlap(&[], &new), 1.0);
|
||||||
assert_eq!(name_overlap(&old, &[]), 0.0);
|
assert_eq!(name_overlap(&old, &[]), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_catalog() -> Vec<ProviderModels> {
|
fn embedded_catalog() -> Vec<ProviderModels> {
|
||||||
serde_yaml::from_str(crate::client::MODELS_YAML).unwrap()
|
serde_yaml::from_str(MODELS_YAML).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_provider<'a>(providers: &'a [ProviderModels], name: &str) -> &'a ProviderModels {
|
fn embedded_provider<'a>(providers: &'a [ProviderModels], name: &str) -> &'a ProviderModels {
|
||||||
@@ -1598,8 +1561,7 @@ rules:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backfill_restores_embedded_floor_for_unresolved_providers() {
|
fn backfill_restores_embedded_floor_for_unresolved_providers() {
|
||||||
let embedded: Vec<ProviderModels> =
|
let embedded: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
|
||||||
serde_yaml::from_str(crate::client::MODELS_YAML).unwrap();
|
|
||||||
let mut build = build_catalog(&make_sources(Some(MODELSDEV_FIXTURE), None), &ALL_QUIRKS);
|
let mut build = build_catalog(&make_sources(Some(MODELSDEV_FIXTURE), None), &ALL_QUIRKS);
|
||||||
let unresolved: Vec<String> = build
|
let unresolved: Vec<String> = build
|
||||||
.degraded
|
.degraded
|
||||||
@@ -1645,8 +1607,7 @@ rules:
|
|||||||
.expect("openrouter fetch failed"),
|
.expect("openrouter fetch failed"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let embedded: Vec<ProviderModels> =
|
let embedded: Vec<ProviderModels> = serde_yaml::from_str(MODELS_YAML).unwrap();
|
||||||
serde_yaml::from_str(crate::client::MODELS_YAML).unwrap();
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{:<12} {:>7} {:>5} {:>5}",
|
"{:<12} {:>7} {:>5} {:>5}",
|
||||||
|
|||||||
+17
-18
@@ -94,7 +94,7 @@ use serde_json::json;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env, fs,
|
||||||
fs::{File, create_dir_all, read_dir, read_to_string},
|
fs::{File, create_dir_all, read_dir, read_to_string},
|
||||||
io::Write,
|
io::Write,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -548,8 +548,6 @@ pub async fn sync_models(url: Option<&str>, abort_signal: AbortSignal) -> Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`sync_models`] without a spinner or terminal output, for background use;
|
|
||||||
/// failures are returned for the caller to log.
|
|
||||||
pub async fn sync_models_quiet(url: Option<&str>) -> Result<()> {
|
pub async fn sync_models_quiet(url: Option<&str>) -> Result<()> {
|
||||||
let list = match url {
|
let list = match url {
|
||||||
Some(url) => {
|
Some(url) => {
|
||||||
@@ -589,6 +587,7 @@ fn build_refreshed_catalog(
|
|||||||
if sources.openrouter.is_none() {
|
if sources.openrouter.is_none() {
|
||||||
warnings.push("Failed to fetch OpenRouter; continuing with models.dev data only".into());
|
warnings.push("Failed to fetch OpenRouter; continuing with models.dev data only".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut build = catalog::build_catalog(sources, &catalog::ALL_QUIRKS);
|
let mut build = catalog::build_catalog(sources, &catalog::ALL_QUIRKS);
|
||||||
if !build.degraded.is_empty() {
|
if !build.degraded.is_empty() {
|
||||||
warnings.push(format!(
|
warnings.push(format!(
|
||||||
@@ -602,12 +601,15 @@ fn build_refreshed_catalog(
|
|||||||
build.failed.join(", ")
|
build.failed.join(", ")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let embedded = serde_yaml::from_str::<Vec<ProviderModels>>(MODELS_YAML)
|
let embedded = serde_yaml::from_str::<Vec<ProviderModels>>(MODELS_YAML)
|
||||||
.with_context(|| "Failed to parse the embedded models.yaml")?;
|
.with_context(|| "Failed to parse the embedded models.yaml")?;
|
||||||
catalog::backfill_embedded_floor(&mut build, &embedded);
|
catalog::backfill_embedded_floor(&mut build, &embedded);
|
||||||
|
|
||||||
if build.providers.is_empty() {
|
if build.providers.is_empty() {
|
||||||
bail!("Model sync produced no providers");
|
bail!("Model sync produced no providers");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((build.providers, warnings))
|
Ok((build.providers, warnings))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,20 +628,18 @@ fn write_models_override(list: Vec<ProviderModels>) -> Result<PathBuf> {
|
|||||||
// name embeds the pid so concurrent coyote processes (auto-refresh racing
|
// name embeds the pid so concurrent coyote processes (auto-refresh racing
|
||||||
// a manual --sync-models) never interleave writes into the same file.
|
// a manual --sync-models) never interleave writes into the same file.
|
||||||
let mut tmp_name = model_override_path.as_os_str().to_os_string();
|
let mut tmp_name = model_override_path.as_os_str().to_os_string();
|
||||||
tmp_name.push(format!(".{}.tmp", std::process::id()));
|
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||||
let tmp_path = PathBuf::from(tmp_name);
|
let tmp_path = PathBuf::from(tmp_name);
|
||||||
std::fs::write(&tmp_path, models_override_data)
|
fs::write(&tmp_path, models_override_data)
|
||||||
.with_context(|| format!("Failed to write to '{}'", tmp_path.display()))?;
|
.with_context(|| format!("Failed to write to '{}'", tmp_path.display()))?;
|
||||||
if let Err(error) = std::fs::rename(&tmp_path, &model_override_path) {
|
if let Err(error) = fs::rename(&tmp_path, &model_override_path) {
|
||||||
let _ = std::fs::remove_file(&tmp_path);
|
let _ = fs::remove_file(&tmp_path);
|
||||||
return Err(error)
|
return Err(error)
|
||||||
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()));
|
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()));
|
||||||
}
|
}
|
||||||
Ok(model_override_path)
|
Ok(model_override_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A missing, unreadable, corrupt, or version-mismatched override is always
|
|
||||||
/// stale; a loadable one is stale once its mtime is older than the TTL.
|
|
||||||
fn models_override_is_stale(loadable: bool, mtime: Option<SystemTime>, ttl_hours: u64) -> bool {
|
fn models_override_is_stale(loadable: bool, mtime: Option<SystemTime>, ttl_hours: u64) -> bool {
|
||||||
if !loadable {
|
if !loadable {
|
||||||
return true;
|
return true;
|
||||||
@@ -653,15 +653,12 @@ fn models_override_is_stale(loadable: bool, mtime: Option<SystemTime>, ttl_hours
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget refresh of the models override on REPL startup. The
|
|
||||||
/// running session keeps its already-loaded catalog; the next start picks up
|
|
||||||
/// the refreshed file.
|
|
||||||
pub fn maybe_spawn_models_refresh(app: &AppConfig) {
|
pub fn maybe_spawn_models_refresh(app: &AppConfig) {
|
||||||
if !app.sync_models_auto || app.dry_run {
|
if !app.sync_models_auto || app.dry_run {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let loadable = paths::local_models_override().is_ok();
|
let loadable = paths::local_models_override().is_ok();
|
||||||
let mtime = std::fs::metadata(paths::models_override_file())
|
let mtime = fs::metadata(paths::models_override_file())
|
||||||
.and_then(|metadata| metadata.modified())
|
.and_then(|metadata| metadata.modified())
|
||||||
.ok();
|
.ok();
|
||||||
if !models_override_is_stale(loadable, mtime, app.sync_models_ttl_hours) {
|
if !models_override_is_stale(loadable, mtime, app.sync_models_ttl_hours) {
|
||||||
@@ -1030,13 +1027,13 @@ fn render_config_template_from(
|
|||||||
|
|
||||||
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
|
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
|
||||||
ensure_parent_exists(config_path)?;
|
ensure_parent_exists(config_path)?;
|
||||||
std::fs::write(config_path, config_data)
|
fs::write(config_path, config_data)
|
||||||
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::prelude::PermissionsExt;
|
use std::os::unix::prelude::PermissionsExt;
|
||||||
let perms = std::fs::Permissions::from_mode(0o600);
|
let perms = fs::Permissions::from_mode(0o600);
|
||||||
std::fs::set_permissions(config_path, perms)?;
|
fs::set_permissions(config_path, perms)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✓ Saved the config file to '{}'.\n", config_path.display());
|
println!("✓ Saved the config file to '{}'.\n", config_path.display());
|
||||||
@@ -1545,6 +1542,7 @@ clients:
|
|||||||
let margin = std::time::Duration::from_secs(300);
|
let margin = std::time::Duration::from_secs(300);
|
||||||
let just_fresh = SystemTime::now() - (ttl - margin);
|
let just_fresh = SystemTime::now() - (ttl - margin);
|
||||||
let just_stale = SystemTime::now() - (ttl + margin);
|
let just_stale = SystemTime::now() - (ttl + margin);
|
||||||
|
|
||||||
assert!(!models_override_is_stale(true, Some(just_fresh), ttl_hours));
|
assert!(!models_override_is_stale(true, Some(just_fresh), ttl_hours));
|
||||||
assert!(models_override_is_stale(true, Some(just_stale), ttl_hours));
|
assert!(models_override_is_stale(true, Some(just_stale), ttl_hours));
|
||||||
}
|
}
|
||||||
@@ -1552,6 +1550,7 @@ clients:
|
|||||||
#[test]
|
#[test]
|
||||||
fn future_mtime_is_not_stale() {
|
fn future_mtime_is_not_stale() {
|
||||||
let future = SystemTime::now() + std::time::Duration::from_secs(3600);
|
let future = SystemTime::now() + std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
assert!(!models_override_is_stale(true, Some(future), 24));
|
assert!(!models_override_is_stale(true, Some(future), 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,8 +1563,8 @@ clients:
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let tmp_dir = std::env::temp_dir().join(format!("coyote-models-override-{unique}"));
|
let tmp_dir = env::temp_dir().join(format!("coyote-models-override-{unique}"));
|
||||||
fs::create_dir_all(&tmp_dir).unwrap();
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
|
|
||||||
let env_name = get_env_name("config_dir");
|
let env_name = get_env_name("config_dir");
|
||||||
let prev = env::var_os(&env_name);
|
let prev = env::var_os(&env_name);
|
||||||
|
|||||||
Reference in new issue
Block a user