Skip to content

Commit fda0c72

Browse files
committed
Add oauth support for openai WIP
1 parent f822c26 commit fda0c72

File tree

10 files changed

+188
-16
lines changed

10 files changed

+188
-16
lines changed

deps.edn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
com.cognitect/transit-clj {:mvn/version "1.0.333"}
1111
hato/hato {:mvn/version "1.0.0"}
1212
ring/ring-codec {:mvn/version "1.3.0"}
13+
ring/ring-jetty-adapter {:mvn/version "1.15.2"}
1314
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
1415
org.slf4j/slf4j-simple {:mvn/version "2.0.17"}
1516
com.googlecode.java-diff-utils/diffutils {:mvn/version "1.3.0"}

src/eca/llm_api.clj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@
121121
:web-search web-search
122122
:extra-payload extra-payload
123123
:api-url api-url
124-
:api-key api-key}
124+
:api-key api-key
125+
:auth-type provider-auth-type}
125126
callbacks)
126127

127128
(= "anthropic" provider)

src/eca/llm_providers/anthropic.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@
380380
(if (string/starts-with? input "sk-")
381381
(do
382382
(config/update-global-config! {:providers {"anthropic" {:key input}}})
383-
(swap! db* dissoc :auth provider)
383+
(swap! db* update :auth dissoc provider)
384384
(send-msg! (format "API key and models saved to %s" (.getCanonicalPath (config/global-config-file))))
385385
(f.login/login-done! ctx))
386386
(send-msg! (format "Invalid API key '%s'" input))))

src/eca/llm_providers/azure.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@
3030
{}
3131
(string/split input #","))
3232
:key api-key}}}))
33-
(swap! db* dissoc :auth provider)
33+
(swap! db* update :auth dissoc provider)
3434
(send-msg! (format "API key, url and models saved to %s" (.getCanonicalPath (config/global-config-file))))
3535
(f.login/login-done! ctx))

src/eca/llm_providers/deepseek.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424
{}
2525
(string/split input #","))
2626
:key api-key}}}))
27-
(swap! db* dissoc :auth provider)
27+
(swap! db* update :auth dissoc provider)
2828
(send-msg! (format "API key and models saved to %s" (.getCanonicalPath (config/global-config-file))))
2929
(f.login/login-done! ctx))

src/eca/llm_providers/openai.clj

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,40 @@
77
[eca.features.login :as f.login]
88
[eca.llm-util :as llm-util]
99
[eca.logger :as logger]
10-
[hato.client :as http]))
10+
[eca.oauth :as oauth]
11+
[eca.shared :refer [assoc-some multi-str]]
12+
[hato.client :as http]
13+
[ring.util.codec :as ring.util]))
1114

1215
(set! *warn-on-reflection* true)
1316

1417
(def ^:private logger-tag "[OPENAI]")
1518

1619
(def ^:private responses-path "/v1/responses")
20+
(def ^:private codex-url "https://chatgpt.com/backend-api/codex/responses")
1721

18-
(defn ^:private base-completion-request! [{:keys [rid body api-url url-relative-path api-key on-error on-response]}]
19-
(let [url (str api-url (or url-relative-path responses-path))]
22+
(defn ^:private jtw-token->account-id [api-key]
23+
(let [[_ base64] (string/split api-key #"\.")
24+
payload (some-> base64
25+
llm-util/<-base64
26+
json/parse-string)]
27+
(get-in payload ["https://api.openai.com/auth" "chatgpt_account_id"])))
28+
29+
(defn ^:private base-completion-request! [{:keys [rid body api-url auth-type url-relative-path api-key on-error on-response]}]
30+
(let [oauth? (= :auth/oauth auth-type)
31+
url (if oauth?
32+
codex-url
33+
(str api-url (or url-relative-path responses-path)))]
2034
(llm-util/log-request logger-tag rid url body)
2135
(http/post
2236
url
23-
{:headers {"Authorization" (str "Bearer " api-key)
24-
"Content-Type" "application/json"}
37+
{:headers (assoc-some
38+
{"Authorization" (str "Bearer " api-key)
39+
"Content-Type" "application/json"}
40+
"chatgpt-account-id" (jtw-token->account-id api-key)
41+
"OpenAI-Beta" (when oauth? "responses=experimental"),
42+
"Originator" (when oauth? "codex_cli_rs")
43+
"Session-ID" (when oauth? (str (random-uuid))))
2544
:body (json/generate-string body)
2645
:throw-exceptions? false
2746
:async? true
@@ -81,7 +100,7 @@
81100
messages))
82101

83102
(defn completion! [{:keys [model user-messages instructions reason? supports-image? api-key api-url url-relative-path
84-
max-output-tokens past-messages tools web-search extra-payload]}
103+
max-output-tokens past-messages tools web-search extra-payload auth-type]}
85104
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated]}]
86105
(let [input (concat (normalize-messages past-messages supports-image?)
87106
(normalize-messages user-messages supports-image?))
@@ -91,7 +110,9 @@
91110
:input input
92111
:prompt_cache_key (str (System/getProperty "user.name") "@ECA")
93112
:parallel_tool_calls true
94-
:instructions instructions
113+
:instructions (if (= :auth/oauth auth-type)
114+
(str "You are Codex." instructions)
115+
instructions)
95116
:tools tools
96117
:include (when reason?
97118
["reasoning.encrypted_content"])
@@ -190,6 +211,7 @@
190211
:api-url api-url
191212
:url-relative-path url-relative-path
192213
:api-key api-key
214+
:auth-type auth-type
193215
:on-error on-error
194216
:on-response handle-response})
195217
(doseq [tool-call tool-calls]
@@ -209,18 +231,90 @@
209231
:api-url api-url
210232
:url-relative-path url-relative-path
211233
:api-key api-key
234+
:auth-type auth-type
212235
:on-error on-error
213236
:on-response on-response-fn})))
214237

238+
(def ^:private client-id "app_EMoamEEZ73f0CkXaXp7hrann")
239+
240+
(defn ^:private oauth-url [server-url]
241+
(let [url "https://auth.openai.com/oauth/authorize"
242+
{:keys [challenge verifier]} (llm-util/generate-pkce)]
243+
{:verifier verifier
244+
:url (str url "?" (ring.util/form-encode {:client_id client-id
245+
:response_type "code"
246+
:redirect_uri server-url
247+
:scope "openid profile email offline_access"
248+
:id_token_add_organizations "true"
249+
:prompt "login"
250+
:codex_cli_simplified_flow "true"
251+
:code_challenge challenge
252+
:code_challenge_method "S256"
253+
:state verifier}))}))
254+
255+
(defn ^:private oauth-authorize [server-url code verifier]
256+
(let [{:keys [status body]} (http/post
257+
"https://auth.openai.com/oauth/token"
258+
{:headers {"Content-Type" "application/json"}
259+
:body (json/generate-string
260+
{:grant_type "authorization_code"
261+
:client_id client-id
262+
:code code
263+
:code_verifier verifier
264+
:redirect_uri server-url})
265+
:as :json})]
266+
(if (= 200 status)
267+
{:refresh-token (:refresh_token body)
268+
:access-token (:access_token body)
269+
:expires-at (+ (quot (System/currentTimeMillis) 1000) (:expires_in body))}
270+
(throw (ex-info (format "OpenAI token exchange failed: %s" (pr-str body))
271+
{:status status
272+
:body body})))))
273+
215274
(defmethod f.login/login-step ["openai" :login/start] [{:keys [db* chat-id provider send-msg!]}]
216275
(swap! db* assoc-in [:chats chat-id :login-provider] provider)
217-
(swap! db* assoc-in [:auth provider] {:step :login/waiting-api-key})
218-
(send-msg! "Paste your API Key"))
276+
(swap! db* assoc-in [:auth provider] {:step :login/waiting-login-method})
277+
(send-msg! (multi-str "Now, inform the login method:"
278+
""
279+
;; "pro: GPT Pro (subscription)"
280+
"manual: Manually enter API Key")))
281+
282+
(defmethod f.login/login-step ["openai" :login/waiting-login-method] [{:keys [db* input provider send-msg!] :as ctx}]
283+
(case input
284+
"pro"
285+
(let [local-server-port 1455 ;; openai requires this port
286+
server-url (str "http://localhost:" local-server-port "/auth/callback")
287+
{:keys [verifier url]} (oauth-url server-url)]
288+
(throw (ex-info "Unsupported login" {}))
289+
(oauth/start-oauth-server!
290+
{:port local-server-port
291+
:on-success (fn [{:keys [code]}]
292+
(let [{:keys [access-token refresh-token expires-at]} (oauth-authorize server-url code verifier)]
293+
(swap! db* update-in [:auth provider] merge {:step :login/done
294+
:type :auth/oauth
295+
:refresh-token refresh-token
296+
:api-key access-token
297+
:expires-at expires-at})
298+
(send-msg! "")
299+
(f.login/login-done! ctx))
300+
(future
301+
(Thread/sleep 2000) ;; wait to render success page
302+
(oauth/stop-oauth-server!)))
303+
:on-error (fn [error]
304+
(send-msg! (str "Error authenticating via oauth: " error))
305+
(oauth/stop-oauth-server!))})
306+
(send-msg! (format "Open your browser at `%s` and authenticate at OpenAI.\n\nThen ECA will finish the login automatically." url)))
307+
"manual"
308+
(do
309+
(swap! db* assoc-in [:auth provider] {:step :login/waiting-api-key
310+
:mode :manual})
311+
(send-msg! "Paste your API Key"))
312+
(send-msg! (format "Unknown login method '%s'. Inform one of the options: pro, manual" input))))
219313

220314
(defmethod f.login/login-step ["openai" :login/waiting-api-key] [{:keys [input db* provider send-msg!] :as ctx}]
221315
(if (string/starts-with? input "sk-")
222316
(do (config/update-global-config! {:providers {"openai" {:key input}}})
223-
(swap! db* dissoc :auth provider)
317+
(swap! db* update :auth dissoc provider)
224318
(send-msg! (str "API key saved in " (.getCanonicalPath (config/global-config-file))))
225319

226320
(f.login/login-done! ctx :update-cache? false))

src/eca/llm_providers/openrouter.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424
{}
2525
(string/split input #","))
2626
:key api-key}}}))
27-
(swap! db* dissoc :auth provider)
27+
(swap! db* update :auth dissoc provider)
2828
(send-msg! (format "API key and models saved to %s" (.getCanonicalPath (config/global-config-file))))
2929
(f.login/login-done! ctx))

src/eca/llm_providers/z_ai.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424
{}
2525
(string/split input #","))
2626
:key api-key}}}))
27-
(swap! db* dissoc :auth provider)
27+
(swap! db* update :auth dissoc provider)
2828
(send-msg! (format "API key and models saved to %s" (.getCanonicalPath (config/global-config-file))))
2929
(f.login/login-done! ctx))

src/eca/llm_util.clj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
(.nextBytes (SecureRandom.) seed)
7373
seed))
7474

75+
(defn <-base64 ^String [^String s]
76+
(String. (.decode (Base64/getDecoder) s)))
77+
7578
(defn ^:private ->base64 [^bytes bs]
7679
(.encodeToString (Base64/getEncoder) bs))
7780

src/eca/oauth.clj

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
(ns eca.oauth
2+
(:require
3+
[eca.logger :as logger]
4+
[ring.adapter.jetty :as jetty]
5+
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
6+
[ring.middleware.params :refer [wrap-params]]
7+
[ring.util.response :as response])
8+
(:import
9+
[org.eclipse.jetty.server Server]))
10+
11+
(set! *warn-on-reflection* true)
12+
13+
(def ^:private logger-tag "[OAUTH]")
14+
15+
(defonce ^:private oauth-server* (atom nil))
16+
17+
(defn ^:private oauth-handler [request on-success on-error]
18+
(let [{:keys [code error state]} (:params request)]
19+
(if code
20+
(do
21+
(on-success {:code code
22+
:state state})
23+
(-> (response/response (str "<html>"
24+
"<head>"
25+
"<meta charset=\"UTF-8\">"
26+
"<title>My Web Page</title>"
27+
"</head>"
28+
"<body>"
29+
"<h2>✅ Authentication Successful!</h2>"
30+
"<p>You can close this window and return to ECA.</p>"
31+
"<script>window.close();</script>"
32+
"</body></html>"))
33+
(response/content-type "text/html")))
34+
(do
35+
(on-error error)
36+
(-> (response/response (str "<html>"
37+
"<head>"
38+
"<meta charset=\"UTF-8\">"
39+
"<title>My Web Page</title>"
40+
"</head>"
41+
"<body>"
42+
"<h2>❌ Authentication Failed</h2>"
43+
"<p>Error: " (or error "Unknown error") "</p>"
44+
"<p>You can close this window and return to ECA.</p>"
45+
"</body></html>"))
46+
(response/content-type "text/html"))))))
47+
48+
(defn start-oauth-server!
49+
"Start local server on port to handle OAuth redirect"
50+
[{:keys [on-error on-success port]}]
51+
(when-not @oauth-server*
52+
(let [handler (-> oauth-handler
53+
wrap-keyword-params
54+
wrap-params)
55+
server (jetty/run-jetty
56+
(fn [request]
57+
(if (= "/auth/callback" (:uri request))
58+
(handler request on-success on-error)
59+
(-> (response/response "404 Not Found")
60+
(response/status 404))))
61+
{:port port
62+
:join? false})]
63+
(reset! oauth-server* server)
64+
(logger/info logger-tag (str "OAuth server started on http://localhost:" port))
65+
server)))
66+
67+
(defn stop-oauth-server!
68+
"Stop the local OAuth server"
69+
[]
70+
(when-let [^Server server @oauth-server*]
71+
(.stop server)
72+
(reset! oauth-server* nil)
73+
(logger/info logger-tag "OAuth server stopped")))

0 commit comments

Comments
 (0)