From 3884dbf2e5db0f93137a87d34d7776045e0cbea4 Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Sat, 22 Aug 2026 17:45:55 +0300 Subject: [PATCH] Restore parallel tool calls for Codex OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route explicitly parallel-capable Lite models through the ordinary Responses function-tool shape, since the Lite endpoint requires serial tool calls. Preserve the Lite path for fallback and partial model metadata. 🤖 Generated with [ECA](https://eca.dev) (openai/gpt-5.6-sol - xhigh) Co-Authored-By: eca-agent --- CHANGELOG.md | 2 + src/eca/llm_providers/openai.clj | 39 ++++++-- test/eca/llm_providers/openai_test.clj | 133 +++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc5231d5..4966e4029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Restore parallel function-call batching for ChatGPT OAuth models that advertise Responses Lite and parallel tool-call support. #572 + ## 0.155.1 - Fix Anthropic streaming dropping tool calls when providers report `end_turn`/`stop` stop_reason with pending `tool_use` blocks. #576 diff --git a/src/eca/llm_providers/openai.clj b/src/eca/llm_providers/openai.clj index 14d32fa4a..03355eda6 100644 --- a/src/eca/llm_providers/openai.clj +++ b/src/eca/llm_providers/openai.clj @@ -130,27 +130,34 @@ :discovered-variants (codex-reasoning-variants supported-reasoning-efforts)})) (defn ^:private codex-live-model-discovery - "Maps a Codex /models entry into generic discovery keys. Codex-only model - behavior goes inside :discovered-provider-data, interpreted only here." + "Maps a Codex /models entry into provider-specific discovery metadata." [{:keys [use_responses_lite default_reasoning_level supported_reasoning_levels supports_parallel_tool_calls] :as model}] (let [efforts (codex-normalize-reasoning-efforts supported_reasoning_levels) provider-data (assoc-some (cond-> {} (contains? model :use_responses_lite) - (assoc :responses-lite? (true? use_responses_lite))) + (assoc :responses-lite? (true? use_responses_lite)) + + ;; Lite requires parallel_tool_calls=false. Preserve the + ;; explicit live pair before merging fallback metadata. + (and (true? use_responses_lite) + (true? supports_parallel_tool_calls)) + (assoc :parallel-tool-calls-without-lite? true)) :default-reasoning-effort default_reasoning_level :parallel-tool-calls? supports_parallel_tool_calls)] (assoc-some {} :discovered-provider-data (not-empty provider-data) :discovered-variants (codex-reasoning-variants efforts)))) +(defn ^:private function-tool? [tool] + (= "function" (:type tool))) + (defn ^:private codex-responses-lite-body "Projects a regular Responses request into the Codex Responses Lite shape." [body] (let [instructions (:instructions body) - tools (->> (:tools body) - (filterv #(= "function" (:type %)))) + tools (filterv function-tool? (:tools body)) input (cond-> [{:type "additional_tools" :role "developer" :tools tools}] @@ -169,6 +176,17 @@ :reasoning (assoc (or (:reasoning body) {}) :context "all_turns"))))) +(defn ^:private codex-body-projection + "Selects the effective Codex wire shape once for an entire turn." + [codex? provider-data body] + (cond + (not codex?) :ordinary + (and (true? (:parallel-tool-calls-without-lite? provider-data)) + (true? (:parallel_tool_calls body)) + (some function-tool? (:tools body))) :codex-parallel + (true? (:responses-lite? provider-data)) :codex-lite + :else :ordinary)) + (defn ^:private pos-num [n] (when (and (number? n) (pos? n)) n)) @@ -608,7 +626,6 @@ provider-data (when codex? (merge (:discovered-provider-data (codex-model-fallback-discovery model)) provider-data)) - responses-lite? (boolean (:responses-lite? provider-data)) default-reasoning-effort (:default-reasoning-effort provider-data) turn-context (when codex? (new-codex-turn-context)) input (concat (normalize-messages past-messages supports-image?) @@ -637,9 +654,15 @@ ;; tool calls; sending true to it fails the request. (and codex? (false? (:parallel-tool-calls? provider-data))) (assoc :parallel_tool_calls false)) + body-projection (codex-body-projection codex? provider-data base-body) + responses-lite? (= :codex-lite body-projection) prepare-body (fn [body] - (if responses-lite? - (codex-responses-lite-body body) + (case body-projection + :codex-lite (codex-responses-lite-body body) + :codex-parallel (-> body + (update :tools #(filterv function-tool? %)) + (update :reasoning #(assoc (or % {}) + :context "all_turns"))) body)) body (prepare-body base-body) tool-call-by-item-id* (atom {}) diff --git a/test/eca/llm_providers/openai_test.clj b/test/eca/llm_providers/openai_test.clj index 880317fc1..b276b7790 100644 --- a/test/eca/llm_providers/openai_test.clj +++ b/test/eca/llm_providers/openai_test.clj @@ -539,6 +539,75 @@ (first @tools-called*))) (is (= 2 (count @requests*))))))) +(deftest create-response-parallel-tool-calls-test + (let [tool-batches* (atom []) + requests* (atom []) + read-tool {:full-name "eca__read_file" + :description "read" + :parameters {:type "object"}}] + (with-redefs [llm-providers.openai/base-responses-request! + (fn [{:keys [on-stream] :as opts}] + (let [request-number (count (swap! requests* conj opts))] + (on-stream "response.completed" + {:response {:output (if (= 1 request-number) + [{:type "function_call" + :id "item-1" + :call_id "call-1" + :name "eca__read_file" + :arguments "{\"path\":\"/a\"}"} + {:type "function_call" + :id "item-2" + :call_id "call-2" + :name "eca__read_file" + :arguments "{\"path\":\"/b\"}"}] + []) + :usage {:input_tokens 10 :output_tokens 5} + :status "completed"}})))] + (llm-providers.openai/create-response! + (assoc (base-provider-params) + :provider "openai" + :auth-type :auth/oauth + :reason? true + :web-search true + :image-generation true + :extra-payload {:parallel_tool_calls true} + :provider-data {:responses-lite? true + :parallel-tool-calls? true + :parallel-tool-calls-without-lite? true} + :tools [read-tool]) + (base-callbacks + {:on-tools-called + (fn [tool-calls] + (swap! tool-batches* conj tool-calls) + {:new-messages + (mapcat (fn [{:keys [id full-name arguments]}] + [{:role "tool_call" + :content {:id id + :full-name full-name + :arguments arguments}} + {:role "tool_call_output" + :content {:id id + :full-name full-name + :output {:error false + :contents [{:type :text :text "contents"}]}}}]) + tool-calls) + :tools [read-tool]})})) + (is (= [["call-1" "item-1"] ["call-2" "item-2"]] + (mapv (juxt :id :item-id) (first @tool-batches*)))) + (is (= 2 (count @requests*))) + (doseq [request @requests*] + (is (false? (:responses-lite? request))) + (is (= "test" (get-in request [:body :instructions]))) + (is (= ["function"] (mapv :type (get-in request [:body :tools])))) + (is (true? (get-in request [:body :parallel_tool_calls]))) + (is (= "all_turns" (get-in request [:body :reasoning :context])))) + (is (= [["function_call" "call-1"] + ["function_call_output" "call-1"] + ["function_call" "call-2"] + ["function_call_output" "call-2"]] + (mapv (juxt :type :call_id) + (get-in (second @requests*) [:body :input]))))))) + (deftest create-response-sync-error-test (testing "sync completion path returns a structured error instead of throwing on non-200 (#495)" (with-client-proxied {:version :http-2} @@ -848,6 +917,69 @@ {:type "image_generation" :output_format "png"}] (get-in (first @requests*) [:body :tools]))))))) +(deftest create-response-codex-partial-live-metadata-stays-lite-test + (let [request* (atom nil)] + (with-redefs [http/get + (fn [_url _opts] + {:status 200 + :body {:models [{:slug "gpt-5.6-sol" + :supports_parallel_tool_calls true}]}}) + llm-providers.openai/base-responses-request! + (fn [{:keys [on-stream] :as opts}] + (reset! request* opts) + (on-stream "response.completed" + {:response {:output [] + :usage {:input_tokens 0 :output_tokens 0} + :status "completed"}}))] + (let [models (#'llm-providers.openai/fetch-oauth-models "oauth-token" {}) + provider-data (get-in models + ["gpt-5.6-sol" :discovered-provider-data])] + (is (true? (:responses-lite? provider-data))) + (is (true? (:parallel-tool-calls? provider-data))) + (is (nil? (:parallel-tool-calls-without-lite? provider-data))) + (llm-providers.openai/create-response! + (assoc (base-provider-params) + :model "gpt-5.6-sol" + :provider "openai" + :auth-type :auth/oauth + :extra-payload {:parallel_tool_calls true} + :provider-data provider-data) + (base-callbacks {})) + (is (true? (:responses-lite? @request*))) + (is (false? (get-in @request* [:body :parallel_tool_calls]))) + (is (= "additional_tools" (get-in @request* [:body :input 0 :type]))))))) + +(deftest create-response-codex-parallel-shape-compatibility-test + (let [request* (atom nil) + cases [{:label "requested false stays Lite" + :params {:extra-payload {:parallel_tool_calls false} + :provider-data {:responses-lite? true + :parallel-tool-calls? true + :parallel-tool-calls-without-lite? true}}} + {:label "no function tools stays Lite" + :params {:tools [] + :extra-payload {:parallel_tool_calls true} + :provider-data {:responses-lite? true + :parallel-tool-calls? true + :parallel-tool-calls-without-lite? true}}}]] + (with-redefs [llm-providers.openai/base-responses-request! + (fn [{:keys [on-stream] :as opts}] + (reset! request* opts) + (on-stream "response.completed" + {:response {:output [] + :usage {:input_tokens 0 :output_tokens 0} + :status "completed"}}))] + (doseq [{:keys [label params]} cases] + (testing label + (llm-providers.openai/create-response! + (merge (base-provider-params) + {:provider "openai" :auth-type :auth/oauth} + params) + (base-callbacks {})) + (is (true? (:responses-lite? @request*))) + (is (false? (get-in @request* [:body :parallel_tool_calls]))) + (is (= "additional_tools" (get-in @request* [:body :input 0 :type])))))))) + (deftest create-response-codex-request-shapes-test (testing "API-key requests ignore Codex-only Lite metadata, even for Lite models" (let [request* (atom nil)] @@ -1322,6 +1454,7 @@ (deftest codex-live-model-discovery-test (is (= {:discovered-provider-data {:responses-lite? true + :parallel-tool-calls-without-lite? true :default-reasoning-effort "low" :parallel-tool-calls? true} :discovered-variants