Skip to content

Commit 736887e

Browse files
committed
Support key with netrc
1 parent de306e0 commit 736887e

File tree

9 files changed

+142
-75
lines changed

9 files changed

+142
-75
lines changed

CHANGELOG.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22

33
## Unreleased
44

5-
- Support `${classapath:path/to/eca/classpath/file}` in dynamic string parse.
6-
- Support default env values in `${env:MY_ENV:default-value}`.
7-
- Deprecate configs:
5+
- Better config values dynamic string parse:
6+
- Support `${classapath:path/to/eca/classpath/file}` in dynamic string parse.
7+
- Support `${netrc:api.foo.com}` in dynamic string parse to parse keys.
8+
- Support default env values in `${env:MY_ENV:default-value}`.
9+
- Support for ECA_CONFIG and custom config file.
10+
- Deprecate configs:
811
- `systemPromptFile` in favor of `systemPrompt` using `${file:...}` or `${classpath:...}`
912
- `urlEnv` in favor of `url` using `${env:...}`
13+
- `keyEnv` in favor of `key` using `${env:...}`
14+
- `keyRc` in favor of `key` using `${netrc:...}`
1015

1116
## 0.83.0
1217

docs/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,14 @@ There are multiples ways to configure ECA:
4646
ECA_CONFIG='{"myConfig": "my_value"}' eca server
4747
```
4848

49-
!!! info "Dynamic string contents"
49+
### Dynamic string contents
5050

5151
It's possible to retrieve content of any configs with a string value using the `${key:value}` approach, being `key`:
5252

5353
- `file`: `${file:/path/to/my-file}` or `${file:../rel-path/to/my-file}` to get a file content
5454
- `env`: `${env:MY_ENV}` to get a system env value
5555
- `classpath`: `${classpath:path/to/eca/file}` to get a file content from [ECA's classpath](https://github.com/editor-code-assistant/eca/tree/master/resources)
56+
- `netrc`: Support Unix RC [credential files](./models.md#credential-file-authentication)
5657

5758
## Providers / Models
5859

docs/models.md

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ Schema:
6565
|-------------------------------|--------|--------------------------------------------------------------------------------------------------------------|----------|
6666
| `api` | string | The API schema to use (`"openai-responses"`, `"openai-chat"`, or `"anthropic"`) | Yes |
6767
| `url` | string | API URL (with support for env like `${env:MY_URL}`) | No* |
68-
| `keyEnv` | string | Environment variable name containing the API key | No* |
69-
| `keyRc` | string | Lookup specification to read the API key from Unix RC [credential files](#credential-file-authentication) | No* |
70-
| `key` | string | Direct API key (use instead of `keyEnv`) | No* |
68+
| `key` | string | API key (with support for `${env:MY_KEY}` or `{netrc:api.my-provider.com}` | No* |
7169
| `completionUrlRelativePath` | string | Optional override for the completion endpoint path (see defaults below and examples like Azure) | No |
7270
| `thinkTagStart` | string | Optional override the think start tag tag for openai-chat (Default: "<think>") api | No |
7371
| `thinkTagEnd` | string | Optional override the think end tag for openai-chat (Default: "</think>") api | No |
@@ -86,8 +84,8 @@ Examples:
8684
"providers": {
8785
"my-company": {
8886
"api": "openai-chat",
89-
"urlEnv": "MY_COMPANY_API_URL", // or "url"
90-
"keyEnv": "MY_COMPANY_API_KEY", // or "key"
87+
"url": "${env:MY_COMPANY_API_URL}",
88+
"key": "${env:MY_COMPANY_API_KEY}",
9189
"models": {
9290
"gpt-5": {},
9391
"deepseek-r1": {}
@@ -180,7 +178,7 @@ Further reading on credential file formats:
180178
- [GNU Inetutils .netrc documentation](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html)
181179

182180
Notes:
183-
- Authentication priority (short): `key` > `keyRc files` > `keyEnv` > OAuth.
181+
- Authentication priority (short): `key` (with dynamic string pase support) > OAuth.
184182
- All providers with API key auth can use credential files.
185183

186184
## Providers examples
@@ -215,8 +213,8 @@ Notes:
215213
"providers": {
216214
"litellm": {
217215
"api": "openai-responses",
218-
"url": "https://litellm.my-company.com", // or "urlEnv"
219-
"key": "your-api-key", // or "keyEnv"
216+
"url": "https://litellm.my-company.com",
217+
"key": "your-api-key",
220218
"models": {
221219
"gpt-5": {},
222220
"deepseek-r1": {}
@@ -243,8 +241,8 @@ Notes:
243241
"providers": {
244242
"openrouter": {
245243
"api": "openai-chat",
246-
"url": "https://openrouter.ai/api/v1", // or "urlEnv"
247-
"key": "your-api-key", // or "keyEnv"
244+
"url": "https://openrouter.ai/api/v1",
245+
"key": "your-api-key",
248246
"models": {
249247
"anthropic/claude-3.5-sonnet": {},
250248
"openai/gpt-4-turbo": {},
@@ -272,8 +270,8 @@ Notes:
272270
"providers": {
273271
"deepseek": {
274272
"api": "openai-chat",
275-
"url": "https://api.deepseek.com", // or "urlEnv"
276-
"key": "your-api-key", // or "keyEnv"
273+
"url": "https://api.deepseek.com",
274+
"key": "your-api-key",
277275
"models": {
278276
"deepseek-chat": {},
279277
"deepseek-coder": {},
@@ -300,8 +298,8 @@ Notes:
300298
"providers": {
301299
"azure": {
302300
"api": "openai-responses",
303-
"url": "https://your-resource-name.openai.azure.com", // or "urlEnv"
304-
"key": "your-api-key", // or "keyEnv"
301+
"url": "https://your-resource-name.openai.azure.com",
302+
"key": "your-api-key",
305303
"completionUrlRelativePath": "/openai/responses?api-version=2025-04-01-preview",
306304
"models": {
307305
"gpt-5": {}
@@ -327,7 +325,7 @@ Notes:
327325
"z-ai": {
328326
"api": "anthropic",
329327
"url": "https://api.z.ai/api/anthropic",
330-
"key": "your-api-key", // or "keyEnv"
328+
"key": "your-api-key",
331329
"models": {
332330
"GLM-4.5": {},
333331
"GLM-4.5-Air": {}

integration-test/integration/chat/ollama_test.clj

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
(eca/notify! (fixture/initialized-notification))
2323
(testing "the default model is local"
2424
(is (match?
25-
{:chat {:models (m/embeds ["ollama/qwen3"])
26-
:selectModel "ollama/qwen3"}}
25+
{:chat {:models (m/embeds ["ollama/qwen3"])}}
2726
(eca/client-awaits-server-notification :config/updated))))
2827
(let [chat-id* (atom nil)]
2928
(testing "We send a simple hello message"
@@ -116,8 +115,7 @@
116115
(eca/notify! (fixture/initialized-notification))
117116
(testing "the default model is local"
118117
(is (match?
119-
{:chat {:models (m/embeds ["ollama/qwen3"])
120-
:selectModel "ollama/qwen3"}}
118+
{:chat {:models (m/embeds ["ollama/qwen3"])}}
121119
(eca/client-awaits-server-notification :config/updated))))
122120
(let [chat-id* (atom nil)]
123121
(testing "We send a hello message"
@@ -195,8 +193,7 @@
195193
(eca/notify! (fixture/initialized-notification))
196194
(testing "the default model is local"
197195
(is (match?
198-
{:chat {:models (m/embeds ["ollama/qwen3"])
199-
:selectModel "ollama/qwen3"}}
196+
{:chat {:models (m/embeds ["ollama/qwen3"])}}
200197
(eca/client-awaits-server-notification :config/updated))))
201198
(let [chat-id* (atom nil)]
202199
(testing "We ask what files LLM see"

integration-test/integration/fixture.clj

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,13 @@
99

1010
(def default-providers
1111
{"openai" {:url (str base-llm-mock-url "/openai")
12-
:key "foo-key"
13-
:keyEnv "FOO"}
12+
:key "${env:FOO:foo-key}"}
1413
"anthropic" {:url (str base-llm-mock-url "/anthropic")
15-
:key "foo-key"
16-
:keyEnv "FOO"}
14+
:key "${env:FOO:foo-key}"}
1715
"github-copilot" {:url (str base-llm-mock-url "/github-copilot")
18-
:key "foo-key"
19-
:keyEnv "FOO"}
16+
:key "${env:FOO:foo-key}"}
2017
"google" {:url (str base-llm-mock-url "/google")
21-
:key "foo-key"
22-
:keyEnv "FOO"}})
18+
:key "${env:FOO:foo-key}"}})
2319

2420
(def default-init-options {:pureConfig true
2521
:env "test"

src/eca/config.clj

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
[clojure.walk :as walk]
1919
[eca.logger :as logger]
2020
[eca.messenger :as messenger]
21+
[eca.secrets :as secrets]
2122
[eca.shared :as shared :refer [multi-str]])
2223
(:import
2324
[java.io File]))
@@ -41,8 +42,7 @@
4142
(def ^:private initial-config*
4243
{:providers {"openai" {:api "openai-responses"
4344
:url "${env:OPENAI_API_URL:https://api.openai.com}"
44-
:key nil
45-
:keyEnv "OPENAI_API_KEY"
45+
:key "${env:OPENAI_API_KEY}"
4646
:requiresAuth? true
4747
:models {"gpt-5.1" {}
4848
"gpt-5-codex" {}
@@ -54,8 +54,7 @@
5454
"o3" {}}}
5555
"anthropic" {:api "anthropic"
5656
:url "${env:ANTHROPIC_API_URL:https://api.anthropic.com}"
57-
:key nil
58-
:keyEnv "ANTHROPIC_API_KEY"
57+
:key "${env:ANTHROPIC_API_KEY}"
5958
:requiresAuth? true
6059
:models {"claude-sonnet-4.5" {:modelName "claude-sonnet-4-5-20250929"}
6160
"claude-opus-4.5" {:modelName "claude-opus-4-5-20251101"}
@@ -64,7 +63,6 @@
6463
"github-copilot" {:api "openai-chat"
6564
:url "${env:GITHUB_COPILOT_API_URL:https://api.githubcopilot.com}"
6665
:key nil ;; not supported, requires login auth
67-
:keyEnv nil ;; not supported, requires login auth
6866
:requiresAuth? true
6967
:models {"claude-haiku-4.5" {}
7068
"claude-opus-4.1" {}
@@ -79,8 +77,7 @@
7977
"gemini-2.5-pro" {}}}
8078
"google" {:api "openai-chat"
8179
:url "${env:GOOGLE_API_URL:https://generativelanguage.googleapis.com/v1beta/openai}"
82-
:key nil
83-
:keyEnv "GOOGLE_API_KEY"
80+
:key "${env:GOOGLE_API_KEY}"
8481
:requiresAuth? true
8582
:models {"gemini-2.0-flash" {}
8683
"gemini-2.5-pro" {}}}
@@ -150,7 +147,8 @@
150147
"Given a string and a current working directory, look for patterns replacing its content:
151148
- `${env:SOME-ENV:default-value}`: Replace with a env falling back to a optional default value
152149
- `${file:/some/path}`: Replace with a file content checking from cwd if relative
153-
- `${classpath:path/to/file}`: Replace with a file content found checking classpath"
150+
- `${classpath:path/to/file}`: Replace with a file content found checking classpath
151+
- `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/models/#credential-file-authentication)"
154152
[s cwd]
155153
(some-> s
156154
(string/replace #"\$\{env:([^:}]+)(?::([^}]*))?\}"
@@ -173,6 +171,13 @@
173171
(slurp (io/resource resource-path))
174172
(catch Exception e
175173
(logger/warn logger-tag "Error reading classpath resource:" (.getMessage e))
174+
""))))
175+
(string/replace #"\$\{netrc:([^}]+)\}"
176+
(fn [[_match key-rc]]
177+
(try
178+
(or (secrets/get-credential key-rc) "")
179+
(catch Exception e
180+
(logger/warn logger-tag "Error reading netrc credential:" (.getMessage e))
176181
""))))))
177182

178183
(defn ^:private parse-dynamic-string-values
@@ -213,15 +218,17 @@
213218

214219
(defn ^:private config-from-envvar* []
215220
(some-> (System/getenv "ECA_CONFIG")
216-
(safe-read-json-string (var *env-var-config-error*))))
221+
(safe-read-json-string (var *env-var-config-error*))
222+
(parse-dynamic-string-values (io/file "."))))
217223

218224
(def ^:private config-from-envvar (memoize config-from-envvar*))
219225

220226
(defn ^:private config-from-custom* []
221227
(when-some [path @custom-config-file-path*]
222228
(let [config-file (io/file path)]
223229
(when (.exists config-file)
224-
(safe-read-json-string (slurp config-file) (var *custom-config-error*))))))
230+
(some-> (safe-read-json-string (slurp config-file) (var *custom-config-error*))
231+
(parse-dynamic-string-values (fs/file (fs/parent config-file))))))))
225232

226233
(def ^:private config-from-custom (memoize config-from-custom*))
227234

src/eca/llm_util.clj

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
(ns eca.llm-util
22
(:require
3+
[camel-snake-kebab.core :as csk]
34
[cheshire.core :as json]
45
[clojure.string :as string]
56
[eca.config :as config]
67
[eca.logger :as logger]
78
[eca.secrets :as secrets]
8-
[eca.shared :as shared]
9-
[camel-snake-kebab.core :as csk])
9+
[eca.shared :as shared])
1010
(:import
1111
[java.io BufferedReader]
1212
[java.nio.charset StandardCharsets]
@@ -100,10 +100,13 @@
100100
:challenge (-> verifier str->sha256 ->base64 ->base64url (string/replace "=" ""))}))
101101

102102
(defn provider-api-key [provider provider-auth config]
103-
(or (when-let [key (get-in config [:providers (name provider) :key])]
103+
(or (when-let [key (not-empty (get-in config [:providers (name provider) :key]))]
104104
[:auth/token key])
105105
(when-let [key (:api-key provider-auth)]
106106
[:auth/oauth key])
107+
(when-let [key (config/get-env (str (csk/->SCREAMING_SNAKE_CASE (name provider)) "_API_KEY"))]
108+
[:auth/token key])
109+
;; legacy
107110
(when-let [key (some-> (get-in config [:providers (name provider) :keyRc])
108111
(secrets/get-credential (:netrcFile config)))]
109112
[:auth/token key])
@@ -112,7 +115,7 @@
112115
[:auth/token key])))
113116

114117
(defn provider-api-url [provider config]
115-
(or (get-in config [:providers (name provider) :url])
118+
(or (not-empty (get-in config [:providers (name provider) :url]))
116119
(config/get-env (str (csk/->SCREAMING_SNAKE_CASE (name provider)) "_API_URL"))
117120
(some-> (get-in config [:providers (name provider) :urlEnv]) config/get-env) ;; legacy
118121
))

0 commit comments

Comments
 (0)