|
7 | 7 | [eca.features.login :as f.login] |
8 | 8 | [eca.llm-util :as llm-util] |
9 | 9 | [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])) |
11 | 14 |
|
12 | 15 | (set! *warn-on-reflection* true) |
13 | 16 |
|
14 | 17 | (def ^:private logger-tag "[OPENAI]") |
15 | 18 |
|
16 | 19 | (def ^:private responses-path "/v1/responses") |
| 20 | +(def ^:private codex-url "https://chatgpt.com/backend-api/codex/responses") |
17 | 21 |
|
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)))] |
20 | 34 | (llm-util/log-request logger-tag rid url body) |
21 | 35 | (http/post |
22 | 36 | 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)))) |
25 | 44 | :body (json/generate-string body) |
26 | 45 | :throw-exceptions? false |
27 | 46 | :async? true |
|
81 | 100 | messages)) |
82 | 101 |
|
83 | 102 | (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]} |
85 | 104 | {:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated]}] |
86 | 105 | (let [input (concat (normalize-messages past-messages supports-image?) |
87 | 106 | (normalize-messages user-messages supports-image?)) |
|
91 | 110 | :input input |
92 | 111 | :prompt_cache_key (str (System/getProperty "user.name") "@ECA") |
93 | 112 | :parallel_tool_calls true |
94 | | - :instructions instructions |
| 113 | + :instructions (if (= :auth/oauth auth-type) |
| 114 | + (str "You are Codex." instructions) |
| 115 | + instructions) |
95 | 116 | :tools tools |
96 | 117 | :include (when reason? |
97 | 118 | ["reasoning.encrypted_content"]) |
|
190 | 211 | :api-url api-url |
191 | 212 | :url-relative-path url-relative-path |
192 | 213 | :api-key api-key |
| 214 | + :auth-type auth-type |
193 | 215 | :on-error on-error |
194 | 216 | :on-response handle-response}) |
195 | 217 | (doseq [tool-call tool-calls] |
|
209 | 231 | :api-url api-url |
210 | 232 | :url-relative-path url-relative-path |
211 | 233 | :api-key api-key |
| 234 | + :auth-type auth-type |
212 | 235 | :on-error on-error |
213 | 236 | :on-response on-response-fn}))) |
214 | 237 |
|
| 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 | + |
215 | 274 | (defmethod f.login/login-step ["openai" :login/start] [{:keys [db* chat-id provider send-msg!]}] |
216 | 275 | (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)))) |
219 | 313 |
|
220 | 314 | (defmethod f.login/login-step ["openai" :login/waiting-api-key] [{:keys [input db* provider send-msg!] :as ctx}] |
221 | 315 | (if (string/starts-with? input "sk-") |
222 | 316 | (do (config/update-global-config! {:providers {"openai" {:key input}}}) |
223 | | - (swap! db* dissoc :auth provider) |
| 317 | + (swap! db* update :auth dissoc provider) |
224 | 318 | (send-msg! (str "API key saved in " (.getCanonicalPath (config/global-config-file)))) |
225 | 319 |
|
226 | 320 | (f.login/login-done! ctx :update-cache? false)) |
|
0 commit comments