Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 31 additions & 8 deletions src/eca/llm_providers/openai.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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}]
Expand All @@ -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))

Expand Down Expand Up @@ -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?)
Expand Down Expand Up @@ -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 {})
Expand Down
133 changes: 133 additions & 0 deletions test/eca/llm_providers/openai_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down
Loading