Skip to content

Commit 23086a9

Browse files
committed
feat(llm): add provider model discovery and defaults catalog
1 parent 255e053 commit 23086a9

6 files changed

Lines changed: 321 additions & 17 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,25 @@ Provider API configuration also supports a provider-agnostic EDN map:
3535
```clojure
3636
NOUMENON_LLM_PROVIDERS_EDN='{:glm {:base-url "https://api.z.ai/api/anthropic" :api-key "..."}
3737
:claude-api {:base-url "https://api.anthropic.com" :api-key "..."}
38-
:tencent {:base-url "https://your-litellm-gateway" :api-key "..."}}'
38+
:default-provider :gateway
39+
:gateway {:base-url "https://your-litellm-gateway"
40+
:api-key "..."
41+
:models-path "/v1/models"
42+
:models ["gpt-4.1-mini" "gpt-4.1"]
43+
:default-model "gpt-4.1-mini"}}'
44+
```
45+
46+
` :models` is optional; when present, selected model must be one of the listed values.
47+
`:default-model` is optional; when present, it is used when `--model` is omitted.
48+
`:default-provider` is optional; when present, it is used when `--provider` is omitted.
49+
You can also set `NOUMENON_DEFAULT_PROVIDER` (takes precedence over `:default-provider`).
50+
`:models-path` is optional; used for dynamic model discovery (defaults are built in for known providers).
51+
52+
Inspect current provider/model defaults with:
53+
54+
```bash
55+
noum llm-providers
56+
noum llm-models --provider gateway
3957
```
4058

4159
Provider config resolution precedence for API providers is:

src/noumenon/cli.clj

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,12 @@
4343
{:ok value}
4444
{:error (:error-invalid spec) :value raw})))
4545

46+
(defn- valid-providers
47+
[]
48+
(set (conj (llm/supported-provider-names) "claude")))
49+
4650
(def ^:private all-valid-providers
47-
#{"glm" "claude" "claude-api" "claude-cli"})
51+
(fn [raw] ((valid-providers) raw)))
4852

4953
;; --- Reusable flag atoms ---
5054

@@ -324,6 +328,20 @@
324328
"status" {:spec simple-command-spec
325329
:summary "Show import counts for a repository"
326330
:usage "status [options] <repo-path>"}
331+
"llm-providers" {:spec {:flags []
332+
:initial {:subcommand "llm-providers"}
333+
:positionals {:required 0 :error nil :keys []}}
334+
:summary "Show configured LLM providers, models, and defaults"
335+
:usage "llm-providers"
336+
:epilog "Reads NOUMENON_LLM_PROVIDERS_EDN and NOUMENON_DEFAULT_PROVIDER.
337+
Shows each provider's available models and default model."}
338+
"llm-models" {:spec {:flags [(assoc provider-flag :valid all-valid-providers)]
339+
:initial {:subcommand "llm-models"}
340+
:positionals {:required 0 :error nil :keys []}}
341+
:summary "Show models for a provider (API first, config fallback)"
342+
:usage "llm-models [--provider <name>]"
343+
:epilog "Fetches provider models dynamically when supported, and falls back to
344+
configured :models when discovery is unavailable."}
327345
"show-schema" {:spec simple-command-spec
328346
:summary "Show the database schema with all attributes and types"
329347
:usage "show-schema [options] <repo-path>"}
@@ -431,7 +449,7 @@
431449
:epilog "Starts an HTTP API server on 127.0.0.1 for the noum launcher\nand future Electron UI. Writes connection info to ~/.noumenon/daemon.edn.\nUse --port to specify a fixed port, or omit for auto-assignment.\nUse --token for remote access authentication."}})
432450

433451
(def ^:private command-order
434-
["digest" "import" "analyze" "enrich" "synthesize" "embed" "update" "watch" "query" "show-schema" "status" "list-databases" "ask" "serve" "daemon" "benchmark" "introspect" "reseed" "artifact-history"])
452+
["digest" "import" "analyze" "enrich" "synthesize" "embed" "update" "watch" "query" "show-schema" "status" "llm-providers" "llm-models" "list-databases" "ask" "serve" "daemon" "benchmark" "introspect" "reseed" "artifact-history"])
435453

436454
;; --- Help text generation ---
437455

src/noumenon/llm.clj

Lines changed: 177 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,57 @@
1919
(def canonical-providers
2020
#{"glm" "claude-api" "claude-cli"})
2121

22+
(defn- getenv
23+
[k]
24+
(System/getenv k))
25+
26+
(defn- parse-provider-map
27+
[]
28+
(if-let [raw (getenv "NOUMENON_LLM_PROVIDERS_EDN")]
29+
(let [parsed (try
30+
(edn/read-string raw)
31+
(catch Exception e
32+
(throw (ex-info "NOUMENON_LLM_PROVIDERS_EDN contains invalid EDN"
33+
{:env-var "NOUMENON_LLM_PROVIDERS_EDN"}
34+
e))))]
35+
(when-not (map? parsed)
36+
(throw (ex-info "NOUMENON_LLM_PROVIDERS_EDN must be an EDN map"
37+
{:env-var "NOUMENON_LLM_PROVIDERS_EDN"})))
38+
parsed)
39+
{}))
40+
41+
(defn- configured-provider-names
42+
[]
43+
(->> (parse-provider-map)
44+
keys
45+
(filter keyword?)
46+
(remove #{:default-provider})
47+
(map name)
48+
set))
49+
50+
(defn supported-provider-names
51+
[]
52+
(sort (into canonical-providers (configured-provider-names))))
53+
54+
(defn default-provider-name
55+
[]
56+
(let [providers-edn (parse-provider-map)
57+
env-default (getenv "NOUMENON_DEFAULT_PROVIDER")
58+
map-default (some-> (:default-provider providers-edn) name)
59+
selected (or env-default map-default default-provider)]
60+
(if ((set (supported-provider-names)) selected)
61+
selected
62+
(throw (ex-info (str "Default provider is not supported: " selected)
63+
{:provider selected :known (supported-provider-names)})))))
64+
2265
(defn normalize-provider-name
2366
"Normalize provider name string to canonical form.
2467
Returns nil when the input is not a supported provider."
2568
[provider]
2669
(when provider
27-
(let [normalized (get provider-aliases provider provider)]
28-
(when (canonical-providers normalized)
70+
(let [normalized (get provider-aliases provider provider)
71+
providers (set (supported-provider-names))]
72+
(when (providers normalized)
2973
normalized))))
3074

3175
(defn provider->kw
@@ -36,8 +80,8 @@
3680
normalized (normalize-provider-name provider-name)]
3781
(when-not normalized
3882
(throw (ex-info (str "Unrecognized provider: " provider-name
39-
". Known providers: " (str/join ", " (sort canonical-providers)))
40-
{:provider provider-name :known (sort canonical-providers)})))
83+
". Known providers: " (str/join ", " (supported-provider-names)))
84+
{:provider provider-name :known (supported-provider-names)})))
4185
(keyword normalized)))
4286

4387
;; --- Pricing ---
@@ -271,16 +315,14 @@
271315

272316
(def ^:private api-provider-config
273317
{:glm {:env-var "NOUMENON_ZAI_TOKEN"
274-
:base-url "https://api.z.ai/api/anthropic"}
318+
:base-url "https://api.z.ai/api/anthropic"
319+
:models-path "/v1/models"}
275320
:claude-api {:env-var "ANTHROPIC_API_KEY"
276-
:base-url "https://api.anthropic.com"}})
321+
:base-url "https://api.anthropic.com"
322+
:models-path "/v1/models"}})
277323

278324
(def ^:private runtime-modes #{"local" "service"})
279325

280-
(defn- getenv
281-
[k]
282-
(System/getenv k))
283-
284326
(defn- runtime-mode
285327
[]
286328
(let [mode (or (getenv "NOUMENON_RUNTIME_MODE") "local")]
@@ -346,7 +388,127 @@
346388

347389
(defn- provider-map-config
348390
[provider-kw]
349-
(get (parse-edn-env "NOUMENON_LLM_PROVIDERS_EDN") provider-kw))
391+
(get (parse-provider-map) provider-kw))
392+
393+
(defn provider-catalog
394+
[]
395+
(let [providers (parse-provider-map)
396+
names (supported-provider-names)
397+
default-p (default-provider-name)]
398+
{:default-provider default-p
399+
:providers (into {}
400+
(map (fn [provider-name]
401+
(let [provider-kw (keyword provider-name)
402+
configured (provider-map-config provider-kw)
403+
configured-models (:models configured)
404+
default-model (or (:default-model configured)
405+
(first configured-models)
406+
default-model-alias)]
407+
[provider-name {:default? (= provider-name default-p)
408+
:default-model default-model
409+
:models (or configured-models [])}])))
410+
names)}))
411+
412+
(defn- model-id-from-entry
413+
[entry]
414+
(or (:id entry) (:name entry) (:model entry)))
415+
416+
(defn- parse-models-response
417+
[body]
418+
(let [parsed (json/read-str body :key-fn keyword)
419+
data (cond
420+
(vector? parsed) parsed
421+
(vector? (:data parsed)) (:data parsed)
422+
:else [])]
423+
(->> data
424+
(map model-id-from-entry)
425+
(filter string?)
426+
vec)))
427+
428+
(defn- models-url
429+
[provider-kw base-url]
430+
(let [configured (provider-map-config provider-kw)
431+
path (or (:models-path configured)
432+
(:models-path (api-provider-config provider-kw)))]
433+
(when path
434+
(str (str/replace (str/trim base-url) #"/+$" "") path))))
435+
436+
(defn discover-provider-models
437+
([provider] (discover-provider-models provider {}))
438+
([provider {:keys [timeout-ms] :or {timeout-ms 15000}}]
439+
(let [provider-kw (provider->kw provider)
440+
configured (provider-map-config provider-kw)
441+
{:keys [env-var base-url]} (api-provider-config provider-kw)
442+
resolved-url (or (:base-url configured) base-url)
443+
api-key (or (:api-key configured) (read-env-var env-var))
444+
url (models-url provider-kw resolved-url)
445+
fallback (vec (:models configured []))]
446+
(if-not url
447+
{:provider (name provider-kw)
448+
:default-model (or (:default-model configured) (first fallback) default-model-alias)
449+
:models fallback
450+
:source :config}
451+
(try
452+
(let [{:keys [status body error]} @(http/request {:url url
453+
:method :get
454+
:headers {"x-api-key" api-key
455+
"anthropic-version" "2023-06-01"}
456+
:timeout timeout-ms})]
457+
(if (or error (not= 200 status))
458+
{:provider (name provider-kw)
459+
:default-model (or (:default-model configured) (first fallback) default-model-alias)
460+
:models fallback
461+
:source :config
462+
:warning (str "Model discovery unavailable (HTTP " status "), using configured fallback")}
463+
(let [models (parse-models-response body)]
464+
{:provider (name provider-kw)
465+
:default-model (or (:default-model configured) (first models) (first fallback) default-model-alias)
466+
:models (if (seq models) models fallback)
467+
:source (if (seq models) :api :config)})))
468+
(catch Exception _
469+
{:provider (name provider-kw)
470+
:default-model (or (:default-model configured) (first fallback) default-model-alias)
471+
:models fallback
472+
:source :config
473+
:warning "Model discovery failed, using configured fallback"}))))))
474+
475+
(defn- provider-default-model
476+
[provider-kw]
477+
(:default-model (provider-map-config provider-kw)))
478+
479+
(defn- provider-models
480+
[provider-kw]
481+
(:models (provider-map-config provider-kw)))
482+
483+
(defn- normalize-model-name
484+
[model-name]
485+
(if (known-model-ids model-name)
486+
(model-alias->id model-name)
487+
model-name))
488+
489+
(defn- configured-model-set
490+
[provider-kw]
491+
(some->> (provider-models provider-kw)
492+
(map normalize-model-name)
493+
set))
494+
495+
(defn- resolve-model-id
496+
[provider-kw model]
497+
(let [configured (configured-model-set provider-kw)
498+
default-model (provider-default-model provider-kw)
499+
selected (or model
500+
default-model
501+
(first (provider-models provider-kw))
502+
default-model-alias)
503+
resolved (normalize-model-name selected)]
504+
(when (and default-model (seq configured)
505+
(not (configured (normalize-model-name default-model))))
506+
(throw (ex-info (str "Configured :default-model is not listed in :models for provider " (name provider-kw))
507+
{:provider provider-kw :default-model default-model :allowed (sort configured)})))
508+
(when (and (seq configured) (not (configured resolved)))
509+
(throw (ex-info (str "Model " resolved " is not configured for provider " (name provider-kw))
510+
{:provider provider-kw :model resolved :allowed (sort configured)})))
511+
resolved))
350512

351513
(defn- normalize-base-url
352514
[base-url]
@@ -447,10 +609,11 @@
447609

448610
(defn resolve-opts
449611
"Resolve provider/model from option defaults. Returns map with :provider-kw
450-
and :model-id — the canonical, validated identifiers."
612+
and :model-id — the canonical, validated identifiers."
451613
[{:keys [provider model]}]
452-
{:provider-kw (provider->kw (or provider default-provider))
453-
:model-id (model-alias->id (or model default-model-alias))})
614+
(let [provider-kw (provider->kw (or provider (default-provider-name)))]
615+
{:provider-kw provider-kw
616+
:model-id (resolve-model-id provider-kw model)}))
454617

455618
(defn make-messages-fn-from-opts
456619
"Build a messages-based invoke-fn from provider/model options.

src/noumenon/main.clj

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,17 @@
439439
head))
440440
{:exit 0 :result stats}))))))
441441

442+
(defn do-llm-providers
443+
"Show configured LLM providers, models, and defaults."
444+
[_opts]
445+
{:exit 0 :result (llm/provider-catalog)})
446+
447+
(defn do-llm-models
448+
"Show provider models using API discovery when available."
449+
[opts]
450+
(let [provider (or (:provider opts) (llm/default-provider-name))]
451+
{:exit 0 :result (llm/discover-provider-models provider)}))
452+
442453
;; --- List Databases ---
443454

444455
(defn- format-date [inst]
@@ -916,6 +927,8 @@
916927
"ask" (do-ask parsed)
917928
"show-schema" (do-show-schema parsed)
918929
"status" (do-status parsed)
930+
"llm-providers" (do-llm-providers parsed)
931+
"llm-models" (do-llm-models parsed)
919932
"list-databases" (do-list-databases parsed)
920933
"benchmark" (do-benchmark parsed)
921934
"digest" (do-digest parsed)

src/noumenon/mcp.clj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@
188188
{:name "noumenon_list_databases"
189189
:description "List all noumenon databases with entity counts, pipeline stages, and cost."
190190
:inputSchema {:type "object" :properties {}}}
191+
{:name "noumenon_llm_providers"
192+
:description "List configured LLM providers, their available models, and defaults. Uses NOUMENON_LLM_PROVIDERS_EDN and NOUMENON_DEFAULT_PROVIDER."
193+
:inputSchema {:type "object" :properties {}}}
194+
{:name "noumenon_llm_models"
195+
:description "List available models for a provider. Tries provider API first and falls back to configured :models."
196+
:inputSchema {:type "object"
197+
:properties {"provider" {:type "string" :description "Provider name (optional; defaults to configured default provider)"}}}}
191198
{:name "noumenon_benchmark_run"
192199
:description "Run a benchmark comparing LLM answers across knowledge graph layers. WARNING: Expensive — uses many LLM calls. Use max_questions to limit scope."
193200
:inputSchema {:type "object"
@@ -645,6 +652,13 @@
645652
stats))))
646653
(tool-result "No databases found."))))
647654

655+
(defn- handle-llm-providers [_args _defaults]
656+
(tool-result (pr-str (llm/provider-catalog))))
657+
658+
(defn- handle-llm-models [args _defaults]
659+
(let [provider (or (args "provider") (llm/default-provider-name))]
660+
(tool-result (pr-str (llm/discover-provider-models provider)))))
661+
648662
(defn- handle-benchmark-run [args defaults]
649663
(validate-llm-inputs! args)
650664
(with-conn args defaults
@@ -1043,6 +1057,8 @@
10431057
"noumenon_enrich" handle-enrich
10441058
"noumenon_synthesize" handle-synthesize
10451059
"noumenon_list_databases" handle-list-databases
1060+
"noumenon_llm_providers" handle-llm-providers
1061+
"noumenon_llm_models" handle-llm-models
10461062
"noumenon_benchmark_run" handle-benchmark-run
10471063
"noumenon_benchmark_results" handle-benchmark-results
10481064
"noumenon_benchmark_compare" handle-benchmark-compare

0 commit comments

Comments
 (0)