Skip to content

Commit ffbf1ba

Browse files
committed
Add github copilot support
1 parent 6f3514d commit ffbf1ba

File tree

13 files changed

+433
-231
lines changed

13 files changed

+433
-231
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Add `/login` command to login to providers
6+
- Add Github Copilot models support with login.
7+
58
## 0.29.2
69

710
- Add `/doctor` command to help with troubleshooting

docs/models.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ The models capabilities and configurations are retrieved from [models.dev](https
44

55
## Built-in providers and capabilities
66

7-
| model | tools (MCP) | reasoning / thinking | prompt caching | web_search |
8-
|-----------|-------------|----------------------|----------------|------------|
9-
| OpenAI |||||
10-
| Anthropic |||||
11-
| Ollama ||| X | X |
7+
| model | tools (MCP) | reasoning / thinking | prompt caching | web_search |
8+
|----------------|-------------|----------------------|----------------|------------|
9+
| OpenAI |||||
10+
| Anthropic |||||
11+
| Github Copilot |||| X |
12+
| Ollama ||| X | X |
1213

1314
### OpenAI
1415

src/eca/config.clj

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
:anthropicApiKey nil
2323
:openaiApiUrl nil
2424
:anthropicApiUrl nil
25+
:githubCopilotApiUrl nil
2526
:ollamaApiUrl nil
2627
:rules []
2728
:commands []
@@ -32,16 +33,21 @@
3233
:disabledTools []
3334
:mcpTimeoutSeconds 60
3435
:mcpServers {}
35-
:models {"gpt-5" {}
36-
"gpt-5-mini" {}
37-
"gpt-5-nano" {}
38-
"gpt-4.1" {}
39-
"o4-mini" {}
40-
"o3" {}
41-
"claude-sonnet-4-20250514" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}
42-
"claude-opus-4-1-20250805" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}
43-
"claude-opus-4-20250514" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}
44-
"claude-3-5-haiku-20241022" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}}
36+
:models {"openai/gpt-5" {}
37+
"openai/gpt-5-mini" {}
38+
"openai/gpt-5-nano" {}
39+
"openai/gpt-4.1" {}
40+
"openai/o4-mini" {}
41+
"openai/o3" {}
42+
"github-copilot/gpt-5-mini" {}
43+
"github-copilot/gpt-4.1" {}
44+
"github-copilot/gpt-4o" {}
45+
"github-copilot/claude-3.5-sonnet" {}
46+
"github-copilot/gemini-2.0-flash-001" {}
47+
"anthropic/claude-sonnet-4" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}
48+
"anthropic/claude-opus-4.1" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}
49+
"anthropic/claude-opus-4" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}
50+
"anthropic/claude-3-5-haiku" {:extraPayload {:thinking {:type "enabled" :budget_tokens 2048}}}}
4551
:ollama {:useTools true
4652
:think true}
4753
:chat {:welcomeMessage "Welcome to ECA!\n\nType '/' for commands\n\n"}

src/eca/db.clj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
:chat-behaviors ["agent" "plan"]
2525
:chat-default-behavior "agent"
2626
:models {}
27+
:auth {}
2728
:mcp-clients {}})
2829

2930
(defonce db* (atom initial-db))
@@ -94,10 +95,10 @@
9495
(when-let [global-cache (read-workspaces-cache (:workspace-folders @db*))]
9596
(swap! db* (fn [state-db]
9697
(merge state-db
97-
(select-keys global-cache [:chats]))))))
98+
(select-keys global-cache [:chats :auth]))))))
9899

99100
(defn ^:private normalize-db-for-write [db]
100-
(-> (select-keys db [:chats])
101+
(-> (select-keys db [:chats :auth])
101102
(update :chats (fn [chats]
102103
(into {}
103104
(map (fn [[k v]]

src/eca/features/chat.clj

Lines changed: 164 additions & 164 deletions
Large diffs are not rendered by default.

src/eca/features/commands.clj

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[clojure.string :as string]
66
[eca.config :as config]
77
[eca.features.index :as f.index]
8+
[eca.features.login :as f.login]
89
[eca.features.prompt :as f.prompt]
910
[eca.features.tools.mcp :as f.mcp]
1011
[eca.llm-api :as llm-api]
@@ -75,6 +76,10 @@
7576
:type :native
7677
:description "Create/update the AGENT.md file teaching LLM about the project"
7778
:arguments []}
79+
{:name "login"
80+
:type :native
81+
:description "Log into a provider (Ex: /login gitub-copilot)"
82+
:arguments [{:name "provider-id"}]}
7883
{:name "costs"
7984
:type :native
8085
:description "Total costs of the current chat session."
@@ -130,13 +135,24 @@
130135
""
131136
(System/getenv))))))
132137

133-
(defn handle-command! [command args chat-id model instructions config db*]
138+
(defn handle-command! [command args {:keys [chat-id db* config full-model instructions]}]
134139
(let [db @db*
135140
custom-commands (custom-commands config (:workspace-folders db))]
136141
(case command
137142
"init" {:type :send-prompt
138143
:clear-history-after-finished? true
139144
:prompt (f.prompt/build-init-prompt db)}
145+
"login" (let [[msg error?] (if-let [provider (first args)]
146+
(let [{:keys [message error]} (f.login/start-login chat-id provider db*)]
147+
(if error
148+
[error true]
149+
[message]))
150+
["Inform the provider-id (Ex: github-copilot)" true])]
151+
{:type :chat-messages
152+
:status (when-not error? :login)
153+
:skip-finish? true
154+
:chats {chat-id (->> [{:role "system" :content [{:type :text :text msg}]}]
155+
(remove nil?))}})
140156
"resume" (let [chats (:chats db)]
141157
;; Override current chat with first chat
142158
(when-let [first-chat (second (first chats))]
@@ -154,7 +170,7 @@
154170
(when total-input-cache-read-tokens
155171
(str "Total input cache read tokens: " total-input-cache-read-tokens))
156172
(str "Total output tokens: " total-output-tokens)
157-
(str "Total cost: $" (shared/tokens->cost total-input-tokens total-input-cache-creation-tokens total-input-cache-read-tokens total-output-tokens model db)))]
173+
(str "Total cost: $" (shared/tokens->cost total-input-tokens total-input-cache-creation-tokens total-input-cache-read-tokens total-output-tokens full-model db)))]
158174
{:type :chat-messages
159175
:chats {chat-id [{:role "system" :content [{:type :text :text text}]}]}})
160176
"doctor" {:type :chat-messages

src/eca/features/login.clj

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
(ns eca.features.login
2+
(:require
3+
[eca.db :as db]
4+
[eca.llm-api :as llm-api]
5+
[eca.messenger :as messenger]))
6+
7+
(defn start-login [chat-id provider db*]
8+
(let [{:keys [error-message auth-type] :as result} (llm-api/auth-start {:provider provider})]
9+
(cond
10+
error-message
11+
(do
12+
(swap! db* assoc-in [:chats chat-id :status] :idle)
13+
{:error true
14+
:message error-message})
15+
16+
(= :oauth/simple auth-type)
17+
(do
18+
(swap! db* assoc-in [:chats chat-id :login-provider] provider)
19+
(swap! db* assoc-in [:auth provider] {:step :login/waiting-user-confirmation
20+
:device-code (:device-code result)})
21+
{:message (format "Open your browser at `%s` and authenticate using the code: `%s`\nThen type anything in the chat and send it to continue the authentication."
22+
(:url result)
23+
(:user-code result))}))))
24+
25+
(defn continue [{:keys [chat-id request-id]} db* messenger]
26+
(let [provider (get-in @db* [:chats chat-id :login-provider])
27+
step (get-in @db* [:auth provider :step])]
28+
(case step
29+
:login/waiting-user-confirmation
30+
(case provider
31+
"github-copilot" (let [{:keys [api-token expires-at error-message]} (llm-api/auth-continue {:provider provider
32+
:db* db*})
33+
msg (or error-message "Login successful! You can now use the 'github-copilot' models.")]
34+
(when-not error-message
35+
(swap! db* update-in [:auth provider] merge {:step :login/done
36+
:api-token api-token
37+
:expires-at expires-at}))
38+
(swap! db* update-in [:chats chat-id :status] :idle)
39+
(messenger/chat-content-received
40+
messenger
41+
{:chat-id chat-id
42+
:request-id request-id
43+
:role "system"
44+
:content {:type :text
45+
:text msg}})
46+
(messenger/chat-content-received
47+
messenger
48+
{:chat-id chat-id
49+
:request-id request-id
50+
:role "system"
51+
:content {:type :progress
52+
:state :finished}}))))
53+
(db/update-workspaces-cache! @db*)))

src/eca/handlers.clj

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
[eca.config :as config]
44
[eca.db :as db]
55
[eca.features.chat :as f.chat]
6+
[eca.features.login :as f.login]
67
[eca.features.tools :as f.tools]
78
[eca.features.tools.mcp :as f.mcp]
89
[eca.llm-api :as llm-api]
@@ -20,12 +21,12 @@
2021
(swap! db* update :models merge eca-models)
2122
(when-let [custom-providers (seq (:customProviders config))]
2223
(let [models (reduce
23-
(fn [models [provider {provider-models :models default-model :defaultModel}]]
24+
(fn [models [custom-provider {provider-models :models default-model :defaultModel}]]
2425
(reduce
2526
(fn [m model]
2627
(let [known-model (get all-models model)]
2728
(assoc m
28-
(str (name provider) "/" model)
29+
(str (name custom-provider) "/" model)
2930
{:tools (or (:tools known-model) true)
3031
:reason? (or (:reason? known-model) true)
3132
:web-search (or (:web-search known-model) true)
@@ -77,7 +78,9 @@
7778
(defn chat-prompt [{:keys [messenger db* config]} params]
7879
(logger/logging-task
7980
:eca/chat-prompt
80-
(f.chat/prompt params db* messenger config)))
81+
(case (get-in @db* [:chats (:chat-id params) :status])
82+
:login (f.login/continue params db* messenger)
83+
(f.chat/prompt params db* messenger config))))
8184

8285
(defn chat-query-context [{:keys [db* config]} params]
8386
(logger/logging-task

src/eca/llm_api.clj

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[clojure.string :as string]
55
[eca.config :as config]
66
[eca.llm-providers.anthropic :as llm-providers.anthropic]
7+
[eca.llm-providers.copilot :as llm-providers.copilot]
78
[eca.llm-providers.ollama :as llm-providers.ollama]
89
[eca.llm-providers.openai :as llm-providers.openai]
910
[eca.llm-providers.openai-chat :as llm-providers.openai-chat]
@@ -49,6 +50,11 @@
4950
(config/get-env "OPENAI_API_URL")
5051
llm-providers.openai/base-url))
5152

53+
(defn ^:private github-copilot-api-url [config]
54+
(or (:githubCopilotApiUrl config)
55+
(config/get-env "GITHUB_COPILOT_API_URL")
56+
llm-providers.copilot/base-api-url))
57+
5258
(defn ^:private ollama-api-url [config]
5359
(or (:ollamaApiUrl config)
5460
(config/get-env "OLLAMA_API_URL")
@@ -82,12 +88,12 @@
8288
(:models db)))]
8389
[:custom-provider-default-model custom-provider-default-model])
8490
(when (anthropic-api-key config)
85-
[:api-key-found "claude-sonnet-4-20250514"])
91+
[:api-key-found "anthropic/claude-sonnet-4"])
8692
(when (openai-api-key config)
87-
[:api-key-found "gpt-5"])
93+
[:api-key-found "openai/gpt-5"])
8894
(when-let [ollama-model (first (filter #(string/starts-with? % config/ollama-model-prefix) (keys (:models db))))]
8995
[:ollama-running ollama-model])
90-
[:default "claude-sonnet-4-20250514"])]
96+
[:default "anthropic/claude-sonnet-4"])]
9197
(logger/info logger-tag (format "Default LLM model '%s' decision '%s'" model decision))
9298
model))
9399

@@ -96,9 +102,9 @@
96102
:type "function"))
97103

98104
(defn complete!
99-
[{:keys [model provider model-config instructions user-messages config on-first-response-received
105+
[{:keys [provider model model-config instructions user-messages config on-first-response-received
100106
on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated
101-
past-messages tools]}]
107+
past-messages tools provider-auth]}]
102108
(let [first-response-received* (atom false)
103109
emit-first-message-fn (fn [& args]
104110
(when-not @first-response-received*
@@ -164,6 +170,25 @@
164170
:api-key (anthropic-api-key config)}
165171
callbacks)
166172

173+
(= "github-copilot" provider)
174+
(llm-providers.openai-chat/completion!
175+
{:model model
176+
:instructions instructions
177+
:user-messages user-messages
178+
:max-output-tokens max-output-tokens
179+
:reason? (:reason? model-config)
180+
:past-messages past-messages
181+
:tools tools
182+
:extra-payload extra-payload
183+
:api-url (github-copilot-api-url config)
184+
:api-key (:api-token provider-auth)
185+
:extra-headers {"openai-intent" "conversation-panel"
186+
"x-request-id" (str (random-uuid))
187+
"vscode-sessionid" ""
188+
"vscode-machineid" ""
189+
"copilot-integration-id" "vscode-chat"}}
190+
callbacks)
191+
167192
(string/starts-with? model config/ollama-model-prefix)
168193
(llm-providers.ollama/completion!
169194
{:api-url (ollama-api-url config)
@@ -206,3 +231,26 @@
206231
(on-error-wrapper {:message (str "ECA Unsupported model: " model)}))
207232
(catch Exception e
208233
(on-error-wrapper {:exception e})))))
234+
235+
(defn auth-start [{:keys [provider]}]
236+
(try
237+
(case provider
238+
"github-copilot" (let [auth (llm-providers.copilot/auth-url)]
239+
{:auth-type :oauth/simple
240+
:url (:url auth)
241+
:device-code (:device-code auth)
242+
:user-code (:user-code auth)})
243+
{:error-message (str "Unknown provider: " provider)})
244+
(catch Exception e
245+
{:error-message (format "Error log into provider %s: %s" provider (.getMessage e))})))
246+
247+
(defn auth-continue [{:keys [provider db*]}]
248+
(try
249+
(case provider
250+
"github-copilot" (let [{:keys [api-token expires-at]} (llm-providers.copilot/auth-exchange (get-in @db* [:auth provider :device-code]))]
251+
{:api-token api-token
252+
:expires-at expires-at})
253+
{:error-message (str "Unknown provider: " provider)})
254+
(catch Exception e
255+
(logger/error logger-tag "Error on login: " e)
256+
{:error-message (format "Error log into provider %s: %s" provider (.getMessage e))})))

src/eca/llm_providers/copilot.clj

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
(ns eca.llm-providers.copilot
2+
(:require
3+
[cheshire.core :as json]
4+
[eca.config :as config]
5+
[hato.client :as http]))
6+
7+
(def base-api-url "https://api.githubcopilot.com")
8+
9+
(def ^:private client-id "Iv1.b507a08c87ecfe98")
10+
11+
(defn ^:private auth-headers []
12+
{"Content-Type" "application/json"
13+
"Accept" "application/json"
14+
"editor-plugin-version" "eca/*"
15+
"editor-version" (str "eca/" (config/eca-version))})
16+
17+
(defn auth-url []
18+
(let [{:keys [body]} (http/post
19+
"https://github.com/login/device/code"
20+
{:headers (auth-headers)
21+
:body (json/generate-string {:client_id client-id
22+
:scope "read:user"})
23+
:as :json})]
24+
{:user-code (:user_code body)
25+
:device-code (:device_code body)
26+
:url (:verification_uri body)}))
27+
28+
(defn auth-exchange [device-code]
29+
(let [{:keys [status body]} (http/post
30+
"https://github.com/login/oauth/access_token"
31+
{:headers (auth-headers)
32+
:body (json/generate-string {:client_id client-id
33+
:device_code device-code
34+
:grant_type "urn:ietf:params:oauth:grant-type:device_code"})
35+
:throw-exceptions? false
36+
:as :json})
37+
access-token (:access_token body)]
38+
(if (= 200 status)
39+
(let [{:keys [body]} (http/get
40+
"https://api.github.com/copilot_internal/v2/token"
41+
{:headers (merge (auth-headers)
42+
{"authorization" (str "token " access-token)})
43+
:throw-exceptions? false
44+
:as :json})]
45+
(if-let [token (:token body)]
46+
{:api-token token
47+
:expires-at (:expires_at body)}
48+
(throw (ex-info (format "Error: You may not have access to Github Copilot")
49+
{:status status
50+
:body body}))))
51+
(throw (ex-info (format "Github auth failed: %s" (pr-str body))
52+
{:status status
53+
:body body})))))
54+
55+
(comment
56+
(def a (auth-url))
57+
(:user-code a)
58+
(:device-code a)
59+
(:url a)
60+
61+
(def credentials (auth-exchange (:device-code a)))
62+
63+
(:api-token credentials)
64+
(:expires-at credentials))

0 commit comments

Comments
 (0)