Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Unreleased

- Improve error handleing for incompatible models messages in chat. #209
- Add dynamic model discovery via `fetchModels` provider config for OpenAI-compatible `/models` endpoints
- Improve error handling for incompatible models messages in chat. #209

## 0.87.2

Expand Down
9 changes: 5 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ There are multiples ways to configure ECA:
```bash
ECA_CONFIG='{"myConfig": "my_value"}' eca server
```

### Dynamic string contents

It's possible to retrieve content of any configs with a string value using the `${key:value}` approach, being `key`:
Expand Down Expand Up @@ -276,7 +276,7 @@ You can configure in multiple different ways:
```markdown title=".eca/commands/check-performance.md"
Check for performance issues in $ARG1 and optimize if needed.
```

ECA will make available a `/check-performance` command after creating that file.

=== "Global custom commands"
Expand All @@ -286,7 +286,7 @@ You can configure in multiple different ways:
```markdown title="~/.config/eca/commands/check-performance.md"
Check for performance issues in $ARG1 and optimize if needed.
```

ECA will make available a `/check-performance` command after creating that file.

=== "Config"
Expand All @@ -298,7 +298,7 @@ You can configure in multiple different ways:
"commands": [{"path": "my-custom-prompt.md"}]
}
```

ECA will make available a `/my-custom-prompt` command after creating that file.

## Rules
Expand Down Expand Up @@ -576,6 +576,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
interface Config {
providers?: {[key: string]: {
api?: 'openai-responses' | 'openai-chat' | 'anthropic';
fetchModels?: boolean;
url?: string;
key?: string; // when provider supports api key.
keyRc?: string; // credential file lookup in format [login@]machine[:port]
Expand Down
30 changes: 25 additions & 5 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Schema:
| `models` | map | Key: model name, value: its config | Yes |
| `models <model> extraPayload` | map | Extra payload sent in body to LLM | No |
| `models <model> modelName` | string | Override model name, useful to have multiple models with different configs and names that use same LLM model | No |
| `fetchModels` | boolean | Enable automatic model discovery from `/models` endpoint (OpenAI-compatible providers) | No |

_* url and key will be searched as envs `<provider>_API_URL` and `<provider>_API_KEY`, they require the env to be found or config to work._

Expand Down Expand Up @@ -105,7 +106,7 @@ Examples:
"providers": {
"openai": {
"api": "openai-responses",
"models": {
"models": {
"gpt-5": {},
"gpt-5-high": {
"modelName": "gpt-5",
Expand All @@ -116,9 +117,28 @@ Examples:
}
}
```

This way both will use gpt-5 model but one will override the reasoning to be high instead of the default.

=== "Dynamic model discovery"

For OpenAI-compatible providers, set `fetchModels: true` to automatically discover available models:

```javascript title="~/.config/eca/config.json"
{
"providers": {
"openrouter": {
"api": "openai-chat",
"url": "https://openrouter.ai/api/v1",
"key": "your-api-key",
"fetchModels": true
}
}
}
```

Static `models` config overrides discovered models, allowing customization.

### API Types

When configuring custom providers, choose the appropriate API type:
Expand Down Expand Up @@ -335,11 +355,11 @@ Notes:
}
}
```

=== "LM Studio"

This config works with LM studio:

```javascript title="~/.config/eca/config.json"
{
"providers": {
Expand Down
4 changes: 3 additions & 1 deletion src/eca/llm_api.clj
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@
:extra-payload extra-payload}
callbacks)

(and model-config handler)
(and (or (:fetchModels provider-config)
model-config)
handler)
(let [url-relative-path (:completionUrlRelativePath provider-config)
think-tag-start (:thinkTagStart provider-config)
think-tag-end (:thinkTagEnd provider-config)
Expand Down
147 changes: 121 additions & 26 deletions src/eca/models.clj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
[eca.llm-providers.ollama :as llm-providers.ollama]
[eca.llm-util :as llm-util]
[eca.logger :as logger]
[eca.shared :refer [assoc-some] :as shared]))
[eca.shared :refer [assoc-some] :as shared]
[hato.client :as http]))

(set! *warn-on-reflection* true)

Expand Down Expand Up @@ -77,36 +78,130 @@
(and (llm-util/provider-api-url provider config)
(llm-util/provider-api-key provider (get-in db [:auth provider]) config)))))

(def ^:private models-endpoint-path "/models")

(defn ^:private fetch-compatible-models
"Fetches models from an /models endpoint (both Anthropic and OpenAI).
Returns a map of model-id -> {} (empty config, to be enriched later).
On any error, logs a warning and returns nil."
[{:keys [api-url api-key provider]}]
(when api-url
(let [url (str api-url models-endpoint-path)
rid (llm-util/gen-rid)
headers (cond-> {"Content-Type" "application/json"}
api-key (assoc "Authorization" (str "Bearer " api-key)))]
(try
(llm-util/log-request logger-tag rid url nil headers)
(let [{:keys [status body]} (http/get url
{:headers headers
:throw-exceptions? false
:as :json
:timeout 10000})]
(if (= 200 status)
(do
(llm-util/log-response logger-tag rid "models" body)
(let [models-data (:data body)]
(when (seq models-data)
(reduce
(fn [acc model]
(let [model-id (:id model)]
(if model-id
(assoc acc model-id {})
acc)))
{}
models-data))))
(logger/warn logger-tag
(format "Provider '%s': /models endpoint returned status %s"
provider status))))
(catch Exception e
(logger/warn logger-tag
(format "Provider '%s': Failed to fetch models from %s: %s"
provider url (ex-message e))))))))

(defn ^:private provider-with-fetch-models?
"Returns true if provider should fetch models dynamically (fetchModels = true)."
[provider-config]
(and (:api provider-config)
(true? (:fetchModels provider-config))))

(defn ^:private fetch-dynamic-provider-models
"For providers that support dynamic model discovery,
attempts to fetch available models from the API.
Returns a map of {provider-name -> {model-id -> model-config}}."
[config db]
(reduce
(fn [acc [provider provider-config]]
(if (provider-with-fetch-models? provider-config)
(let [api-url (llm-util/provider-api-url provider config)
[_auth-type api-key] (llm-util/provider-api-key provider
(get-in db [:auth provider])
config)
fetched-models (fetch-compatible-models
{:api-url api-url
:api-key api-key
:provider provider})]
(if (seq fetched-models)
(do
(logger/info logger-tag
(format "Provider '%s': Discovered %d models from /models endpoint"
provider (count fetched-models)))
(assoc acc provider fetched-models))
acc))
acc))
{}
(:providers config)))

(defn ^:private build-model-capabilities
"Build capabilities for a single model, looking up from known models database."
[all-models provider model model-config]
(let [real-model-name (or (:modelName model-config) model)
full-real-model (str provider "/" real-model-name)
full-model (str provider "/" model)
model-capabilities (merge
(or (get all-models full-real-model)
;; we guess the capabilities from
;; the first model with same name
(when-let [found-full-model
(->> (keys all-models)
(filter #(or (= (shared/normalize-model-name (string/replace-first real-model-name
#"(.+/)"
""))
(shared/normalize-model-name (second (string/split % #"/" 2))))
(= (shared/normalize-model-name real-model-name)
(shared/normalize-model-name (second (string/split % #"/" 2))))))
first)]
(get all-models found-full-model))
{:tools true
:reason? true
:web-search false})
{:model-name real-model-name})]
[full-model model-capabilities]))

(defn ^:private merge-provider-models
"Merges static config models with dynamically fetched models.
Static config takes precedence (allows user overrides)."
[static-models dynamic-models]
(merge dynamic-models static-models))

(defn sync-models! [db* config on-models-updated]
(let [all-models (all)
db @db*
;; Fetch dynamic models for providers that support it
dynamic-provider-models (fetch-dynamic-provider-models config db)
;; Build all supported models from config + dynamic sources
all-supported-models (reduce
(fn [p [provider provider-config]]
(merge p
(reduce
(fn [m [model model-config]]
(let [real-model-name (or (:modelName model-config) model)
full-real-model (str provider "/" real-model-name)
full-model (str provider "/" model)
model-capabilities (merge
(or (get all-models full-real-model)
;; we guess the capabilities from
;; the first model with same name
(when-let [found-full-model (first (filter #(or (= (shared/normalize-model-name (string/replace-first real-model-name
#"(.+/)"
""))
(shared/normalize-model-name (second (string/split % #"/" 2))))
(= (shared/normalize-model-name real-model-name)
(shared/normalize-model-name (second (string/split % #"/" 2)))))
(keys all-models)))]
(get all-models found-full-model))
{:tools true
:reason? true
:web-search false})
{:model-name real-model-name})]
(assoc m full-model model-capabilities)))
{}
(:models provider-config))))
(let [static-models (:models provider-config)
dynamic-models (get dynamic-provider-models provider)
merged-models (merge-provider-models static-models dynamic-models)]
(merge p
(reduce
(fn [m [model model-config]]
(let [[full-model capabilities] (build-model-capabilities
all-models provider model model-config)]
(assoc m full-model capabilities)))
{}
merged-models))))
{}
(:providers config))
ollama-api-url (llm-util/provider-api-url "ollama" config)
Expand Down
Loading
Loading