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"