From 07d96a4881333401db776f8031f60993aedd3d27 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Fri, 7 Aug 2026 14:03:06 +0100 Subject: [PATCH] feat(llm): add DeepInfra as a declarative provider 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. --- .../skald-core/src/llm/providers/declared.rs | 117 +++++++++++++++++- providers.yaml | 43 ++++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/crates/skald-core/src/llm/providers/declared.rs b/crates/skald-core/src/llm/providers/declared.rs index cbc9376..2af369e 100644 --- a/crates/skald-core/src/llm/providers/declared.rs +++ b/crates/skald-core/src/llm/providers/declared.rs @@ -104,6 +104,10 @@ struct ModelsSpec { /// Static model-id catalog (provider exposes no listing endpoint). #[serde(rename = "static")] static_models: Option>, + /// 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, #[serde(default)] map: MapSpec, #[serde(default)] @@ -122,8 +126,17 @@ enum AuthSpec { 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 /// 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)] struct MapSpec { id: Option, @@ -138,6 +151,13 @@ struct MapSpec { /// capability name → boolean JSON field that enables it. #[serde(default)] capability_flags: HashMap, + /// Dotted path of a string-array field carrying the model's feature tags + /// (e.g. `metadata.tags`); read by `capability_tags`. + tags: Option, + /// capability name → tag value: the capability is enabled when the tags + /// array (at `tags`) contains the tag. + #[serde(default)] + capability_tags: HashMap, } #[derive(Debug, Default, serde::Deserialize)] @@ -335,7 +355,7 @@ impl DeclaredProvider { fn map_model(&self, m: &serde_json::Value, models: &ModelsSpec) -> Option { let map = &models.map; - let get = |f: &Option| f.as_deref().map(|k| &m[k]); + let get = |f: &Option| f.as_deref().and_then(|k| get_path(m, k)); let id = get(&map.id) .or_else(|| Some(&m["id"])) .and_then(|v| v.as_str())? @@ -358,10 +378,28 @@ impl DeclaredProvider { add_cap("vision"); } 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); } } + 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 { id, name, @@ -405,7 +443,10 @@ impl DeclaredProvider { .as_array() .cloned() .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 { 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). fn apply_enrich(rules: &[EnrichRule], info: &mut RemoteLlmModelInfo) { 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")); } + #[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 /// is runtime data, but this test keeps a typo from reaching users. #[test] diff --git a/providers.yaml b/providers.yaml index 54e0d51..4430b07 100644 --- a/providers.yaml +++ b/providers.yaml @@ -23,11 +23,19 @@ # models_url: defaults to the resolved base_url # auth: bearer | none (default: bearer unless api_key: none) # 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 # vision: (also adds the `vision` capability) # price_input_per_million / price_output_per_million: # capability_flags: { : } (e.g. reasoning) +# tags: (e.g. metadata.tags) +# capability_tags: { : } — on when tags contains tag +# (a `vision` one also sets the vision flag) # base_capabilities: capabilities every listed model gets # defaults: { vision: bool } — used when the source omits it # enrich: first-matching rule wins, glob on the model id @@ -157,6 +165,39 @@ providers: values: [disabled, 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 name: "LM Studio" base_url: "http://localhost:1234/v1"