DeepInfra's chat API is plain OpenAI-compatible (api.deepinfra.com/v1/openai)
and its GET /models returns the OpenAI data envelope, but the declared
engine could not describe it: metadata sits under dotted paths
(metadata.context_length, metadata.pricing.*), capabilities ride a
metadata.tags string array, and the catalog mixes in tts/stt/embed/image
models with no way to keep only chat ones.
Three generic extensions to the declared engine close that, usable by any
future provider entry:
- map field names accept dotted paths (metadata.pricing.input_tokens)
- map.tags + map.capability_tags enable a capability when the model's
tags array contains a value (a vision one also sets the vision flag)
- models.filter { field, contains } keeps only listed models whose
string-array field holds the value (endpoint listings only)
The deepinfra entry filters on the chat tag, maps context/pricing/vision/
reasoning from the live catalog, and wires the flat reasoning_effort knob
(disabled remaps to none) for models tagged reasoning_effort.
This commit is contained in:
@@ -104,6 +104,10 @@ struct ModelsSpec {
|
|||||||
/// Static model-id catalog (provider exposes no listing endpoint).
|
/// Static model-id catalog (provider exposes no listing endpoint).
|
||||||
#[serde(rename = "static")]
|
#[serde(rename = "static")]
|
||||||
static_models: Option<Vec<String>>,
|
static_models: Option<Vec<String>>,
|
||||||
|
/// Keep only listed models whose string-array field (dotted path, e.g.
|
||||||
|
/// `metadata.tags`) contains a value — a catalog that also serves
|
||||||
|
/// non-chat kinds (tts, embed, image…) would flood the picker.
|
||||||
|
filter: Option<FilterSpec>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
map: MapSpec,
|
map: MapSpec,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -122,8 +126,17 @@ enum AuthSpec {
|
|||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct FilterSpec {
|
||||||
|
/// Dotted path of a string-array field (e.g. `metadata.tags`).
|
||||||
|
field: String,
|
||||||
|
/// Required array member (e.g. `chat`).
|
||||||
|
contains: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-model JSON field names → `RemoteLlmModelInfo` fields. Absent mappings
|
/// Per-model JSON field names → `RemoteLlmModelInfo` fields. Absent mappings
|
||||||
/// leave the corresponding field `None` (id defaults to `"id"`, name to id).
|
/// leave the corresponding field `None` (id defaults to `"id"`, name to id).
|
||||||
|
/// Field names accept dotted paths (`metadata.pricing.input_tokens`).
|
||||||
#[derive(Debug, Default, serde::Deserialize)]
|
#[derive(Debug, Default, serde::Deserialize)]
|
||||||
struct MapSpec {
|
struct MapSpec {
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
@@ -138,6 +151,13 @@ struct MapSpec {
|
|||||||
/// capability name → boolean JSON field that enables it.
|
/// capability name → boolean JSON field that enables it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
capability_flags: HashMap<String, String>,
|
capability_flags: HashMap<String, String>,
|
||||||
|
/// Dotted path of a string-array field carrying the model's feature tags
|
||||||
|
/// (e.g. `metadata.tags`); read by `capability_tags`.
|
||||||
|
tags: Option<String>,
|
||||||
|
/// capability name → tag value: the capability is enabled when the tags
|
||||||
|
/// array (at `tags`) contains the tag.
|
||||||
|
#[serde(default)]
|
||||||
|
capability_tags: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, serde::Deserialize)]
|
#[derive(Debug, Default, serde::Deserialize)]
|
||||||
@@ -335,7 +355,7 @@ impl DeclaredProvider {
|
|||||||
|
|
||||||
fn map_model(&self, m: &serde_json::Value, models: &ModelsSpec) -> Option<RemoteLlmModelInfo> {
|
fn map_model(&self, m: &serde_json::Value, models: &ModelsSpec) -> Option<RemoteLlmModelInfo> {
|
||||||
let map = &models.map;
|
let map = &models.map;
|
||||||
let get = |f: &Option<String>| f.as_deref().map(|k| &m[k]);
|
let get = |f: &Option<String>| f.as_deref().and_then(|k| get_path(m, k));
|
||||||
let id = get(&map.id)
|
let id = get(&map.id)
|
||||||
.or_else(|| Some(&m["id"]))
|
.or_else(|| Some(&m["id"]))
|
||||||
.and_then(|v| v.as_str())?
|
.and_then(|v| v.as_str())?
|
||||||
@@ -358,10 +378,28 @@ impl DeclaredProvider {
|
|||||||
add_cap("vision");
|
add_cap("vision");
|
||||||
}
|
}
|
||||||
for (cap, field) in &map.capability_flags {
|
for (cap, field) in &map.capability_flags {
|
||||||
if m[field].as_bool().unwrap_or(false) {
|
if get_path(m, field).and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
add_cap(cap);
|
add_cap(cap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(tags) = map
|
||||||
|
.tags
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| get_path(m, p))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
{
|
||||||
|
let has = |tag: &str| tags.iter().any(|t| t.as_str() == Some(tag));
|
||||||
|
for (cap, tag) in &map.capability_tags {
|
||||||
|
if has(tag) {
|
||||||
|
add_cap(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A tag-derived vision capability also sets the vision flag — the
|
||||||
|
// same sync apply_enrich keeps between the two.
|
||||||
|
if vision.is_none() && map.capability_tags.get("vision").is_some_and(|t| has(t)) {
|
||||||
|
vision = Some(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(RemoteLlmModelInfo {
|
Some(RemoteLlmModelInfo {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -405,7 +443,10 @@ impl DeclaredProvider {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| anyhow!("unexpected {who} response shape"))?;
|
.ok_or_else(|| anyhow!("unexpected {who} response shape"))?;
|
||||||
raw.iter().filter_map(|m| self.map_model(m, models)).collect()
|
raw.iter()
|
||||||
|
.filter(|m| passes_filter(m, models.filter.as_ref()))
|
||||||
|
.filter_map(|m| self.map_model(m, models))
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
for info in &mut list {
|
for info in &mut list {
|
||||||
apply_enrich(&models.enrich, info);
|
apply_enrich(&models.enrich, info);
|
||||||
@@ -414,6 +455,28 @@ impl DeclaredProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a possibly-dotted field path (`metadata.pricing.input_tokens`)
|
||||||
|
/// against a model JSON object. A bare key behaves like a flat lookup; any
|
||||||
|
/// missing segment yields `None`.
|
||||||
|
fn get_path<'a>(v: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
|
||||||
|
let mut cur = v;
|
||||||
|
for part in path.split('.') {
|
||||||
|
cur = cur.get(part)?;
|
||||||
|
}
|
||||||
|
Some(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a raw catalog entry passes the optional listing filter: no filter
|
||||||
|
/// keeps everything, otherwise the entry's string-array field must contain
|
||||||
|
/// the required value.
|
||||||
|
fn passes_filter(m: &serde_json::Value, filter: Option<&FilterSpec>) -> bool {
|
||||||
|
filter.is_none_or(|f| {
|
||||||
|
get_path(m, &f.field)
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.is_some_and(|a| a.iter().any(|t| t.as_str() == Some(f.contains.as_str())))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies the first matching enrich rule (later rules are not consulted).
|
/// Applies the first matching enrich rule (later rules are not consulted).
|
||||||
fn apply_enrich(rules: &[EnrichRule], info: &mut RemoteLlmModelInfo) {
|
fn apply_enrich(rules: &[EnrichRule], info: &mut RemoteLlmModelInfo) {
|
||||||
let Some(rule) = rules.iter().find(|r| glob_match(&r.glob, &info.id)) else {
|
let Some(rule) = rules.iter().find(|r| glob_match(&r.glob, &info.id)) else {
|
||||||
@@ -828,6 +891,54 @@ mod tests {
|
|||||||
assert!(info.capabilities.iter().any(|c| c == "video"));
|
assert!(info.capabilities.iter().any(|c| c == "video"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotted_paths_filter_and_capability_tags() {
|
||||||
|
let p = provider(
|
||||||
|
r#"
|
||||||
|
id: t
|
||||||
|
name: T
|
||||||
|
base_url: http://x
|
||||||
|
ui: { color: c, icon: i }
|
||||||
|
models:
|
||||||
|
endpoint: /models
|
||||||
|
filter: { field: metadata.tags, contains: chat }
|
||||||
|
map:
|
||||||
|
context_length: metadata.context_length
|
||||||
|
price_input_per_million: metadata.pricing.input_tokens
|
||||||
|
tags: metadata.tags
|
||||||
|
capability_tags: { vision: vision, reasoning_effort: reasoning_effort }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let models = p.spec.models.as_ref().unwrap();
|
||||||
|
let m = serde_json::json!({
|
||||||
|
"id": "acme/x",
|
||||||
|
"metadata": {
|
||||||
|
"context_length": 131072,
|
||||||
|
"pricing": { "input_tokens": 0.5 },
|
||||||
|
"tags": ["chat", "vision", "reasoning_effort"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let info = p.map_model(&m, models).unwrap();
|
||||||
|
assert_eq!(info.context_length, Some(131072));
|
||||||
|
assert_eq!(info.price_input_per_million, Some(0.5));
|
||||||
|
assert_eq!(info.vision, Some(true));
|
||||||
|
assert!(info.capabilities.iter().any(|c| c == "vision"));
|
||||||
|
assert!(info.capabilities.iter().any(|c| c == "reasoning_effort"));
|
||||||
|
|
||||||
|
// The filter keeps only entries whose tags array holds the value.
|
||||||
|
assert!(passes_filter(&m, models.filter.as_ref()));
|
||||||
|
let tts = serde_json::json!({ "id": "acme/tts", "metadata": { "tags": ["tts"] } });
|
||||||
|
assert!(!passes_filter(&tts, models.filter.as_ref()));
|
||||||
|
assert!(passes_filter(&tts, None));
|
||||||
|
|
||||||
|
// Dotted lookups miss cleanly on absent segments.
|
||||||
|
let bare = serde_json::json!({ "id": "acme/plain" });
|
||||||
|
let info = p.map_model(&bare, models).unwrap();
|
||||||
|
assert_eq!(info.context_length, None);
|
||||||
|
assert_eq!(info.vision, None);
|
||||||
|
assert!(!info.capabilities.iter().any(|c| c == "vision"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The catalog shipped at the repository root must always parse: the file
|
/// The catalog shipped at the repository root must always parse: the file
|
||||||
/// is runtime data, but this test keeps a typo from reaching users.
|
/// is runtime data, but this test keeps a typo from reaching users.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+42
-1
@@ -23,11 +23,19 @@
|
|||||||
# models_url: defaults to the resolved base_url
|
# models_url: defaults to the resolved base_url
|
||||||
# auth: bearer | none (default: bearer unless api_key: none)
|
# auth: bearer | none (default: bearer unless api_key: none)
|
||||||
# static: [model ids] — alternative to endpoint
|
# static: [model ids] — alternative to endpoint
|
||||||
# map: per-model JSON field names, all optional:
|
# filter: { field, contains } — keep only listed models whose
|
||||||
|
# string-array field (dotted path, e.g. metadata.tags)
|
||||||
|
# contains the value (endpoint listings only)
|
||||||
|
# map: per-model JSON field names, all optional; every
|
||||||
|
# field name accepts a dotted path
|
||||||
|
# (e.g. metadata.pricing.input_tokens):
|
||||||
# id / name / context_length / max_completion_tokens / knowledge_cutoff
|
# id / name / context_length / max_completion_tokens / knowledge_cutoff
|
||||||
# vision: <bool field> (also adds the `vision` capability)
|
# vision: <bool field> (also adds the `vision` capability)
|
||||||
# price_input_per_million / price_output_per_million: <number field>
|
# price_input_per_million / price_output_per_million: <number field>
|
||||||
# capability_flags: { <capability>: <bool field> } (e.g. reasoning)
|
# capability_flags: { <capability>: <bool field> } (e.g. reasoning)
|
||||||
|
# tags: <string-array field> (e.g. metadata.tags)
|
||||||
|
# capability_tags: { <capability>: <tag> } — on when tags contains tag
|
||||||
|
# (a `vision` one also sets the vision flag)
|
||||||
# base_capabilities: capabilities every listed model gets
|
# base_capabilities: capabilities every listed model gets
|
||||||
# defaults: { vision: bool } — used when the source omits it
|
# defaults: { vision: bool } — used when the source omits it
|
||||||
# enrich: first-matching rule wins, glob on the model id
|
# enrich: first-matching rule wins, glob on the model id
|
||||||
@@ -157,6 +165,39 @@ providers:
|
|||||||
values: [disabled, enabled]
|
values: [disabled, enabled]
|
||||||
default: enabled
|
default: enabled
|
||||||
|
|
||||||
|
- id: deepinfra
|
||||||
|
name: "DeepInfra"
|
||||||
|
base_url: "https://api.deepinfra.com/v1/openai"
|
||||||
|
ui:
|
||||||
|
color: "#4C59D3"
|
||||||
|
icon: "bi-lightning-charge"
|
||||||
|
description: "Hosted open-source model inference (OpenAI-compatible)"
|
||||||
|
fields:
|
||||||
|
- { key: api_key, label: "API Key", required: true, secret: true }
|
||||||
|
models:
|
||||||
|
endpoint: /models
|
||||||
|
# The catalog also serves tts/stt/embed/image/video models; keep LLMs.
|
||||||
|
filter: { field: metadata.tags, contains: chat }
|
||||||
|
map:
|
||||||
|
context_length: metadata.context_length
|
||||||
|
price_input_per_million: metadata.pricing.input_tokens
|
||||||
|
price_output_per_million: metadata.pricing.output_tokens
|
||||||
|
tags: metadata.tags
|
||||||
|
capability_tags:
|
||||||
|
vision: vision
|
||||||
|
reasoning: reasoning
|
||||||
|
reasoning_effort: reasoning_effort
|
||||||
|
base_capabilities: [function_calling]
|
||||||
|
reasoning:
|
||||||
|
# Flat reasoning_effort (none/minimal/low/medium/high/xhigh/max);
|
||||||
|
# "none" disables reasoning where the model supports it. Models tagged
|
||||||
|
# `reasoning` but not `reasoning_effort` always think — no knob.
|
||||||
|
request: { kind: effort, remap: { disabled: none } }
|
||||||
|
modes:
|
||||||
|
- when: { capability: reasoning_effort }
|
||||||
|
values: [disabled, minimal, low, medium, high, xhigh, max]
|
||||||
|
default: high
|
||||||
|
|
||||||
- id: lm_studio
|
- id: lm_studio
|
||||||
name: "LM Studio"
|
name: "LM Studio"
|
||||||
base_url: "http://localhost:1234/v1"
|
base_url: "http://localhost:1234/v1"
|
||||||
|
|||||||
Reference in New Issue
Block a user