Skip to content

Commit 283c800

Browse files
committed
Anthropic subscription support, via /login anthropic command.
1 parent a375c22 commit 283c800

File tree

7 files changed

+133
-11
lines changed

7 files changed

+133
-11
lines changed

CHANGELOG.md

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

33
## Unreleased
44

5+
- Anthropic subscription support, via `/login anthropic` command.
6+
57
## 0.34.2
68

79
- Fix copilot requiring login in different workspaces.

deps.edn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
babashka/process {:mvn/version "0.6.23"}
1010
com.cognitect/transit-clj {:mvn/version "1.0.333"}
1111
hato/hato {:mvn/version "1.0.0"}
12+
ring/ring-codec {:mvn/version "1.3.0"}
1213
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
1314
org.slf4j/slf4j-simple {:mvn/version "2.0.17"}
1415
com.googlecode.java-diff-utils/diffutils {:mvn/version "1.3.0"}

src/eca/features/commands.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
(if error
153153
[error true]
154154
[message]))
155-
["Inform the provider-id (Ex: github-copilot)" true])]
155+
["Inform the provider-id (Ex: anthropic, github-copilot)" true])]
156156
{:type :chat-messages
157157
:status (when-not error? :login)
158158
:skip-finish? true

src/eca/features/login.clj

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,59 @@
11
(ns eca.features.login
22
(:require
3+
[clojure.string :as string]
34
[eca.db :as db]
5+
[eca.llm-providers.anthropic :as llm-providers.anthropic]
46
[eca.llm-providers.copilot :as llm-providers.copilot]
57
[eca.messenger :as messenger]))
68

79
(defn start-login [chat-id provider db*]
810
(case provider
11+
"anthropic"
12+
(let [{:keys [verifier url]} (llm-providers.anthropic/oauth-url :console)]
13+
(swap! db* assoc-in [:chats chat-id :login-provider] provider)
14+
(swap! db* assoc-in [:auth provider] {:step :login/waiting-provider-code
15+
:verifier verifier})
16+
{:message (format "Open your browser at `%s` and authenticate at Anthropic.\nThen paste the code generated in the chat and send it to continue the authentication."
17+
url)})
918
"github-copilot"
1019
(let [{:keys [user-code device-code url]} (llm-providers.copilot/oauth-url)]
1120
(swap! db* assoc-in [:chats chat-id :login-provider] provider)
1221
(swap! db* assoc-in [:auth provider] {:step :login/waiting-user-confirmation
1322
:device-code device-code})
1423
{: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."
1524
url
16-
user-code)})))
25+
user-code)})
26+
{:message "Unknown provider-id"}))
1727

18-
(defn continue [{:keys [chat-id request-id]} db* messenger]
28+
(defn continue [{:keys [message chat-id request-id]} db* messenger]
1929
(let [provider (get-in @db* [:chats chat-id :login-provider])
2030
step (get-in @db* [:auth provider :step])]
2131
(case step
32+
:login/waiting-provider-code
33+
(case provider
34+
"anthropic" (let [provider-code (string/trim message)
35+
{:keys [access-token refresh-token expires-at]} (llm-providers.anthropic/oauth-credentials provider-code (get-in @db* [:auth provider :verifier]))
36+
api-key (llm-providers.anthropic/create-api-key access-token)]
37+
(swap! db* update-in [:auth provider] merge {:step :login/done
38+
:access-token access-token
39+
:refresh-token refresh-token
40+
:api-token api-key
41+
:expires-at expires-at})
42+
(swap! db* update-in [:chats chat-id :status] :idle)
43+
(messenger/chat-content-received
44+
messenger
45+
{:chat-id chat-id
46+
:request-id request-id
47+
:role "system"
48+
:content {:type :text
49+
:text "Login successful! You can now use the 'anthropic' models."}})
50+
(messenger/chat-content-received
51+
messenger
52+
{:chat-id chat-id
53+
:request-id request-id
54+
:role "system"
55+
:content {:type :progress
56+
:state :finished}})))
2257
:login/waiting-user-confirmation
2358
(case provider
2459
"github-copilot" (let [access-token (llm-providers.copilot/oauth-access-token (get-in @db* [:auth provider :device-code]))

src/eca/llm_api.clj

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
(string/join "\n" (subvec lines start end)))
3232
content))))
3333

34-
(defn ^:private provider-api-key [provider config]
34+
(defn ^:private provider-api-key [provider provider-auth config]
3535
(or (get-in config [:providers (name provider) :key])
36+
(:api-token provider-auth)
3637
(some-> (get-in config [:providers (name provider) :keyEnv]) config/get-env)))
3738

3839
(defn ^:private provider-api-url [provider config]
@@ -61,9 +62,9 @@
6162
(let [[decision model]
6263
(or (when-let [config-default-model (:defaultModel config)]
6364
[:config-default-model config-default-model])
64-
(when (provider-api-key "anthropic" config)
65+
(when (provider-api-key "anthropic" (get-in db [:auth "anthropic"]) config)
6566
[:api-key-found "anthropic/claude-sonnet-4-20250514"])
66-
(when (provider-api-key "openai" config)
67+
(when (provider-api-key "openai" (get-in db [:auth "openai"]) config)
6768
[:api-key-found "openai/gpt-5"])
6869
(when (get-in db [:auth "github-copilot" :api-key])
6970
[:api-key-found "github-copilot/gpt-4.1"])
@@ -107,7 +108,7 @@
107108
provider-config (get-in config [:providers provider])
108109
model-config (get-in provider-config [:models model])
109110
extra-payload (:extraPayload model-config)
110-
provider-api-key (provider-api-key provider config)
111+
provider-api-key (provider-api-key provider provider-auth config)
111112
provider-api-url (provider-api-url provider config)
112113
callbacks {:on-message-received on-message-received-wrapper
113114
:on-error on-error-wrapper
@@ -158,7 +159,7 @@
158159
:tools tools
159160
:extra-payload extra-payload
160161
:api-url provider-api-url
161-
:api-key (:api-token provider-auth)
162+
:api-key provider-api-key
162163
:extra-headers {"openai-intent" "conversation-panel"
163164
"x-request-id" (str (random-uuid))
164165
"vscode-sessionid" ""

src/eca/llm_providers/anthropic.clj

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
[eca.llm-util :as llm-util]
77
[eca.logger :as logger]
88
[eca.shared :as shared :refer [assoc-some]]
9-
[hato.client :as http]))
9+
[hato.client :as http]
10+
[ring.util.codec :as ring.util]))
1011

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

@@ -107,7 +108,8 @@
107108
:temperature temperature
108109
:stream true
109110
:tools (->tools tools web-search)
110-
:system [{:type "text" :text instructions :cache_control {:type "ephemeral"}}]}
111+
:system [{:type "text" :text "You are Claude Code, Anthropic's official CLI for Claude."}
112+
{:type "text" :text instructions :cache_control {:type "ephemeral"}}]}
111113
:thinking (when (and reason? thinking)
112114
thinking))
113115
extra-payload)
@@ -194,3 +196,56 @@
194196
:content-block* (atom nil)
195197
:on-error on-error
196198
:on-response on-response-fn})))
199+
200+
(def client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e")
201+
202+
(defn oauth-url [mode]
203+
(let [url (str (if (= :console mode) "https://console.anthropic.com" "https://claude.ai") "/oauth/authorize")
204+
{:keys [challenge verifier]} (llm-util/generate-pkce)]
205+
{:verifier verifier
206+
:url (str url "?" (ring.util/form-encode {:code true
207+
:client_id client-id
208+
:response_type "code"
209+
:redirect_uri "https://console.anthropic.com/oauth/code/callback"
210+
:scope "org:create_api_key user:profile user:inference"
211+
:code_challenge challenge
212+
:code_challenge_method "S256"
213+
:state verifier}))}))
214+
215+
(defn oauth-credentials [code verifier]
216+
(let [[code state] (string/split code #"#")
217+
url "https://console.anthropic.com/v1/oauth/token"
218+
body {:grant_type "authorization_code"
219+
:code code
220+
:state state
221+
:client_id client-id
222+
:redirect_uri "https://console.anthropic.com/oauth/code/callback"
223+
:code_verifier verifier}
224+
{:keys [status body]} (http/post
225+
url
226+
{:headers {"Content-Type" "application/json"}
227+
:body (json/generate-string body)
228+
:as :json})]
229+
(if (= 200 status)
230+
{:refresh-token (:refresh_token body)
231+
:access-token (:access_token body)
232+
:expires-at (+ (System/currentTimeMillis) (* 1000 (:expires_in body)))}
233+
(throw (ex-info (format "Anthropic token exchange failed: %s" (pr-str body))
234+
{:status status
235+
:body body})))))
236+
237+
(defn create-api-key [access-token]
238+
(let [url "https://api.anthropic.com/api/oauth/claude_cli/create_api_key"
239+
{:keys [status body]} (http/post
240+
url
241+
{:headers {"Authorization" (str "Bearer " access-token)
242+
"Content-Type" "application/x-www-form-urlencoded"
243+
"Accept" "application/json, text/plain, */*"}
244+
:as :json})]
245+
(if (= 200 status)
246+
(let [raw-key (:raw_key body)]
247+
(when-not (string/blank? raw-key)
248+
raw-key))
249+
(throw (ex-info (format "Anthropic create API token failed: %s" (pr-str body))
250+
{:status status
251+
:body body})))))

src/eca/llm_util.clj

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
[clojure.string :as string]
55
[eca.logger :as logger])
66
(:import
7-
[java.io BufferedReader]))
7+
[java.io BufferedReader]
8+
[java.nio.charset StandardCharsets]
9+
[java.security MessageDigest SecureRandom]
10+
[java.util Base64]))
811

912
(defn event-data-seq [^BufferedReader rdr]
1013
(letfn [(next-group []
@@ -60,3 +63,28 @@
6063

6164
(defn log-response [tag rid event data]
6265
(logger/debug tag (format "[%s] %s %s" rid event data)))
66+
67+
(defn ^:private rand-bytes
68+
"Returns a random byte array of the specified size."
69+
[size]
70+
(let [seed (byte-array size)]
71+
(.nextBytes (SecureRandom.) seed)
72+
seed))
73+
74+
(defn ^:private ->base64 [^bytes bs]
75+
(.encodeToString (Base64/getEncoder) bs))
76+
77+
(defn ^:private ->base64url [base64-str]
78+
(-> base64-str (string/replace "+" "-") (string/replace "/" "_")))
79+
80+
(defn ^:private str->sha256 [^String s]
81+
(-> (MessageDigest/getInstance "SHA-256")
82+
(.digest (.getBytes s StandardCharsets/UTF_8))))
83+
84+
(defn ^:private random-verifier []
85+
(->base64url (->base64 (rand-bytes 63))))
86+
87+
(defn generate-pkce []
88+
(let [verifier (random-verifier)]
89+
{:verifier verifier
90+
:challenge (-> verifier str->sha256 ->base64 ->base64url (string/replace "=" ""))}))

0 commit comments

Comments
 (0)