Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add `models/refresh` to update the model catalog without restarting; report provider fallback and stale-model warnings while preserving chat selections.

## 0.163.0

- Add subagent `timeoutSeconds`; on timeout or max steps the subagent gets a final no-tools turn to report findings, and a parent stop keeps its partial output. #625
Expand Down
24 changes: 24 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -3504,6 +3504,30 @@ interface MCPRemoveServerResult {
}
```

## Model Catalog

### Refresh Models (↩️)

Request a fresh model catalog without changing provider configuration or chat selections. The request completes after reconciliation. It does not select a model. `providers/list` remains a read-only snapshot.

_Request:_

* method: `models/refresh`
* params: `{}`

_Response:_

```typescript
interface ModelsRefreshResult {
modelCount: number;
warnings: { provider: string; message: string }[];
}
```

A successful response can contain warnings. Discovery uses the native provider endpoint first, then `models.dev`. If both sources fail, the server retains that provider's last known in-memory models and reports them as stale. A disk-cache fallback produces one `models.dev` warning because cached metadata or fallback lists can be stale. An Ollama fetch failure retains its last known models when Ollama remains configured and reports stale data. A valid empty Ollama catalog removes those models. Failed near-expiry token renewal produces a provider warning. Static configured models and available Ollama models remain eligible. If no usable models remain, the request returns a JSON-RPC error with code `no_usable_model_catalog`. The server keeps the current model list in that case.

After successful reconciliation, the server sends `config/updated` with `chat.models` when that list changes. The response does not carry the list. The refresh notification does not contain `selectModel`, `selectAgent`, or `selectVariant`. Existing chat selections stay unchanged, even if a selected model leaves the catalog.

## Provider Management

### List Providers (↩️)
Expand Down
25 changes: 25 additions & 0 deletions integration-test/integration/initialize_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
[clojure.test :refer [deftest is testing]]
[integration.eca :as eca]
[integration.fixture :as fixture]
[llm-mock.mocks :as llm.mocks]
[matcher-combinators.matchers :as m]
[matcher-combinators.test :refer [match?]]))

Expand Down Expand Up @@ -96,3 +97,27 @@
:selectAgent "code"
:welcomeMessage (m/pred #(string/includes? % "Welcome to ECA!"))}}
(eca/client-awaits-server-notification :config/updated))))))

(deftest refresh-models-in-running-server
(eca/start-process!)
(eca/request! (fixture/initialize-request
{:initializationOptions
(assoc fixture/default-init-options :providers
{"ollama" {:url (str fixture/base-llm-mock-url "/ollama")}})}))
(eca/notify! (fixture/initialized-notification))
(is (match? {:chat {:models ["ollama/qwen3"]}}
(eca/client-awaits-server-notification :config/updated)))

(testing "a refresh returns its result and publishes a changed catalog"
(llm.mocks/set-case! :refresh-new-model)
(is (match? {:modelCount (m/pred number?)
:warnings (m/pred vector?)}
(eca/request! [:models/refresh {}])))
(is (match? {:chat {:models ["ollama/qwen4"]}}
(eca/client-awaits-server-notification :config/updated))))

(testing "an empty catalog returns a JSON-RPC error, not a success result"
(llm.mocks/set-case! :refresh-empty)
(let [response (eca/request! [:models/refresh {}])]
(is (match? {:error {:code "no_usable_model_catalog"}} response))
(is (not (contains? response :result))))))
6 changes: 5 additions & 1 deletion integration-test/llm_mock/ollama.clj
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@
(defn handle-ollama-tags [_req]
{:status 200
:headers {"Content-Type" "application/json"}
:body (json/generate-string {:models [{:model "qwen3"}]})})
:body (json/generate-string
{:models (case llm.mocks/*case*
:refresh-new-model [{:model "qwen4"}]
:refresh-empty []
[{:model "qwen3"}])})})

(defn handle-ollama-show [req]
(let [_body (some-> (slurp (:body req)) (json/parse-string true))]
Expand Down
18 changes: 11 additions & 7 deletions src/eca/features/login.clj
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,14 @@
otherwise call provider endpoints with a stale short-lived token (e.g.
Copilot's session token) and get a 401. `ctx` needs {:db* :messenger :config
:metrics}."
[{:keys [db*] :as ctx}]
[{:keys [db* on-renew-error] :as ctx}]
(doseq [provider (keys (:auth @db*))]
(maybe-renew-auth-token!
{:provider provider
:on-error (fn [msg]
(logger/warn logger-tag
(format "Could not renew '%s' auth token before sync: %s" provider msg)))}
(format "Could not renew '%s' auth token before sync: %s" provider msg))
(when on-renew-error (on-renew-error provider msg)))}
ctx)))

(defn login-done! [{:keys [chat-id db* messenger metrics provider send-msg!]}
Expand All @@ -223,12 +224,15 @@
(db/update-global-auth-cache! @db* provider metrics))
(when-not skip-models-sync?
(models/sync-models! db*
(config/all @db*) ;; force get updated config
#(config/all @db*)
(fn [new-models]
(messenger/config-updated
messenger
{:chat
{:models (sort (keys new-models))}}))))
(config/notify-fields-changed-only!
{:chat {:models (sort (keys new-models))}}
messenger db*))
{:before-sync (fn [config _warn!]
(renew-expiring-auth-tokens!
{:db* db* :messenger messenger
:config config :metrics metrics}))}))
(swap! db* assoc-in [:chats chat-id :login-provider] nil)
(swap! db* assoc-in [:chats chat-id :status] :idle)
(when-not silent?
Expand Down
95 changes: 56 additions & 39 deletions src/eca/features/providers.clj
Original file line number Diff line number Diff line change
Expand Up @@ -208,16 +208,28 @@
(messenger/provider-updated messenger
(build-provider-status provider-name @db* config)))

(def ^:dynamic *model-sync-turn* nil)

(defn sync-and-notify!
"Common post-login/logout action: persist auth cache, re-sync models,
and send both config/updated and providers/updated notifications."
[provider-name db* messenger metrics]
(db/update-global-auth-cache! @db* provider-name metrics)
(let [config (config/all @db*)]
(models/sync-models! db* config
(fn [new-models]
(messenger/config-updated messenger {:chat {:models (sort (keys new-models))}})
(notify-provider-updated! provider-name db* config messenger)))))
([provider-name db* messenger metrics]
(sync-and-notify! provider-name db* messenger metrics nil))
([provider-name db* messenger metrics turn]
(db/update-global-auth-cache! @db* provider-name metrics)
(let [sync-config* (volatile! nil)]
(models/sync-models! db* #(config/all @db*)
(fn [new-models]
(config/notify-fields-changed-only!
{:chat {:models (sort (keys new-models))}} messenger db*)
(notify-provider-updated! provider-name db* @sync-config* messenger))
{:turn (or turn *model-sync-turn*)
:before-sync (fn [config _warn!]
(vreset! sync-config* config)
;; Login already depends on providers, so resolve renewal at call time.
((requiring-resolve 'eca.features.login/renew-expiring-auth-tokens!)
{:db* db* :messenger messenger
:config config :metrics metrics}))}))))

;; --- Login dispatch ---

Expand Down Expand Up @@ -287,38 +299,43 @@

(defn provider-login-input
"Processes login input submitted by the client."
[provider-name data db* config messenger metrics]
(if (:code data)
;; OAuth code exchange (e.g., Anthropic after browser auth)
(complete-oauth-code! provider-name data db* config messenger metrics)
;; API key input
(let [base-provider (config/provider-base provider-name config)
api-key (get data :api-key)
models-str (get data :models)
url (get data :url)
provider-cfg (get provider-configs base-provider)
models-map (when (not-empty models-str)
(into {} (map (fn [m] [(string/trim m) {}])
(string/split models-str #","))))]
(if (get provider-login-fields base-provider)
;; Providers that save to config (google, deepseek, openrouter, z-ai, azure)
(let [config-update (shared/assoc-some
(merge {:key api-key} provider-cfg)
:url (or url (:url provider-cfg))
:models models-map)]
(config/update-global-config! {:providers {provider-name config-update}})
(swap! db* assoc-in [:auth provider-name] {:step :login/done :type :auth/token}))
;; Manual key for OAuth providers (anthropic, openai)
(swap! db* assoc-in [:auth provider-name] {:step :login/done
:type :auth/token
:api-key api-key
:mode :manual}))
(sync-and-notify! provider-name db* messenger metrics)
{:action "done"})))
([provider-name data db* config messenger metrics]
(provider-login-input provider-name data db* config messenger metrics nil))
([provider-name data db* config messenger metrics turn]
(if (:code data)
;; OAuth code exchange (e.g., Anthropic after browser auth)
(binding [*model-sync-turn* turn]
(complete-oauth-code! provider-name data db* config messenger metrics))
;; API key input
(let [base-provider (config/provider-base provider-name config)
api-key (get data :api-key)
models-str (get data :models)
url (get data :url)
provider-cfg (get provider-configs base-provider)
models-map (when (not-empty models-str)
(into {} (map (fn [m] [(string/trim m) {}])
(string/split models-str #","))))]
(if (get provider-login-fields base-provider)
;; Providers that save to config (google, deepseek, openrouter, z-ai, azure)
(let [config-update (shared/assoc-some
(merge {:key api-key} provider-cfg)
:url (or url (:url provider-cfg))
:models models-map)]
(config/update-global-config! {:providers {provider-name config-update}})
(swap! db* assoc-in [:auth provider-name] {:step :login/done :type :auth/token}))
;; Manual key for OAuth providers (anthropic, openai)
(swap! db* assoc-in [:auth provider-name] {:step :login/done
:type :auth/token
:api-key api-key
:mode :manual}))
(sync-and-notify! provider-name db* messenger metrics turn)
{:action "done"}))))

(defn provider-logout
"Clears auth for a provider and re-syncs models."
[provider-name db* _config messenger metrics]
(swap! db* assoc-in [:auth provider-name] {})
(sync-and-notify! provider-name db* messenger metrics)
{})
([provider-name db* config messenger metrics]
(provider-logout provider-name db* config messenger metrics nil))
([provider-name db* _config messenger metrics turn]
(swap! db* assoc-in [:auth provider-name] {})
(sync-and-notify! provider-name db* messenger metrics turn)
{}))
45 changes: 34 additions & 11 deletions src/eca/handlers.clj
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,15 @@
(let [new-providers-hash (hash (:providers config))]
(when (not= (:providers-config-hash @db*) new-providers-hash)
(swap! db* assoc :providers-config-hash new-providers-hash)
;; Refresh near-expiry OAuth tokens before fetching catalogs, so
;; this non-interactive path doesn't hit /models with a stale
;; short-lived token (e.g. Copilot) and 401.
(f.login/renew-expiring-auth-tokens!
{:db* db* :messenger messenger :config config :metrics metrics})
;; The callback re-reads config, so plugin agents are included
;; when plugin resolution finishes before model sync.
(models/sync-models! db* config (fn [_models]
(notify-chat-config-state! db* messenger))))))]
(models/sync-models! db* #(config/all @db*)
(fn [_models]
(notify-chat-config-state! db* messenger))
{:before-sync (fn [config _warn!]
(f.login/renew-expiring-auth-tokens!
{:db* db* :messenger messenger
:config config :metrics metrics}))}))))]
(swap! db* assoc-in [:config-updated-fns :sync-models]
(fn [_prev-config new-config] (sync-models-and-notify! new-config)))
(swap! db* assoc-in [:config-updated-fns :mcp-reconcile]
Expand Down Expand Up @@ -613,6 +613,29 @@
:status nil
:exit-code nil})))

(defn models-refresh [{:keys [db* messenger metrics model-sync-turn]} _params]
(metrics/task metrics :eca/models-refresh
(try
(let [{:keys [model-count warnings]}
(models/sync-models!
db* #(config/all @db*)
(fn [new-models]
(config/notify-fields-changed-only!
{:chat {:models (sort (keys new-models))}} messenger db*))
{:refresh? true
:turn model-sync-turn
:before-sync (fn [config warn!]
(f.login/renew-expiring-auth-tokens!
{:db* db* :messenger messenger :config config
:metrics metrics :on-renew-error warn!}))})]
{:modelCount model-count :warnings warnings})
(catch clojure.lang.ExceptionInfo e
(if (= :no-usable-model-catalog (:type (ex-data e)))
{:error {:code "no_usable_model_catalog" :message (ex-message e)}}
(throw e)))
(finally
(when model-sync-turn (deliver (:done model-sync-turn) true))))))

(defn providers-list [{:keys [db* config metrics]} _params]
(metrics/task metrics :eca/providers-list
(f.providers/providers-list db* config)))
Expand All @@ -622,11 +645,11 @@
(handle-expected-errors
(f.providers/provider-login (:provider params) (:method params) db* config messenger metrics))))

(defn providers-login-input [{:keys [db* config messenger metrics]} params]
(defn providers-login-input [{:keys [db* config messenger metrics model-sync-turn]} params]
(metrics/task metrics :eca/providers-login-input
(handle-expected-errors
(f.providers/provider-login-input (:provider params) (:data params) db* config messenger metrics))))
(f.providers/provider-login-input (:provider params) (:data params) db* config messenger metrics model-sync-turn))))

(defn providers-logout [{:keys [db* config messenger metrics]} params]
(defn providers-logout [{:keys [db* config messenger metrics model-sync-turn]} params]
(metrics/task metrics :eca/providers-logout
(f.providers/provider-logout (:provider params) db* config messenger metrics)))
(f.providers/provider-logout (:provider params) db* config messenger metrics model-sync-turn)))
12 changes: 9 additions & 3 deletions src/eca/llm_providers/ollama.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
(:require
[cheshire.core :as json]
[clojure.java.io :as io]
[clojure.string :as string]
[eca.client-http :as client]
[eca.llm-util :as llm-util]
[eca.logger :as logger]
Expand All @@ -28,12 +29,17 @@
(if (= 200 status)
(do
(llm-util/log-response logger-tag rid "api/tags" body)
(:models body))
(let [models (:models body)]
(when (and (vector? models)
(every? #(and (map? %)
(string? (:model %))
(not (string/blank? (:model %)))) models))
models)))
(do
(logger/warn logger-tag "Unknown status code:" status)
[])))
nil)))
(catch Exception _
[])))
nil)))

(defn model-capabilities [{:keys [model api-url]}]
(try
Expand Down
Loading
Loading