From c9ef0ee26f860ae1fc9962947f9b4ea1693fc4b8 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Sat, 13 Jun 2026 15:01:38 +0300 Subject: [PATCH] Fix OpenAI OAuth context window detection OpenAI OAuth requests use the ChatGPT Codex backend, whose model context windows differ from the direct OpenAI API catalog. Resolve OAuth model limits from Codex /models first and fall back to known Codex OAuth caps so gpt-5.5 reports 272k instead of the direct API's 1.05M window. Assisted by: eca-agent --- CHANGELOG.md | 1 + src/eca/models.clj | 103 +++++++++++++++++++++++++++++++++++---- test/eca/models_test.clj | 72 +++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1111adf88..4cd2b18c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Add message pagination over a shared cursor core: new JSON-RPC `chat/history` method and optional `limit`/`before`/`after` window params on `chat/open`, plus opt-in pagination on remote HTTP `GET /api/v1/chats/:id`. Opaque cursors with a `lastCompaction` sentinel; meta exposes before/after/compaction cursors. +- OpenAI OAuth models now use ChatGPT Codex context windows, fixing inflated GPT-5.5 limits from the direct OpenAI catalog. ## 0.140.1 diff --git a/src/eca/models.clj b/src/eca/models.clj index 480f255d8..8333597a5 100644 --- a/src/eca/models.clj +++ b/src/eca/models.clj @@ -16,6 +16,19 @@ (def ^:private models-dev-api-url "https://models.dev/api.json") (def ^:private models-dev-timeout-ms 5000) (def ^:private provider-models-timeout-ms 10000) +(def ^:private codex-oauth-models-url "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0") + +(def ^:private codex-oauth-context-fallback + {"gpt-5.1-codex-max" 272000 + "gpt-5.1-codex-mini" 272000 + "gpt-5.3-codex-spark" 128000 + "gpt-5.3-codex" 272000 + "gpt-5.2-codex" 272000 + "gpt-5.4-mini" 272000 + "gpt-5.5" 272000 + "gpt-5.4" 272000 + "gpt-5.2" 272000 + "gpt-5" 272000}) (def ^:private max-error-body-log-length 500) @@ -92,6 +105,43 @@ [n] (when (and (number? n) (pos? n)) n)) +(defn ^:private codex-oauth-context-fallback-for + [model] + (let [model-name (string/lower-case (str model))] + (some (fn [[slug context-limit]] + (when (string/includes? model-name slug) + context-limit)) + (sort-by (comp - count key) codex-oauth-context-fallback)))) + +(defn ^:private codex-oauth-model-config + [context-limit output-limit] + (assoc-some {::codex-oauth? true} + ::context-limit context-limit + ::output-limit output-limit)) + +(defn ^:private codex-oauth-model-entry + [model] + (let [slug (:slug model) + context-limit (pos-num (:context_window model)) + output-limit (pos-num (or (:max_output_tokens model) + (:max_completion_tokens model)))] + (when (and (string? slug) (not (string/blank? slug))) + [slug (codex-oauth-model-config context-limit output-limit)]))) + +(defn ^:private codex-oauth-fallback-models + [static-models] + (merge + (into {} + (map (fn [[model context-limit]] + [model (codex-oauth-model-config context-limit nil)])) + codex-oauth-context-fallback) + (into {} + (keep (fn [[model model-config]] + (when-let [context-limit (or (codex-oauth-context-fallback-for model) + (codex-oauth-context-fallback-for (:modelName model-config)))] + [model (codex-oauth-model-config context-limit nil)]))) + static-models))) + (def ^:private models-with-image-generation-support "Mainline OpenAI chat models that support the built-in `image_generation` tool on the Responses API. Sourced from OpenAI's image-generation tool @@ -276,6 +326,35 @@ (format "Provider '%s': Ignoring models.dev model entry '%s' with invalid key/model fields" provider model-key))) +(defn ^:private fetch-codex-oauth-models + [api-key static-models] + (let [fallback-models (codex-oauth-fallback-models static-models)] + (try + (if-not api-key + fallback-models + (let [{:keys [status body]} (http/get codex-oauth-models-url + {:headers {"Authorization" (str "Bearer " api-key)} + :throw-exceptions? false + :as :json + :http-client (client/merge-with-global-http-client {}) + :timeout provider-models-timeout-ms})] + (if (not= 200 status) + (do + (logger/warn logger-tag + (format "Provider 'openai': Codex /models endpoint returned status %s" + status)) + fallback-models) + (let [models-data (:models body) + live-models (not-empty (into {} + (keep codex-oauth-model-entry) + models-data))] + (or (not-empty (merge-with merge fallback-models live-models)) + fallback-models))))) + (catch Exception e + (logger/warn logger-tag + (format "Provider 'openai': Failed to fetch Codex /models endpoint: %s" e)) + fallback-models)))) + (defn ^:private fetch-provider-native-models "Fetches models from provider's native /models endpoint. Returns a map of model-id -> {} on success, nil on failure." @@ -325,12 +404,15 @@ config) api-type (:api provider-config)] (when api-url - (when-let [models (fetch-provider-native-models - {:provider provider - :api-url api-url - :auth-type auth-type - :api-key api-key - :api-type api-type})] + (when-let [models (if (and (= "openai" provider) + (= :auth/oauth auth-type)) + (fetch-codex-oauth-models api-key (:models provider-config)) + (fetch-provider-native-models + {:provider provider + :api-url api-url + :auth-type auth-type + :api-key api-key + :api-type api-type}))] (logger/debug logger-tag (format "Provider '%s': Discovered %d models from native /models endpoint" provider (count models))) @@ -397,12 +479,15 @@ [model-config] (let [limit (:limit model-config) cost (:cost model-config) + output-limit (or (pos-num (:output limit)) + (pos-num (::output-limit model-config))) limit-overrides (assoc-some {} - :context (pos-num (:context limit)) - :output (pos-num (:output limit)))] + :context (or (pos-num (:context limit)) + (pos-num (::context-limit model-config))) + :output output-limit)] (assoc-some {} :limit (not-empty limit-overrides) - :max-output-tokens (pos-num (:output limit)) + :max-output-tokens output-limit :input-token-cost (cost-per-1m->per-token (:input cost)) :output-token-cost (cost-per-1m->per-token (:output cost)) :input-cache-creation-token-cost (cost-per-1m->per-token (:cacheWrite cost)) diff --git a/test/eca/models_test.clj b/test/eca/models_test.clj index 558832e20..3c2174316 100644 --- a/test/eca/models_test.clj +++ b/test/eca/models_test.clj @@ -8,6 +8,12 @@ (set! *warn-on-reflection* true) +(defn ^:private build-supported-models + [config db models-dev-data] + (let [known-models (#'models/all models-dev-data) + {:keys [models]} (#'models/fetch-provider-model-catalogs config db models-dev-data)] + (#'models/build-all-supported-models known-models config models))) + (deftest fetch-models-dev-data-test (testing "Uses hato with json-string-keys and global client options" (let [request* (atom nil)] @@ -373,6 +379,72 @@ (is (= "https://api.openai.com/v1/models" (first @request*))) (is (= "Bearer sk-test" (get-in @request* [1 :headers "Authorization"])))))) +(deftest openai-oauth-codex-context-window-test + (let [request* (atom nil) + models-dev-data {"openai" {"api" "https://api.openai.com" + "models" {"gpt-5.5" {"limit" {"context" 1050000 + "output" 128000}}}}} + config {:providers {"openai" {:api "openai-responses" + :url "https://api.openai.com" + :models {"gpt-5.5" {}}}}} + db {:auth {"openai" {:api-key "oauth-token" + :type :auth/oauth}}}] + (with-redefs [http/get (fn [url opts] + (reset! request* [url opts]) + {:status 200 + :body {:models [{:slug "gpt-5.5" + :context_window 272000}]}})] + (let [supported (build-supported-models config db models-dev-data)] + (is (= "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0" + (first @request*))) + (is (= "Bearer oauth-token" (get-in @request* [1 :headers "Authorization"]))) + (is (= 272000 (get-in supported ["openai/gpt-5.5" :limit :context]))) + (is (= 128000 (get-in supported ["openai/gpt-5.5" :limit :output]))))))) + +(deftest openai-oauth-codex-fallback-context-window-test + (let [models-dev-data {"openai" {"api" "https://api.openai.com" + "models" {"gpt-5.5" {"limit" {"context" 1050000}}}}} + config {:providers {"openai" {:api "openai-responses" + :url "https://api.openai.com" + :models {"gpt-5.5" {}}}}} + db {:auth {"openai" {:api-key "expired-token" + :type :auth/oauth}}}] + (with-redefs [http/get (fn [_url _opts] + {:status 401 + :body {:error "unauthorized"}})] + (let [supported (build-supported-models config db models-dev-data)] + (is (= 272000 (get-in supported ["openai/gpt-5.5" :limit :context]))))))) + +(deftest openai-oauth-codex-live-model-preserves-fallback-limit-test + (let [models-dev-data {"openai" {"api" "https://api.openai.com" + "models" {"gpt-5.5" {"limit" {"context" 1050000}}}}} + config {:providers {"openai" {:api "openai-responses" + :url "https://api.openai.com" + :models {"gpt-5.5" {}}}}} + db {:auth {"openai" {:api-key "oauth-token" + :type :auth/oauth}}}] + (with-redefs [http/get (fn [_url _opts] + {:status 200 + :body {:models [{:slug "gpt-5.5"}]}})] + (let [supported (build-supported-models config db models-dev-data)] + (is (= 272000 (get-in supported ["openai/gpt-5.5" :limit :context]))))))) + +(deftest openai-token-keeps-direct-api-context-window-test + (let [request* (atom nil) + models-dev-data {"openai" {"api" "https://api.openai.com" + "models" {"gpt-5.5" {"limit" {"context" 1050000}}}}} + config {:providers {"openai" {:api "openai-responses" + :url "https://api.openai.com" + :key "sk-test" + :models {"gpt-5.5" {}}}}}] + (with-redefs [http/get (fn [url opts] + (reset! request* [url opts]) + {:status 200 + :body {:data [{:id "gpt-5.5"}]}})] + (let [supported (build-supported-models config {} models-dev-data)] + (is (= "https://api.openai.com/v1/models" (first @request*))) + (is (= 1050000 (get-in supported ["openai/gpt-5.5" :limit :context]))))))) + (deftest fetch-provider-models-anthropic-token-uses-x-api-key-test (let [request* (atom nil) models-dev-data {"anthropic" {"api" "https://api.anthropic.com"