Skip to content

Commit ece71ca

Browse files
committed
Support provider-relative model aliases for agents
Agent defaultModel could only be a fixed full provider/model id, so the same agent or subagent (e.g. explorer) couldn't follow the currently selected provider across multiple providers. Resolve model ids alias-first: a bare id is matched against the selected provider's models (the chat's own model, or a subagent's parent chat model) before being treated as a full id, so defining the same model name under each provider lets one agent config work everywhere. Stored chat models still win over agent defaults and unknown ids are kept verbatim. Closes #501
1 parent 9459758 commit ece71ca

9 files changed

Lines changed: 170 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- OpenAI OAuth (ChatGPT/Codex) models now report Codex context windows instead of inflated direct-API limits (e.g. gpt-5.5 272k, not 1.05M).
6+
- Agent `defaultModel` (and explicit model ids) resolve alias-first: a bare model id is matched against the currently selected provider's models before being treated as a full id, enabling provider-relative subagent models. (#501)
67

78
## 141.0
89

docs/config.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -815,9 +815,9 @@
815815
},
816816
"defaultModel": {
817817
"type": "string",
818-
"description": "Default model for this agent, overriding the global default.",
819-
"markdownDescription": "Default model for this agent, overriding the global default.",
820-
"examples": ["anthropic/claude-sonnet-4-6"]
818+
"description": "Default model for this agent, overriding the global default. May be a full provider/model id or a bare model name resolved against the currently selected provider's models (provider-relative alias).",
819+
"markdownDescription": "Default model for this agent, overriding the global default. May be a full `provider/model` id or a bare model name resolved against the currently selected provider's models (provider-relative alias).",
820+
"examples": ["anthropic/claude-sonnet-4-6", "explorer-small"]
821821
},
822822
"variant": {
823823
"type": "string",

docs/config/models.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,34 @@ Examples:
424424

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

427+
=== "Provider-relative model alias"
428+
429+
Define the same logical model name under multiple providers (using `modelName` to point at each provider's real model), then reference that name from an agent's `defaultModel`. A bare model id is resolved against the currently selected provider before being treated as a full id, so the same agent config follows whichever provider you switch to.
430+
431+
```javascript title="~/.config/eca/config.json"
432+
{
433+
"providers": {
434+
"github-copilot": {
435+
"models": {
436+
"explorer-small": { "modelName": "raptor-mini" }
437+
}
438+
},
439+
"company-litellm": {
440+
"models": {
441+
"explorer-small": { "modelName": "hf-qwen3-32b-awq" }
442+
}
443+
}
444+
},
445+
"agent": {
446+
"explorer": {
447+
"defaultModel": "explorer-small"
448+
}
449+
}
450+
}
451+
```
452+
453+
With `github-copilot` selected, the `explorer` subagent uses `github-copilot/raptor-mini`; switching to `company-litellm` makes it use `company-litellm/hf-qwen3-32b-awq` with no agent config change. Full ids like `"anthropic/claude-sonnet-4-6"` still work and are used as-is.
454+
427455
=== "Override limits & pricing (local models)"
428456

429457
Models that models.dev doesn't know about (e.g. a local model served via an OpenAI-compatible endpoint) have no context window or pricing, so the usage display shows no upper limit and auto-compaction never triggers. Set `limit` and/or `cost` to fix this. These values override models.dev, so you can also cap a known model's context window. Costs are per 1M tokens.

src/eca/features/chat.clj

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
[eca.logger :as logger]
2626
[eca.messenger :as messenger]
2727
[eca.metrics :as metrics]
28+
[eca.models :as models]
2829
[eca.shared :as shared :refer [assoc-some future*]]))
2930

3031
(set! *warn-on-reflection* true)
@@ -297,23 +298,37 @@
297298
(defn default-model [db config]
298299
(llm-api/default-model db config))
299300

301+
(defn ^:private selected-provider
302+
"Provider currently selected for `chat-id`, derived from the chat's own model
303+
or, for subagents, its parent chat's model."
304+
[db chat-id]
305+
(let [model (or (get-in db [:chats chat-id :model])
306+
(when-let [parent-id (db/parent-chat-id db chat-id)]
307+
(get-in db [:chats parent-id :model])))]
308+
(some-> model shared/full-model->provider+model first)))
309+
300310
(defn ^:private resolve-full-model
301-
"Resolve the full model id for a prompt response and LLM request."
311+
"Resolve the full model id for a prompt response and LLM request.
312+
Model ids are resolved alias-first: a bare id is matched against the currently
313+
selected provider's models before being treated as a literal full model id."
302314
[requested-model db chat-id agent-config config]
303-
(or requested-model
304-
(let [stored-model (get-in db [:chats chat-id :model])
305-
agent-default-model (:defaultModel agent-config)]
306-
(cond
307-
(and stored-model
308-
(contains? (:models db) stored-model))
309-
stored-model
310-
311-
(and agent-default-model
312-
(contains? (:models db) agent-default-model))
313-
agent-default-model
314-
315-
:else
316-
(default-model db config)))))
315+
(let [provider (selected-provider db chat-id)]
316+
(or (when requested-model
317+
(or (models/full-model-for db provider requested-model)
318+
requested-model))
319+
(let [stored-model (get-in db [:chats chat-id :model])
320+
agent-default-model (:defaultModel agent-config)]
321+
(cond
322+
(and stored-model
323+
(contains? (:models db) stored-model))
324+
stored-model
325+
326+
agent-default-model
327+
(or (models/full-model-for db provider agent-default-model)
328+
(default-model db config))
329+
330+
:else
331+
(default-model db config))))))
317332

318333
(defn ^:private update-pre-request-state
319334
"Pure function to compute new state from hook result."

src/eca/features/tools/agent.clj

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
[eca.config :as config]
66
[eca.features.tools.util :as tools.util]
77
[eca.logger :as logger]
8-
[eca.messenger :as messenger]))
8+
[eca.messenger :as messenger]
9+
[eca.models :as models]
10+
[eca.shared :as shared]))
911

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

@@ -161,7 +163,14 @@
161163
:available (available-model-names db)})))))
162164

163165
parent-model (get-in db [:chats chat-id :model])
164-
subagent-model (or user-model (:model subagent) parent-model)
166+
parent-provider (some-> parent-model shared/full-model->provider+model first)
167+
;; The agent's :defaultModel may be a bare alias resolved against the
168+
;; currently selected (parent) provider; keep it verbatim if it doesn't resolve.
169+
subagent-model (or user-model
170+
(when-let [agent-model (:model subagent)]
171+
(or (models/full-model-for db parent-provider agent-model)
172+
agent-model))
173+
parent-model)
165174

166175
;; Variant validation: reject only when the resolved model has configured
167176
;; variants and the user-specified one isn't among them. Models with no

src/eca/models.clj

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,20 @@
530530
{}
531531
(:providers config)))
532532

533+
(defn full-model-for
534+
"Resolve `model-id` to a full \"provider/model\" key present in `db`'s `:models`.
535+
Checks a provider-local alias first (\"<provider>/<model-id>\", when `model-id`
536+
has no provider prefix), then the literal `model-id`. Returns nil when neither
537+
matches a known model."
538+
[db provider model-id]
539+
(when model-id
540+
(let [models (:models db)
541+
alias-model (when (and provider (not (string/includes? model-id "/")))
542+
(str provider "/" model-id))]
543+
(cond
544+
(and alias-model (contains? models alias-model)) alias-model
545+
(contains? models model-id) model-id))))
546+
533547
(defn sync-models! [db* config on-models-updated]
534548
(let [models-dev-data (models-dev)
535549
known-models (all models-dev-data)

test/eca/features/chat_test.clj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,4 +1811,32 @@
18111811
(is (= {} (:chats (h/db))))
18121812
(is (= {} (h/messages)))))
18131813

1814+
(deftest resolve-full-model-alias-test
1815+
(let [db {:models {"company-litellm/big" {}
1816+
"company-litellm/explorer-small" {}
1817+
"github-copilot/explorer-small" {}
1818+
"anthropic/claude-sonnet-4-6" {}}
1819+
:chats {"main" {:id "main" :model "company-litellm/big"}
1820+
"sub" {:id "sub" :parent-chat-id "main"}}}
1821+
resolve-model (fn [requested chat-id agent-config]
1822+
(#'f.chat/resolve-full-model requested db chat-id agent-config {}))]
1823+
(testing "explicit bare alias resolves against the chat's selected provider"
1824+
(is (= "company-litellm/explorer-small"
1825+
(resolve-model "explorer-small" "main" {}))))
1826+
(testing "subagent resolves a bare alias against its parent chat's provider"
1827+
(is (= "company-litellm/explorer-small"
1828+
(resolve-model "explorer-small" "sub" {}))))
1829+
(testing "agent defaultModel bare alias resolves via the selected provider"
1830+
(is (= "company-litellm/explorer-small"
1831+
(resolve-model nil "sub" {:defaultModel "explorer-small"}))))
1832+
(testing "a literal full model id is returned as-is"
1833+
(is (= "anthropic/claude-sonnet-4-6"
1834+
(resolve-model "anthropic/claude-sonnet-4-6" "main" {}))))
1835+
(testing "an unknown explicit model id is kept verbatim (back-compat)"
1836+
(is (= "totally-unknown"
1837+
(resolve-model "totally-unknown" "main" {}))))
1838+
(testing "a stored full model still wins over the agent defaultModel"
1839+
(is (= "company-litellm/big"
1840+
(resolve-model nil "main" {:defaultModel "explorer-small"}))))))
1841+
18141842

test/eca/features/tools/agent_test.clj

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,39 @@
590590
(is (= "anthropic/claude-sonnet-4-6" (:model @chat-prompt-called*))
591591
"user-specified model should win over agent defaultModel"))))))
592592

593+
(deftest spawn-agent-defaultmodel-alias-test
594+
(testing "agent defaultModel bare alias resolves against the parent chat's provider"
595+
(let [config-with-alias (assoc-in test-config [:agent "explorer" :defaultModel] "explorer-small")
596+
db* (atom {:chats {"chat-1" {:id "chat-1" :model "company-litellm/big"}}
597+
:models {"company-litellm/big" {}
598+
"company-litellm/explorer-small" {}
599+
"github-copilot/explorer-small" {}}})
600+
subagent-chat-id "subagent-tc-1"
601+
chat-prompt-called* (promise)]
602+
(with-redefs [requiring-resolve
603+
(fn [sym]
604+
(case sym
605+
eca.features.chat/prompt
606+
(fn [params _db* _messenger _config _metrics]
607+
(deliver chat-prompt-called* params)
608+
(swap! db* assoc-in [:chats subagent-chat-id :status] :idle)
609+
(swap! db* assoc-in [:chats subagent-chat-id :messages]
610+
[{:role "assistant"
611+
:content [{:type :text :text "Done."}]}]))
612+
(clojure.lang.RT/var (namespace sym) (name sym))))]
613+
(let [result ((get-in (f.tools.agent/definitions config-with-alias test-db) ["spawn_agent" :handler])
614+
{"agent" "explorer" "task" "explore" "activity" "exploring"}
615+
{:db* db*
616+
:config config-with-alias
617+
:messenger (h/messenger)
618+
:metrics (h/metrics)
619+
:chat-id "chat-1"
620+
:tool-call-id "tc-1"
621+
:call-state-fn (constantly {:status :executing})})]
622+
(is (match? {:error false} result))
623+
(is (= "company-litellm/explorer-small" (:model @chat-prompt-called*))
624+
"bare alias should resolve to the parent provider's model"))))))
625+
593626
(deftest extract-final-summary-test
594627
(testing "extracts text from last assistant message"
595628
(is (= "Hello world"

test/eca/models_test.clj

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,3 +633,25 @@
633633
(let [supported (build-supported-models config {} models-dev-data)]
634634
(is (= "https://api.openai.com/v1/models" (first @request*)))
635635
(is (= 1050000 (get-in supported ["openai/gpt-5.5" :limit :context]))))))))
636+
637+
(deftest full-model-for-test
638+
(let [db {:models {"github-copilot/explorer-small" {}
639+
"company-litellm/explorer-small" {}
640+
"anthropic/claude-sonnet-4-6" {}}}]
641+
(testing "resolves a bare alias against the given provider (provider-relative)"
642+
(is (= "github-copilot/explorer-small"
643+
(models/full-model-for db "github-copilot" "explorer-small")))
644+
(is (= "company-litellm/explorer-small"
645+
(models/full-model-for db "company-litellm" "explorer-small"))))
646+
(testing "resolves a literal full model id"
647+
(is (= "anthropic/claude-sonnet-4-6"
648+
(models/full-model-for db "github-copilot" "anthropic/claude-sonnet-4-6"))))
649+
(testing "returns nil when neither alias nor literal matches"
650+
(is (nil? (models/full-model-for db "github-copilot" "missing")))
651+
(is (nil? (models/full-model-for db "github-copilot" "openai/missing"))))
652+
(testing "returns nil for a nil model id"
653+
(is (nil? (models/full-model-for db "github-copilot" nil))))
654+
(testing "without a provider only literal full ids resolve"
655+
(is (nil? (models/full-model-for db nil "explorer-small")))
656+
(is (= "anthropic/claude-sonnet-4-6"
657+
(models/full-model-for db nil "anthropic/claude-sonnet-4-6"))))))

0 commit comments

Comments
 (0)