Compare commits

...

5 Commits

Author SHA1 Message Date
Oleksandr Solovyov
58c8227968 linter fixes 2026-01-01 16:17:32 -08:00
Oleksandr Solovyov
a88af46db1 support for claude to show how different adapters are 2026-01-01 16:17:32 -08:00
Oleksandr Solovyov
2d22131160 switch to new openai /v1/responses api, check for structured output support 2026-01-01 16:17:32 -08:00
Oleksandr Solovyov
eff3a66185 Libor comments 2026-01-01 16:17:32 -08:00
Oleksandr Solovyov
065100e728 Experiment with communicating with LLMs in Clojure 2026-01-01 16:17:25 -08:00
12 changed files with 2371 additions and 2 deletions

View File

@@ -2082,6 +2082,7 @@
warehouse-schema
warehouses-rest
enterprise/dependencies
enterprise/llm
enterprise/transforms
enterprise/transforms-python}}

View File

@@ -7,7 +7,7 @@
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
:encryption :no
:visibility :settings-manager
:default "gpt-4-turbo-preview"
:default "gpt-4.1-mini"
:export? false
:doc false)
@@ -26,6 +26,13 @@
:export? false
:doc false)
(defsetting ee-anthropic-api-key
(deferred-tru "The Anthropic API Key used in Metabase Enterprise.")
:encryption :no
:visibility :settings-manager
:export? false
:doc false)
(defsetting ee-ai-features-enabled
(deferred-tru "Enable AI features.")
:type :boolean

View File

@@ -0,0 +1,111 @@
(ns metabase-enterprise.metabot-v3.self
"TODO:
- done: figure out request cancellation
- done: async tool calls appended to the same xf results
- done: usage tracking - they'll go through and we can do whatever we want later on
- done: test structured output
- done: add anthropic api support to see how hard is to be heterogeneous
- figure out what's lacking compared to ai-service"
(:require
[clojure.core.async :as a]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self.claude :as claude]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase-enterprise.metabot-v3.self.openai :as openai]
[metabase.util :as u]
[metabase.util.malli :as mu]
[potemkin :as p]))
(set! *warn-on-reflection* true)
(p/import-vars [claude
claude-raw
claude->aisdk-xf])
(p/import-vars [core
chan?
sse-chan
aisdk-xf
tool-executor-rff])
(p/import-vars [openai
openai-raw
openai->aisdk-xf
openai])
(comment
(llm/ee-openai-api-key)
(llm/ee-ai-features-enabled)
(def sys
"You MUST call tools for time or currency questions. If asked 'what time' or 'convert X to Y', do not guess—always call the relevant tool first.")
(def usr
"What time is it right now in Europe/Kyiv, and convert 100 EUR to UAH.")
(def q (a/<!! (a/into [] (openai/openai-raw {:messages [{:role "system" :content sys}
{:role "user" :content usr}]
:tools (vals TOOLS)})))))
;;; tools
(mu/defn get-time
"Return current time for a given IANA timezone."
[{:keys [tz]} :- [:map {:closed true}
[:tz [:string {:description "IANA timezone, e.g. Europe/Bucharest"}]]]]
(str (java.time.ZonedDateTime/now (java.time.ZoneId/of tz))))
(mu/defn convert-currency
"Convert an amount between two ISO currencies using a dummy rate."
[{:keys [amount from to]} :- [:map {:closed true}
[:amount :float]
[:from :string]
[:to :string]]]
(Thread/sleep 500) ;; we're doing some request to some far away service
(let [rate (if (= [from to] ["EUR" "USD"]) 1.16 1.0)]
{:amount amount
:from from
:to to
:rate rate
:converted (* amount rate)}))
(mu/defn analyze-data-trend
"Analyze a data trend by calling back to the LLM for natural language insights.
This demonstrates a recursive LLM call pattern commonly used in agentic workflows."
[{:keys [metric values period]} :- [:map {:closed true}
[:metric [:string {:description "The metric being analyzed, e.g. 'revenue', 'users'"}]]
[:values [:vector {:description "Time series values"} number?]]
[:period [:string {:description "Time period, e.g. 'Q1 2025', 'last 6 months'"}]]]]
;; Simulate calling back to LLM with a mini-prompt
(let [prompt (format "Analyze this %s trend over %s: %s. Provide a 1-2 sentence insight highlighting key patterns."
metric period (pr-str values))]
(openai/openai {:messages [{:role "user" :content prompt}]})))
(def TOOLS
"All the defined tools"
(u/index-by
#(-> % meta :name name)
[#'get-time
#'convert-currency
#'analyze-data-trend]))
(comment
(map tool->openai (vals TOOLS)))
(comment
(def q (a/<!! (a/into [] (analyze-data-trend {:metric "revenue"
:values [100.0 120.0 145.0 160.0]
:period "Q1 2025"}))))
(def w (into [] (tool-executor-rff TOOLS) q))
(def e (into [] aisdk-xf w))
(def q (a/<!! (a/into [] (openai-raw
{:system "You are a data analysis assistant. When users provide time-series data and ask for insights, use the analyze-data-trend tool to generate interpretations. Always call the tool rather than making up your own analysis."
:input [{:role "user" :content "Can you analyze these trends? Revenue for Q1: [50000, 55000, 58000, 62000] and customer count: [100, 110, 105, 115]. What story do these numbers tell?"}]
:tools (vals metabase-enterprise.metabot-v3.self/TOOLS)}))))
(def q (a/<!! (a/into [] (claude-raw
{:input [{:role "user" :content "Can you tell me currencies of three northmost American countries?"}]
:schema [:map
[:currencies [:sequential [:map
[:country [:string {:description "Three-letter code"}]]
[:currency [:string {:description "Three-letter code"}]]]]]]})))))

View File

@@ -0,0 +1,155 @@
(ns metabase-enterprise.metabot-v3.self.claude
(:require
[clj-http.client :as http]
[clojure.core.async :as a]
[clojure.string :as str]
[malli.json-schema :as mjs]
[malli.util :as mut]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn claude->aisdk-xf
"Translates Claude /v1/messages streaming events into AI SDK v5 protocol format.
https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
Claude Streaming Format:
- Each event: {:type \"message_start\" :message {...}}
{:type \"content_block_start\" :index 0 :content_block {:type \"text\"}}
{:type \"content_block_delta\" :index 0 :delta {:type \"text_delta\" :text \"...\"}}
{:type \"content_block_stop\" :index 0}
{:type \"message_delta\" :delta {:stop_reason \"end_turn\"}}
{:type \"message_stop\"}
AI SDK v5 Format (SSE protocol):
- Message parts: {:type :start, :messageId ...}
- Part types: start, text-start, text-delta, text-end, finish-step, finish
- Ends with: 'data: [DONE]\\n'"
[rf]
(let [current-type (volatile! nil)
current-id (volatile! nil)
message-id (volatile! nil)
payload (volatile! {})]
(fn
([result]
(cond-> result
;; close up latest type if incomplete
@current-type (rf (merge {:type (case @current-type
:text :text-end
:tool_use :tool-input-available)}
@payload))
true (rf)))
([result {t :type :keys [message content_block delta usage]}]
(let [block-type (when content_block
(keyword (:type content_block)))
chunk-id (or (:id content_block)
#_(when (= @current-id index)
(:id @payload))
@current-id
(core/mkid))]
(cond-> result
;; start of message
(= t "message_start") (-> (rf {:type :start :messageId (:id message)})
(u/prog1
(vreset! message-id (:id message))))
;; start of new content block
(= t "content_block_start") (-> (u/prog1
(vreset! current-type block-type)
(vreset! current-id chunk-id)
(vreset! payload
(case block-type
:text {:id chunk-id}
:tool_use {:toolCallId chunk-id
:toolName (:name content_block)}
nil)))
(rf (merge (case block-type
:text {:type :text-start}
:tool_use {:type :tool-input-start})
@payload)))
;; content block delta
(= t "content_block_delta") (rf (case (:type delta)
"text_delta" {:type :text-delta
:id (:id @payload)
:delta (:text delta)}
"input_json_delta" {:type :tool-input-delta
:toolCallId (:toolCallId @payload)
:inputTextDelta (:partial_json delta)}))
;; end of content block
(= t "content_block_stop") (-> (rf (merge {:type (case @current-type
:text :text-end
:tool_use :tool-input-available)}
@payload))
(u/prog1
(vreset! current-type nil)
(vreset! current-id nil)
(vreset! payload {})))
;; message delta (usage info)
(= t "message_delta") (cond->
usage (rf {:type :usage
:usage {:promptTokens (:input_tokens usage)
:completionTokens (:output_tokens usage)}
:id @message-id}))
;; end of message
(= t "message_stop") identity))))))
(defn- tool->claude [tool]
(let [{:keys [doc schema] :as tool} (if (map? tool) tool (meta tool))
[_:=> [_:cat params] _out] schema
doc (if (str/starts-with? doc "Inputs: ")
;; strip that stuff we're appending in mu/defn
(second (str/split doc #"\n\n " 2))
doc)]
{:name (name (:name tool))
:description doc
:input_schema (mjs/transform params {:additionalProperties false})}))
(mu/defn claude-raw
"Perform a request to Claude API"
[{:keys [model system input tools schema]
:or {model "claude-sonnet-4-5-20250929"
input [{:role "user" :content "Hello"}]}}
:- [:map
[:model {:optional true} :string]
[:system {:optional true} :string]
[:input {:optional true} [:vector [:map
[:role [:enum "user" "assistant"]]
[:content :string]]]]
[:tools {:optional true} [:sequential [:fn var?]]]
[:schema {:optional true} :any]]]
(assert (llm/ee-anthropic-api-key) "No Anthropic API key!")
(let [req (cond-> {:model model
:max_tokens 4096
:stream true
:messages input}
system (assoc :system system)
(seq tools) (assoc :tools (mapv tool->claude tools))
schema (assoc :tool_choice {:type "tool"
:name "structured_output"}
:tools [{:name "structured_output"
:description "Output structured data"
:input_schema (mjs/transform (mut/closed-schema schema))}]))]
(try
(let [res (http/post "https://api.anthropic.com/v1/messages"
{:as :stream
:headers {"x-api-key" (llm/ee-anthropic-api-key)
"anthropic-version" "2023-06-01"
"content-type" "application/json"}
:body (json/encode req)})]
(core/sse-chan (:body res)))
(catch Exception e
(if-let [res (ex-data e)]
(throw (ex-info (.getMessage e) (json/decode-body res)))
(throw e))))))
(comment
(def q (a/<!! (a/into [] (claude-raw {:input [{:role "user" :content "How are you feeling today?"}]}))))
(into [] (comp claude->aisdk-xf core/aisdk-xf) q))

View File

@@ -0,0 +1,170 @@
(ns metabase-enterprise.metabot-v3.self.core
(:require
[clojure.core.async :as a]
[clojure.core.async.impl.protocols :as impl]
[clojure.java.io :as io]
[clojure.string :as str]
[metabase.util :as u]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(defn mkid
"Generate a random id"
[]
(str "mb-" (random-uuid)))
(defn chan?
"Checks if argument is a `core.async/chan`"
[x]
(satisfies? impl/Channel x))
;; we're using channels because of backpressure and ability to close them
(defn sse-chan
"Turn an SSE InputStream into a channel of `data` strings.
Closes the channel on EOF."
[^java.io.Closeable input]
(let [out (a/chan 64)]
(.start
(doto (Thread/ofVirtual) (.name "sse-reader"))
(fn []
;; NOTE: parsing is oriented towards OpenAI-like SSE stream, where there are only `data: ...` lines with json.
;; Feel free to adjust once we have better understanding.
(with-open [r (io/reader input)]
(loop []
(when-let [line (.readLine ^java.io.BufferedReader r)]
(cond
(= line "data: [DONE]") nil
;; this `when` is doing the heavy lifting here: `>!!` will return `false` if the channel is closed:
;; - which exits the loop
;; - which exits `with-open`
;; - which closes the `io/reader`
;; - which closes the underlying `InputStream`
;; and closes the http connection, which should stop the LLM
(str/starts-with? line "data: ") (when (a/>!! out (json/decode+kw (subs line 6)))
(recur))
:else (recur)))))
(a/close! out)))
out))
;;; AISDK5
(defn- aisdk-chunks->part [[chunk :as chunks]]
(case (:type chunk)
:start {:type :start
:id (:messageId chunk)}
:usage chunk
:text-start {:type :text
:id (:id chunk)
:text (->> (map :delta chunks)
(str/join ""))}
:tool-input-start {:type :tool-input
:id (:toolCallId chunk)
:function (:toolName chunk)
:arguments (->> (map :inputTextDelta chunks)
(str/join "")
json/decode+kw)}
:tool-output-available {:type :tool-output
:id (:toolCallId chunk)
:function (:toolName chunk)
:result (:result chunk)
:error (:error chunk)}))
(defn aisdk-xf
"Collect a stream of AI SDK v5 messages into a list of parts (joins by id)."
[rf]
;; FIXME: logic relies on chunks pieces not being interleaved which won't hold for long if we will use
;; `tool-executor-rff`
(let [current-id (volatile! nil)
acc (volatile! [])]
(fn
([result]
(cond-> result
(seq @acc) (rf (aisdk-chunks->part @acc))
true rf))
([result chunk]
(let [chunk-id (or (:id chunk)
(:toolCallId chunk))]
(if (not= chunk-id @current-id)
(u/prog1 (cond-> result
(seq @acc) (rf (aisdk-chunks->part @acc)))
(vreset! current-id chunk-id)
(vreset! acc [chunk]))
(u/prog1 result
(vswap! acc conj chunk))))))))
;;; Fancy tool executor guy
(defn tool-executor-rff
"Transducer that intercepts tool calls, executes them asynchronously, and appends results at the end.
- Passes all chunks through unchanged
- When `:tool-input-start` appears with a tool name from TOOLS, starts tracking that tool call
- Collects arguments from `:tool-input-delta` chunks
- When `:tool-input-available` appears, fires off async tool execution
- At completion, appends all tool results to the stream
NOTE: AISDK5 has only `:tool-output-available` that's sync in nature, so for channels returned (presumably from
LLMS) we just directly append them to our stream."
[tools]
(fn [rf]
;; id -> [chunks...] at first, id -> part + :chan later
(let [active-tools (volatile! {})]
(fn
([result]
(let [merged (a/merge (keep :chan (vals @active-tools)))]
(rf (reduce rf result (take-while some? (repeatedly #(let [v (a/<!! merged)] v)))))))
([result chunk]
(case (:type chunk)
:tool-input-start ; start collecting chunks if tool is known
(let [tool-name (:toolName chunk)
tool-call-id (:toolCallId chunk)]
(when (contains? tools tool-name)
(vswap! active-tools assoc tool-call-id [chunk]))
(rf result chunk))
:tool-input-delta ; append a chunk if we're interested in this tool
(let [tool-call-id (:toolCallId chunk)]
(when (contains? @active-tools tool-call-id)
(vswap! active-tools update tool-call-id conj chunk))
(rf result chunk))
;; grab a tool, execute and send everything to a channel that's going to be examined in
;; reducing arity up there
:tool-input-available
(let [tool-name (:toolName chunk)
tool-call-id (:toolCallId chunk)]
(when-let [chunks (seq (get @active-tools tool-call-id))]
(let [tool-fn (get tools tool-name)
chan (a/chan 10)]
(vswap! active-tools assoc tool-call-id {:chan chan})
(future
(try
;; time to combine those chunks in a sensible representation;
;; we're doing that in a `future` because we're saving some μs (haven't measured yet)
(let [{:keys [arguments]
:as tool-call} (into {} aisdk-xf chunks)
_ (vswap! active-tools update tool-call-id merge tool-call)
result (tool-fn arguments)
done-ch (a/chan)]
(if (chan? result)
(do
(a/pipeline 1 chan (remove #(#{:start :finish} (:type %))) result done-ch)
(a/<!! done-ch)) ;; wait until pipeline stops
(a/>!! chan {:type :tool-output-available
:toolCallId tool-call-id
:toolName tool-name
:result result})))
(catch Exception e
(a/>!! chan {:type :tool-output-available
:toolCallId tool-call-id
:toolName tool-name
:error {:message (.getMessage e)
:type (str (type e))}}))
(finally
(a/close! chan))))))
(rf result chunk))
;; default
(rf result chunk)))))))

View File

@@ -0,0 +1,166 @@
(ns metabase-enterprise.metabot-v3.self.openai
(:require
[clj-http.client :as http]
[clojure.core.async :as a]
[clojure.string :as str]
[malli.json-schema :as mjs]
[malli.util :as mut]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn openai->aisdk-xf
"Translates OpenAI /v1/responses streaming events into AI SDK v5 protocol format.
https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
OpenAI Responses Format:
- Each event: {:type \"response.output_text.delta\" :delta ...}
{:type \"response.output_item.added\" :item {:id :type :output ...}}
AI SDK v5 Format (SSE protocol):
- Message parts: {:type :start, :messageId ...}
- Part types: start, text-start, text-delta, text-end, finish-step, finish
- Ends with: 'data: [DONE]\\n'"
[rf]
;; we've got lots of state since aisdk has lots of start/stop/etc messages that raw openai does not
(let [current-type (volatile! nil)
current-id (volatile! nil)
payload (volatile! {})]
;; some notes about the approach:
;; - most of message types carry similar payload, like id for messages, or id+name for tool calls
;; - this trick with u/prog1 was chosen deliberately, a few approaches were made and they all looked worse
;; - most of dispatch is inlined rather than separated as multimethods is not an overlook to make function
;; smaller, I'd rather contain this hairyness in a single piece while it's possible
(fn
([result]
(cond-> result
;; in case the response was incomplete we'll close up latest type
@current-type (rf (merge {:type (case @current-type
:text :text-end
:function_call :tool-input-available)}
@payload))
true (rf)))
([result {t :type :keys [response item delta] :as chunk}]
(let [middle (second (str/split t #"\."))
chunk-type (case middle
"output_item" (case (:type item)
"message" :text
(keyword (:type item)))
"content_part" :text
"output_text" :text
"function_call_arguments" :function_call
(keyword middle))
chunk-id (or (case chunk-type
;; chunks that have natural id in API response go here
:function_call (:call_id item)
:text (:id chunk)
nil)
@current-id
(core/mkid))]
(cond-> result
(= t "response.created") (rf {:type :start :messageId (:id response)})
;; time to finish previous chunk
;; this logic will skip most of the *.done types, but they seem to be always followed by one of those two?
(or (= t "response.output_item.done")
(and @current-id
(not= chunk-id
@current-id))) (-> (rf (merge {:type (case @current-type
:text :text-end
:function_call :tool-input-available)}
@payload))
(u/prog1
(vreset! current-type nil)
(vreset! current-id nil)
(vreset! payload {})))
;; start of a new chunk
(= t "response.output_item.added") (-> (u/prog1
(vreset! current-type chunk-type)
(vreset! current-id chunk-id)
(vreset! payload
(case @current-type
;; no :type in payloads since we'll use that for finish msg too
:text {:id chunk-id}
:function_call {:toolCallId chunk-id
:toolName (:name item)}
nil)))
(rf (merge (case @current-type
:text {:type :text-start}
:function_call {:type :tool-input-start})
@payload)))
;; just a middle of a chunk
delta (rf (case @current-type
:text {:type :text-delta
:id @current-id
:delta delta}
:function_call {:type :tool-input-delta
:toolCallId (:toolCallId @payload)
:inputTextDelta delta}))
(= (:type chunk)
"response.completed") (rf {:type :usage
:usage (:usage response)
;; non-standard extension, not in AISDK5
:id (:id response)})))))))
(defn- tool->openai [tool]
(let [{:keys [doc schema] :as tool} (if (map? tool) tool (meta tool))
[_:=> [_:cat params] _out] schema
doc (if (str/starts-with? doc "Inputs: ")
;; strip that stuff we're appending in mu/defn
(second (str/split doc #"\n\n " 2))
doc)]
{:type "function"
:name (name (:name tool))
:description doc
:parameters (mjs/transform params {:additionalProperties false})}))
(mu/defn openai-raw
"Perform a request to OpenAI"
[{:keys [model system input tools schema]
:or {model "gpt-4.1-mini"
input [{:role "system" :content "Just tell something to a user"}]}}
:- [:map
[:model {:optional true} :string]
[:system {:optional true} :string]
[:input {:optional true} [:vector [:map
[:role [:enum "user" "assistant"]]
[:content :string]]]]
[:tools {:optional true} [:sequential [:fn var?]]]
;; malli schema expected here
;; TODO: check it's a `:map`
[:schema {:optional true} :any]]]
(assert (llm/ee-openai-api-key) "No OpenAI API key!")
(let [req {:model model
:stream true
:store false
:instructions system
:input input
:tool_choice (when (seq tools) "auto")
:tools (when (seq tools) (mapv tool->openai tools))
:text (when schema
{:format {:type "json_schema"
:strict true
:name "schema"
;; OpenAI insists on `{additionalProperties false}`
:schema (mjs/transform (mut/closed-schema schema))}})}]
(try
(let [res (http/post "https://api.openai.com/v1/responses"
{:as :stream
:headers {"Authorization" (str "Bearer " (llm/ee-openai-api-key))
"Content-Type" "application/json"}
:body (json/encode req)})]
(core/sse-chan (:body res)))
(catch Exception e
(if-let [res (ex-data e)]
(throw (ex-info (.getMessage e) (json/decode-body res)))
(throw e))))))
(defn openai
"Perform a request to OpenAI"
[& args]
;; pipe returns destination channel \o/
(a/pipe (apply openai-raw args) (a/chan 64 openai->aisdk-xf)))

View File

@@ -0,0 +1,284 @@
(ns metabase-enterprise.metabot-v3.self-test
(:require
[clj-http.client :as http]
[clojure.core.async :as a]
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.malli :as mu]
[ring.adapter.jetty :as jetty]))
(set! *warn-on-reflection* true)
(defn json-resource [fname]
(-> (io/resource fname)
slurp
json/decode+kw))
(defn make-sse ^String [v]
(->> (map json/encode v)
(map #(str "data: " % "\n\n"))
(str/join)))
(defn parts->aisdk [parts]
(mapcat
(fn [{:keys [id] :as part}]
(case (:type part)
:start [{:type :start :messageId id}]
:usage [part]
:text (concat
[{:type :text-start :id id}]
(for [bit (partition-all 5 (:text part))]
{:type :text-delta :id id :delta (str/join bit)})
[{:type :text-end :id id}])
:tool-input (concat
[{:type :tool-input-start :toolName (:function part) :toolCallId id}]
(for [bit (partition-all 5 (json/encode (:arguments part)))]
{:type :tool-input-delta :toolCallId id :inputTextDelta (str/join bit)})
[{:type :tool-input-available :toolName (:function part) :toolCallId id}])))
parts))
;;; utils tests
(defn- chan-seq!
"Convert core.async channel to a lazy seq, blocking; just for tests"
[chan]
(lazy-seq
(when-some [chunk (a/<!! chan)]
(cons chunk (chan-seq! chan)))))
(deftest sse-chan-test
(testing "sse-chan actually produces chan with lines"
(let [data ["just a test" "to make you jealous"]
istream (io/input-stream (.getBytes (make-sse data)))
chan (self/sse-chan istream)]
(is (= data
(chan-seq! chan))))))
(deftest sse-chan-stops-response-test
(let [cnt (atom 30)
handler (fn [_req]
(let [out (java.io.PipedOutputStream.)
in (java.io.PipedInputStream. out 65536)]
(future
(try
(dotimes [i @cnt]
(.write out (.getBytes (make-sse [(str "msg-" i)])))
(.flush out)
(swap! cnt dec)
(Thread/sleep 10))
(catch Exception _)
(finally
(try (.close out)
(catch Exception _)))))
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache"}
:body in}))
server (jetty/run-jetty handler {:port 0
:join? false
:output-buffer-size 1})
url (str "http://localhost:" (.. server getURI getPort))]
(try
(let [res (http/request {:method :post :url url :as :stream})
chan (self/sse-chan (:body res))]
(is (= "msg-0" (a/<!! chan)))
(is (= "msg-1" (a/<!! chan)))
(a/close! chan)
;; we're waiting for 10 in the handler, so 30 should be enough for propagation
(Thread/sleep 30)
;; it's been 25-26 when I tested this, if it every becomes flaky maybe decrease the number?
(is (> @cnt 20) "SHOULD have stopped writing when channel closed"))
(finally
(.stop server)))))
;;; conversion tests
(deftest openai-conv-test
(testing "tools calls are mapped well"
(is (= {:start 1
:tool-input-start 2
:tool-input-delta 34
:tool-input-available 2
:usage 1}
(->> (json-resource "llm/openai-tool-calls.json")
(into [] self/openai->aisdk-xf)
(map :type)
frequencies)))
(is (=? [{:type :start}
{:type :tool-input :function "analyze-data-trend" :arguments {}}
{:type :tool-input :function "analyze-data-trend" :arguments {}}
{:type :usage :usage {:total_tokens 225}}]
(->> (json-resource "llm/openai-tool-calls.json")
(into [] (comp self/openai->aisdk-xf self/aisdk-xf))))))
(testing "structured output is parsed too"
(is (= {:start 1
;; TODO: we're representing it as just text but this needs to be improved maybe?
:text-start 1
:text-delta 32
:text-end 1
:usage 1}
(->> (json-resource "llm/openai-structured-output.json")
(into [] self/openai->aisdk-xf)
(map :type)
frequencies)))
(is (=? [{:type :start}
{:type :text :text string?}
{:type :usage :usage {:total_tokens 109}}]
(->> (json-resource "llm/openai-structured-output.json")
(into [] (comp self/openai->aisdk-xf self/aisdk-xf)))))))
(deftest claude-conv-test
(testing "text is mapped well"
(is (= {:start 1
:text-start 1
:text-delta 6
:text-end 1
:usage 1}
(->> (json-resource "llm/claude-text.json")
(into [] self/claude->aisdk-xf)
(map :type)
frequencies)))
(is (=? [{:type :start :id string?}
{:type :text :id string? :text string?}
{:type :usage :id string? :usage {:promptTokens 13}}]
(->> (json-resource "llm/claude-text.json")
(into [] (comp self/claude->aisdk-xf self/aisdk-xf))))))
(testing "tool input (also structured output) is mapped well"
(is (= {:start 1
:tool-input-start 1
:tool-input-delta 15
:tool-input-available 1
:usage 1}
(->> (json-resource "llm/claude-tool-input.json")
(into [] self/claude->aisdk-xf)
(map :type)
frequencies)))
(is (=? [{:type :start}
{:type :tool-input :arguments {:currencies [{:country "CAN" :currency "CAD"}
{:country "USA" :currency "USD"}
{:country "MEX" :currency "MXN"}]}}
;; TODO: convert usage to common format
{:type :usage :usage {:promptTokens 737}}]
(->> (json-resource "llm/claude-tool-input.json")
(into [] (comp self/claude->aisdk-xf self/aisdk-xf)))))))
;;; tool executor
(mu/defn get-time
"Return current time for a given IANA timezone."
[{:keys [tz]} :- [:map {:closed true}
[:tz [:string {:description "IANA timezone, e.g. Europe/Bucharest"}]]]]
(str (java.time.ZonedDateTime/now (java.time.ZoneId/of tz))))
(mu/defn convert-currency
"Convert an amount between two ISO currencies using a dummy rate."
[{:keys [amount from to]} :- [:map {:closed true}
[:amount :float]
[:from :string]
[:to :string]]]
(let [rate (if (= [from to] ["EUR" "USD"]) 1.16 1.0)]
{:amount amount
:from from
:to to
:rate rate
:converted (* amount rate)}))
(mu/defn mock-llm
"Return aisdk-formatted results in a core.async channel"
[{:keys [id input]} :- [:map {:closed true}
[:id :string]
[:input :string]]]
(a/to-chan!! (parts->aisdk
[{:type :start :id "mock-1"}
{:type :text :id id :text input}])))
(def TOOLS
"All the defined tools"
(u/index-by
#(-> % meta :name name)
[#'get-time
#'convert-currency
#'mock-llm]))
(deftest ^:parallel async-tool-executor-rff-test
(testing "tool-executor-rff passes through all chunks unchanged"
(let [chunks (parts->aisdk
[{:type :start :id "msg-123"}
{:type :text :id "text-1" :text "Hello world"}])
result (into [] (self/tool-executor-rff TOOLS) chunks)]
(is (= chunks result)
"Non-tool chunks should pass through unchanged")))
(testing "tool-executor-rff executes tool calls and appends results"
(let [chunks (parts->aisdk
[{:type :start :id "msg-123"}
{:type :tool-input :id "call-1" :function "get-time" :arguments {:tz "Europe/Kyiv"}}
{:type :usage :usage {:total_tokens 100}}])
result (into [] (self/tool-executor-rff TOOLS) chunks)]
(is (= (count chunks) (dec (count result)))
"Should have original chunks plus one tool result")
(is (= chunks (take (count chunks) result))
"Original chunks should be unchanged")
(let [tool-result (last result)]
(is (=? {:type :tool-output-available
:toolCallId "call-1"
:toolName "get-time"
:result string?}
tool-result)))))
(testing "tool-executor-rff handles multiple concurrent tool calls"
(let [chunks (parts->aisdk
[{:type :start :id "msg-456"}
{:type :tool-input :id "call-1" :function "get-time" :arguments {:tz "Europe/Kyiv"}}
{:type :tool-input :id "call-2" :function "convert-currency" :arguments {:amount 100, :from "EUR", :to "USD"}}])
result (into [] (self/tool-executor-rff TOOLS) chunks)]
(is (= (+ (count chunks) 2) (count result))
"Should have original chunks plus two tool results")
(let [tool-results (take-last 2 result)]
(is (every? #(= :tool-output-available (:type %)) tool-results)
"Last two chunks should be tool outputs")
(is (= #{"call-1" "call-2"}
(set (map :toolCallId tool-results)))))))
(testing "tool-executor-rff handles tools returning channels"
(let [llm-id "wut-1"
input "Little bits and pieces"
chunks (parts->aisdk
[{:type :start :id "msg-666"}
{:type :tool-input :id "call-1" :function "mock-llm" :arguments {:input input
:id llm-id}}])
result (into [] (self/tool-executor-rff TOOLS) chunks)]
(is (= 1 (count (filter #(= :start (:type %)) result)))
"Just the first start is left in the stream")
(is (< 3 (count (filter #(= llm-id (:id %)) result)))
"We get output from our 'llm' in little pieces")
(is (= {:type :text
:id llm-id
:text input}
(last (into [] self/aisdk-xf result))))))
(testing "tool-executor-rff handles tool execution errors gracefully"
(let [chunks (parts->aisdk
[{:type :start :id "msg-789"}
{:type :tool-input :id "call-err" :function "get-time" :arguments {:tz "Invalid/Timezone"}}])
result (into [] (self/tool-executor-rff TOOLS) chunks)]
(is (= (count chunks) (dec (count result))))
(let [tool-result (last result)]
(is (=? {:type :tool-output-available
:toolCallId "call-err"
:toolName "get-time"
:error {:message string?
:type string?}}
tool-result)))))
(testing "tool-executor-rff ignores unknown tool names"
(let [chunks (parts->aisdk
[{:type :start :id "msg-789"}
{:type :tool-input :id "call-1" :function "unknown-tool" :arguments {:foo :bar}}])
result (into [] (self/tool-executor-rff TOOLS) chunks)]
(is (= chunks result)
"Unknown tools should be ignored, chunks pass through unchanged"))))

View File

@@ -5,7 +5,8 @@
[cheshire.core :as cheshire]
[cheshire.factory]
[cheshire.generate :as json.generate]
[clojure.java.io :as io])
[clojure.java.io :as io]
[clojure.string :as str])
(:import
(com.fasterxml.jackson.core JsonGenerator)
(java.io InputStream Reader)))
@@ -89,3 +90,12 @@
"Decode a value from a JSON from a string, InputStream, or Reader, keywordizing map keys."
[source]
(decode source true))
(defn decode-body
"Given a response map, decodes body if headers indicate it's a JSON response, or just slurps if it's not a string."
[res]
(let [ctype (get-in res [:headers "content-type"])]
(cond
(some-> ctype (str/starts-with? "application/json")) (update res :body decode+kw)
(string? (:body res)) res
:else (update res :body slurp))))

View File

@@ -0,0 +1,91 @@
[ {
"type" : "message_start",
"message" : {
"model" : "claude-sonnet-4-5-20250929",
"id" : "msg_01NSbAHc6fiJS5e1c6UPdKho",
"type" : "message",
"role" : "assistant",
"content" : [ ],
"stop_reason" : null,
"stop_sequence" : null,
"usage" : {
"input_tokens" : 13,
"cache_creation_input_tokens" : 0,
"cache_read_input_tokens" : 0,
"cache_creation" : {
"ephemeral_5m_input_tokens" : 0,
"ephemeral_1h_input_tokens" : 0
},
"output_tokens" : 2,
"service_tier" : "standard"
}
}
}, {
"type" : "content_block_start",
"index" : 0,
"content_block" : {
"type" : "text",
"text" : ""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "text_delta",
"text" : "I don"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "text_delta",
"text" : "'t experience feelings in the way you "
}
}, {
"type" : "ping"
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "text_delta",
"text" : "do, but I'm functioning well and ready to help!"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "text_delta",
"text" : " I'm curious though – how are *you* feeling today? Is"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "text_delta",
"text" : " there something specific on your mind or something"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "text_delta",
"text" : " I can assist you with?"
}
}, {
"type" : "content_block_stop",
"index" : 0
}, {
"type" : "message_delta",
"delta" : {
"stop_reason" : "end_turn",
"stop_sequence" : null
},
"usage" : {
"input_tokens" : 13,
"cache_creation_input_tokens" : 0,
"cache_read_input_tokens" : 0,
"output_tokens" : 52
}
}, {
"type" : "message_stop"
} ]

View File

@@ -0,0 +1,154 @@
[ {
"type" : "message_start",
"message" : {
"model" : "claude-sonnet-4-5-20250929",
"id" : "msg_01JaVDFooYCJgjDYdVv7UZuA",
"type" : "message",
"role" : "assistant",
"content" : [ ],
"stop_reason" : null,
"stop_sequence" : null,
"usage" : {
"input_tokens" : 737,
"cache_creation_input_tokens" : 0,
"cache_read_input_tokens" : 0,
"cache_creation" : {
"ephemeral_5m_input_tokens" : 0,
"ephemeral_1h_input_tokens" : 0
},
"output_tokens" : 18,
"service_tier" : "standard"
}
}
}, {
"type" : "content_block_start",
"index" : 0,
"content_block" : {
"type" : "tool_use",
"id" : "toolu_012iDFpxaJuqVBQEqVVyfu6T",
"name" : "structured_output",
"input" : { }
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : ""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "{\"currenc"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "ies\": [{\"cou"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "ntry\":\"CA"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "N\",\"curr"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "ency\":\"CAD\""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "},{\"country"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "\":\"USA\",\""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "cur"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "rency\":\""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "USD\"},{\"coun"
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "try\":\""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "MEX\","
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : "\"currency\""
}
}, {
"type" : "content_block_delta",
"index" : 0,
"delta" : {
"type" : "input_json_delta",
"partial_json" : ":\"MXN\"}]}"
}
}, {
"type" : "content_block_stop",
"index" : 0
}, {
"type" : "message_delta",
"delta" : {
"stop_reason" : "tool_use",
"stop_sequence" : null
},
"usage" : {
"input_tokens" : 737,
"cache_creation_input_tokens" : 0,
"cache_read_input_tokens" : 0,
"output_tokens" : 82
}
}, {
"type" : "message_stop"
} ]

View File

@@ -0,0 +1,579 @@
[ {
"type" : "response.created",
"sequence_number" : 0,
"response" : {
"previous_response_id" : null,
"tool_choice" : "auto",
"top_logprobs" : 0,
"store" : false,
"top_p" : 1.0,
"service_tier" : "auto",
"tools" : [ ],
"instructions" : null,
"background" : false,
"reasoning" : {
"effort" : null,
"summary" : null
},
"output" : [ ],
"usage" : null,
"safety_identifier" : null,
"prompt_cache_key" : null,
"incomplete_details" : null,
"status" : "in_progress",
"id" : "resp_0ca34af04d29538e0168fb881f3a2881a09bcbafa7555d1d3c",
"max_output_tokens" : null,
"max_tool_calls" : null,
"truncation" : "disabled",
"error" : null,
"user" : null,
"metadata" : { },
"object" : "response",
"temperature" : 1.0,
"created_at" : 1761314847,
"parallel_tool_calls" : true,
"text" : {
"format" : {
"type" : "json_schema",
"description" : null,
"name" : "schema",
"schema" : {
"type" : "object",
"properties" : {
"currencies" : {
"type" : "array",
"items" : {
"type" : "object",
"properties" : {
"country" : {
"description" : "Three-letter code",
"type" : "string"
},
"currency" : {
"description" : "Three-letter code",
"type" : "string"
}
},
"required" : [ "country", "currency" ],
"additionalProperties" : false
}
}
},
"required" : [ "currencies" ],
"additionalProperties" : false
},
"strict" : true
},
"verbosity" : "medium"
},
"model" : "gpt-4.1-mini-2025-04-14"
}
}, {
"type" : "response.in_progress",
"sequence_number" : 1,
"response" : {
"previous_response_id" : null,
"tool_choice" : "auto",
"top_logprobs" : 0,
"store" : false,
"top_p" : 1.0,
"service_tier" : "auto",
"tools" : [ ],
"instructions" : null,
"background" : false,
"reasoning" : {
"effort" : null,
"summary" : null
},
"output" : [ ],
"usage" : null,
"safety_identifier" : null,
"prompt_cache_key" : null,
"incomplete_details" : null,
"status" : "in_progress",
"id" : "resp_0ca34af04d29538e0168fb881f3a2881a09bcbafa7555d1d3c",
"max_output_tokens" : null,
"max_tool_calls" : null,
"truncation" : "disabled",
"error" : null,
"user" : null,
"metadata" : { },
"object" : "response",
"temperature" : 1.0,
"created_at" : 1761314847,
"parallel_tool_calls" : true,
"text" : {
"format" : {
"type" : "json_schema",
"description" : null,
"name" : "schema",
"schema" : {
"type" : "object",
"properties" : {
"currencies" : {
"type" : "array",
"items" : {
"type" : "object",
"properties" : {
"country" : {
"description" : "Three-letter code",
"type" : "string"
},
"currency" : {
"description" : "Three-letter code",
"type" : "string"
}
},
"required" : [ "country", "currency" ],
"additionalProperties" : false
}
}
},
"required" : [ "currencies" ],
"additionalProperties" : false
},
"strict" : true
},
"verbosity" : "medium"
},
"model" : "gpt-4.1-mini-2025-04-14"
}
}, {
"type" : "response.output_item.added",
"sequence_number" : 2,
"output_index" : 0,
"item" : {
"id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"type" : "message",
"status" : "in_progress",
"content" : [ ],
"role" : "assistant"
}
}, {
"type" : "response.content_part.added",
"sequence_number" : 3,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"part" : {
"type" : "output_text",
"annotations" : [ ],
"logprobs" : [ ],
"text" : ""
}
}, {
"type" : "response.output_text.delta",
"sequence_number" : 4,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "{\"",
"logprobs" : [ ],
"obfuscation" : "Ul3wymizN27SES"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 5,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "curr",
"logprobs" : [ ],
"obfuscation" : "Q6rHkmKGWCPS"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 6,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "encies",
"logprobs" : [ ],
"obfuscation" : "D0iDA126nN"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 7,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":[",
"logprobs" : [ ],
"obfuscation" : "aGjRa82Tg8MTM"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 8,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "{\"",
"logprobs" : [ ],
"obfuscation" : "mfsWjRGPWxhpk3"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 9,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "country",
"logprobs" : [ ],
"obfuscation" : "NxYi9q5cE"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 10,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":\"",
"logprobs" : [ ],
"obfuscation" : "q2K5KIYOEb5ce"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 11,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "USA",
"logprobs" : [ ],
"obfuscation" : "MnmCqcS11BaIo"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 12,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\",\"",
"logprobs" : [ ],
"obfuscation" : "HmTcwAx03927C"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 13,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "currency",
"logprobs" : [ ],
"obfuscation" : "TIgq0xXu"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 14,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":\"",
"logprobs" : [ ],
"obfuscation" : "aoHmfLEtAafDV"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 15,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "USD",
"logprobs" : [ ],
"obfuscation" : "gaNsBVlT07Nwi"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 16,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\"},{\"",
"logprobs" : [ ],
"obfuscation" : "THpEpCEhXhV"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 17,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "country",
"logprobs" : [ ],
"obfuscation" : "ATA5uOovr"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 18,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":\"",
"logprobs" : [ ],
"obfuscation" : "0pAAmxK3eZ4ow"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 19,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "CAN",
"logprobs" : [ ],
"obfuscation" : "I88ezjGjyZQDr"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 20,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\",\"",
"logprobs" : [ ],
"obfuscation" : "xSxLuYIaUNlPu"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 21,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "currency",
"logprobs" : [ ],
"obfuscation" : "MurBe8Ck"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 22,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":\"",
"logprobs" : [ ],
"obfuscation" : "CM2RiZkK3bXo3"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 23,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "CAD",
"logprobs" : [ ],
"obfuscation" : "oIjELbQAJSyD4"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 24,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\"},{\"",
"logprobs" : [ ],
"obfuscation" : "q3PC1t5fNOq"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 25,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "country",
"logprobs" : [ ],
"obfuscation" : "cucS4dtLC"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 26,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":\"",
"logprobs" : [ ],
"obfuscation" : "T6oan7ahr1c46"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 27,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "M",
"logprobs" : [ ],
"obfuscation" : "YiZ6s9ypSEltXFR"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 28,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "EX",
"logprobs" : [ ],
"obfuscation" : "0B7uMO2Em77Sgn"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 29,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\",\"",
"logprobs" : [ ],
"obfuscation" : "W7zyWPzWxcbwa"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 30,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "currency",
"logprobs" : [ ],
"obfuscation" : "64jnAhOj"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 31,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\":\"",
"logprobs" : [ ],
"obfuscation" : "YYZy7RJ0lkG9z"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 32,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "MX",
"logprobs" : [ ],
"obfuscation" : "zdj5CpYRcRK9kP"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 33,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "N",
"logprobs" : [ ],
"obfuscation" : "X7gGiXXowmCDOo0"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 34,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "\"}",
"logprobs" : [ ],
"obfuscation" : "6uGb0m2YCH0QGQ"
}, {
"type" : "response.output_text.delta",
"sequence_number" : 35,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"delta" : "]}",
"logprobs" : [ ],
"obfuscation" : "Aeoh74yeT3aEGi"
}, {
"type" : "response.output_text.done",
"sequence_number" : 36,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"text" : "{\"currencies\":[{\"country\":\"USA\",\"currency\":\"USD\"},{\"country\":\"CAN\",\"currency\":\"CAD\"},{\"country\":\"MEX\",\"currency\":\"MXN\"}]}",
"logprobs" : [ ]
}, {
"type" : "response.content_part.done",
"sequence_number" : 37,
"item_id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"output_index" : 0,
"content_index" : 0,
"part" : {
"type" : "output_text",
"annotations" : [ ],
"logprobs" : [ ],
"text" : "{\"currencies\":[{\"country\":\"USA\",\"currency\":\"USD\"},{\"country\":\"CAN\",\"currency\":\"CAD\"},{\"country\":\"MEX\",\"currency\":\"MXN\"}]}"
}
}, {
"type" : "response.output_item.done",
"sequence_number" : 38,
"output_index" : 0,
"item" : {
"id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"type" : "message",
"status" : "completed",
"content" : [ {
"type" : "output_text",
"annotations" : [ ],
"logprobs" : [ ],
"text" : "{\"currencies\":[{\"country\":\"USA\",\"currency\":\"USD\"},{\"country\":\"CAN\",\"currency\":\"CAD\"},{\"country\":\"MEX\",\"currency\":\"MXN\"}]}"
} ],
"role" : "assistant"
}
}, {
"type" : "response.completed",
"sequence_number" : 39,
"response" : {
"previous_response_id" : null,
"tool_choice" : "auto",
"top_logprobs" : 0,
"store" : false,
"top_p" : 1.0,
"service_tier" : "default",
"tools" : [ ],
"instructions" : null,
"background" : false,
"reasoning" : {
"effort" : null,
"summary" : null
},
"output" : [ {
"id" : "msg_0ca34af04d29538e0168fb88203d6481a09f06bfa2b66cc16a",
"type" : "message",
"status" : "completed",
"content" : [ {
"type" : "output_text",
"annotations" : [ ],
"logprobs" : [ ],
"text" : "{\"currencies\":[{\"country\":\"USA\",\"currency\":\"USD\"},{\"country\":\"CAN\",\"currency\":\"CAD\"},{\"country\":\"MEX\",\"currency\":\"MXN\"}]}"
} ],
"role" : "assistant"
} ],
"usage" : {
"input_tokens" : 76,
"input_tokens_details" : {
"cached_tokens" : 0
},
"output_tokens" : 33,
"output_tokens_details" : {
"reasoning_tokens" : 0
},
"total_tokens" : 109
},
"safety_identifier" : null,
"prompt_cache_key" : null,
"incomplete_details" : null,
"status" : "completed",
"id" : "resp_0ca34af04d29538e0168fb881f3a2881a09bcbafa7555d1d3c",
"max_output_tokens" : null,
"max_tool_calls" : null,
"truncation" : "disabled",
"error" : null,
"user" : null,
"metadata" : { },
"object" : "response",
"temperature" : 1.0,
"created_at" : 1761314847,
"parallel_tool_calls" : true,
"text" : {
"format" : {
"type" : "json_schema",
"description" : null,
"name" : "schema",
"schema" : {
"type" : "object",
"properties" : {
"currencies" : {
"type" : "array",
"items" : {
"type" : "object",
"properties" : {
"country" : {
"description" : "Three-letter code",
"type" : "string"
},
"currency" : {
"description" : "Three-letter code",
"type" : "string"
}
},
"required" : [ "country", "currency" ],
"additionalProperties" : false
}
}
},
"required" : [ "currencies" ],
"additionalProperties" : false
},
"strict" : true
},
"verbosity" : "medium"
},
"model" : "gpt-4.1-mini-2025-04-14"
}
} ]

View File

@@ -0,0 +1,641 @@
[ {
"type" : "response.created",
"sequence_number" : 0,
"response" : {
"previous_response_id" : null,
"tool_choice" : "auto",
"top_logprobs" : 0,
"store" : false,
"top_p" : 1.0,
"service_tier" : "auto",
"tools" : [ {
"type" : "function",
"description" : "Return current time for a given IANA timezone.",
"name" : "get-time",
"parameters" : {
"type" : "object",
"properties" : {
"tz" : {
"description" : "IANA timezone, e.g. Europe/Bucharest",
"type" : "string"
}
},
"required" : [ "tz" ],
"additionalProperties" : false
},
"strict" : true
}, {
"type" : "function",
"description" : "Convert an amount between two ISO currencies using a dummy rate.",
"name" : "convert-currency",
"parameters" : {
"type" : "object",
"properties" : {
"amount" : {
"type" : "number"
},
"from" : {
"type" : "string"
},
"to" : {
"type" : "string"
}
},
"required" : [ "amount", "from", "to" ],
"additionalProperties" : false
},
"strict" : true
}, {
"type" : "function",
"description" : "Analyze a data trend by calling back to the LLM for natural language insights.\n This demonstrates a recursive LLM call pattern commonly used in agentic workflows.",
"name" : "analyze-data-trend",
"parameters" : {
"type" : "object",
"properties" : {
"metric" : {
"description" : "The metric being analyzed, e.g. 'revenue', 'users'",
"type" : "string"
},
"values" : {
"description" : "Time series values",
"type" : "array",
"items" : {
"type" : "number"
}
},
"period" : {
"description" : "Time period, e.g. 'Q1 2025', 'last 6 months'",
"type" : "string"
}
},
"required" : [ "metric", "values", "period" ],
"additionalProperties" : false
},
"strict" : true
} ],
"instructions" : "You are a data analysis assistant. When users provide time-series data and ask for insights, use the analyze-data-trend tool to generate interpretations. Always call the tool rather than making up your own analysis.",
"background" : false,
"reasoning" : {
"effort" : null,
"summary" : null
},
"output" : [ ],
"usage" : null,
"safety_identifier" : null,
"prompt_cache_key" : null,
"incomplete_details" : null,
"status" : "in_progress",
"id" : "resp_0a93101cc19fa9880168fb5bd1d7d481a0a2913223de357104",
"max_output_tokens" : null,
"max_tool_calls" : null,
"truncation" : "disabled",
"error" : null,
"user" : null,
"metadata" : { },
"object" : "response",
"temperature" : 1.0,
"created_at" : 1761303506,
"parallel_tool_calls" : true,
"text" : {
"format" : {
"type" : "text"
},
"verbosity" : "medium"
},
"model" : "gpt-4.1-mini-2025-04-14"
}
}, {
"type" : "response.in_progress",
"sequence_number" : 1,
"response" : {
"previous_response_id" : null,
"tool_choice" : "auto",
"top_logprobs" : 0,
"store" : false,
"top_p" : 1.0,
"service_tier" : "auto",
"tools" : [ {
"type" : "function",
"description" : "Return current time for a given IANA timezone.",
"name" : "get-time",
"parameters" : {
"type" : "object",
"properties" : {
"tz" : {
"description" : "IANA timezone, e.g. Europe/Bucharest",
"type" : "string"
}
},
"required" : [ "tz" ],
"additionalProperties" : false
},
"strict" : true
}, {
"type" : "function",
"description" : "Convert an amount between two ISO currencies using a dummy rate.",
"name" : "convert-currency",
"parameters" : {
"type" : "object",
"properties" : {
"amount" : {
"type" : "number"
},
"from" : {
"type" : "string"
},
"to" : {
"type" : "string"
}
},
"required" : [ "amount", "from", "to" ],
"additionalProperties" : false
},
"strict" : true
}, {
"type" : "function",
"description" : "Analyze a data trend by calling back to the LLM for natural language insights.\n This demonstrates a recursive LLM call pattern commonly used in agentic workflows.",
"name" : "analyze-data-trend",
"parameters" : {
"type" : "object",
"properties" : {
"metric" : {
"description" : "The metric being analyzed, e.g. 'revenue', 'users'",
"type" : "string"
},
"values" : {
"description" : "Time series values",
"type" : "array",
"items" : {
"type" : "number"
}
},
"period" : {
"description" : "Time period, e.g. 'Q1 2025', 'last 6 months'",
"type" : "string"
}
},
"required" : [ "metric", "values", "period" ],
"additionalProperties" : false
},
"strict" : true
} ],
"instructions" : "You are a data analysis assistant. When users provide time-series data and ask for insights, use the analyze-data-trend tool to generate interpretations. Always call the tool rather than making up your own analysis.",
"background" : false,
"reasoning" : {
"effort" : null,
"summary" : null
},
"output" : [ ],
"usage" : null,
"safety_identifier" : null,
"prompt_cache_key" : null,
"incomplete_details" : null,
"status" : "in_progress",
"id" : "resp_0a93101cc19fa9880168fb5bd1d7d481a0a2913223de357104",
"max_output_tokens" : null,
"max_tool_calls" : null,
"truncation" : "disabled",
"error" : null,
"user" : null,
"metadata" : { },
"object" : "response",
"temperature" : 1.0,
"created_at" : 1761303506,
"parallel_tool_calls" : true,
"text" : {
"format" : {
"type" : "text"
},
"verbosity" : "medium"
},
"model" : "gpt-4.1-mini-2025-04-14"
}
}, {
"type" : "response.output_item.added",
"sequence_number" : 2,
"output_index" : 0,
"item" : {
"id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"type" : "function_call",
"status" : "in_progress",
"arguments" : "",
"call_id" : "call_9lJIfuuW5IAKOb4kVfdqkoDJ",
"name" : "analyze-data-trend"
}
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 3,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "{",
"obfuscation" : "FfJORXg2oHG3AnS"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 4,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\"metric",
"obfuscation" : "OOeCNg38A"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 5,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\":",
"obfuscation" : "3vZHLcv3Axrwrf"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 6,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\"re",
"obfuscation" : "dlTxs2T5bkyLy"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 7,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "venue",
"obfuscation" : "KqGxPzytiD9"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 8,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\",",
"obfuscation" : "FDXUoSyY2EQhnC"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 9,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\"values",
"obfuscation" : "P8mXBHkzH"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 10,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\":[",
"obfuscation" : "TOqgWWYWzFSot"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 11,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "50000,",
"obfuscation" : "QjrxQbMdS5"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 12,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "55000,",
"obfuscation" : "6KC3WM2QpB"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 13,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "58000,",
"obfuscation" : "qwbVBFQL9a"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 14,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "62000],",
"obfuscation" : "jIIrAVxqC"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 15,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\"period",
"obfuscation" : "MjNe3ga8A"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 16,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\":",
"obfuscation" : "hlb4qNhA2ESqoi"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 17,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\"Q",
"obfuscation" : "SVkKv2AYdXJO8r"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 18,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "1",
"obfuscation" : "njRtFFC57dLndFB"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 19,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"delta" : "\"}",
"obfuscation" : "h2JUplPrVIeMrm"
}, {
"type" : "response.function_call_arguments.done",
"sequence_number" : 20,
"item_id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"output_index" : 0,
"arguments" : "{\"metric\":\"revenue\",\"values\":[50000,55000,58000,62000],\"period\":\"Q1\"}"
}, {
"type" : "response.output_item.done",
"sequence_number" : 21,
"output_index" : 0,
"item" : {
"id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"type" : "function_call",
"status" : "completed",
"arguments" : "{\"metric\":\"revenue\",\"values\":[50000,55000,58000,62000],\"period\":\"Q1\"}",
"call_id" : "call_9lJIfuuW5IAKOb4kVfdqkoDJ",
"name" : "analyze-data-trend"
}
}, {
"type" : "response.output_item.added",
"sequence_number" : 22,
"output_index" : 1,
"item" : {
"id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"type" : "function_call",
"status" : "in_progress",
"arguments" : "",
"call_id" : "call_vuD5KmaxyQ55WRKCMUZOJ4hz",
"name" : "analyze-data-trend"
}
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 23,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "{",
"obfuscation" : "hPFTrRLvzBmokZu"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 24,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\"metric",
"obfuscation" : "Adu9kuY84"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 25,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\":",
"obfuscation" : "g1AXeVsaO2RqGC"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 26,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\"customer",
"obfuscation" : "Qaz7W75"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 27,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : " count",
"obfuscation" : "wfjVeuKAo8"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 28,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\",",
"obfuscation" : "gfOmpFPNJUwnaq"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 29,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\"values",
"obfuscation" : "OJWErZx0t"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 30,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\":[",
"obfuscation" : "tNU3yBfiZND2w"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 31,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "100,",
"obfuscation" : "tF1hoVFzosb5"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 32,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "110,",
"obfuscation" : "8Fqgor9b46Oo"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 33,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "105,",
"obfuscation" : "FgQ1QmrIyit2"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 34,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "115],",
"obfuscation" : "sId2VdDMuFT"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 35,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\"period",
"obfuscation" : "8s7X915fx"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 36,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\":",
"obfuscation" : "heVDgIklzhMVMK"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 37,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\"Q",
"obfuscation" : "q0Rm2YZL0sAMF5"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 38,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "1",
"obfuscation" : "i3kuywBLI7Q47C1"
}, {
"type" : "response.function_call_arguments.delta",
"sequence_number" : 39,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"delta" : "\"}",
"obfuscation" : "5hBqIqzHLLRtbw"
}, {
"type" : "response.function_call_arguments.done",
"sequence_number" : 40,
"item_id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"output_index" : 1,
"arguments" : "{\"metric\":\"customer count\",\"values\":[100,110,105,115],\"period\":\"Q1\"}"
}, {
"type" : "response.output_item.done",
"sequence_number" : 41,
"output_index" : 1,
"item" : {
"id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"type" : "function_call",
"status" : "completed",
"arguments" : "{\"metric\":\"customer count\",\"values\":[100,110,105,115],\"period\":\"Q1\"}",
"call_id" : "call_vuD5KmaxyQ55WRKCMUZOJ4hz",
"name" : "analyze-data-trend"
}
}, {
"type" : "response.completed",
"sequence_number" : 42,
"response" : {
"previous_response_id" : null,
"tool_choice" : "auto",
"top_logprobs" : 0,
"store" : false,
"top_p" : 1.0,
"service_tier" : "default",
"tools" : [ {
"type" : "function",
"description" : "Return current time for a given IANA timezone.",
"name" : "get-time",
"parameters" : {
"type" : "object",
"properties" : {
"tz" : {
"description" : "IANA timezone, e.g. Europe/Bucharest",
"type" : "string"
}
},
"required" : [ "tz" ],
"additionalProperties" : false
},
"strict" : true
}, {
"type" : "function",
"description" : "Convert an amount between two ISO currencies using a dummy rate.",
"name" : "convert-currency",
"parameters" : {
"type" : "object",
"properties" : {
"amount" : {
"type" : "number"
},
"from" : {
"type" : "string"
},
"to" : {
"type" : "string"
}
},
"required" : [ "amount", "from", "to" ],
"additionalProperties" : false
},
"strict" : true
}, {
"type" : "function",
"description" : "Analyze a data trend by calling back to the LLM for natural language insights.\n This demonstrates a recursive LLM call pattern commonly used in agentic workflows.",
"name" : "analyze-data-trend",
"parameters" : {
"type" : "object",
"properties" : {
"metric" : {
"description" : "The metric being analyzed, e.g. 'revenue', 'users'",
"type" : "string"
},
"values" : {
"description" : "Time series values",
"type" : "array",
"items" : {
"type" : "number"
}
},
"period" : {
"description" : "Time period, e.g. 'Q1 2025', 'last 6 months'",
"type" : "string"
}
},
"required" : [ "metric", "values", "period" ],
"additionalProperties" : false
},
"strict" : true
} ],
"instructions" : "You are a data analysis assistant. When users provide time-series data and ask for insights, use the analyze-data-trend tool to generate interpretations. Always call the tool rather than making up your own analysis.",
"background" : false,
"reasoning" : {
"effort" : null,
"summary" : null
},
"output" : [ {
"id" : "fc_0a93101cc19fa9880168fb5bd37fe481a09db247297868375d",
"type" : "function_call",
"status" : "completed",
"arguments" : "{\"metric\":\"revenue\",\"values\":[50000,55000,58000,62000],\"period\":\"Q1\"}",
"call_id" : "call_9lJIfuuW5IAKOb4kVfdqkoDJ",
"name" : "analyze-data-trend"
}, {
"id" : "fc_0a93101cc19fa9880168fb5bd4078881a082a3ce11fa151383",
"type" : "function_call",
"status" : "completed",
"arguments" : "{\"metric\":\"customer count\",\"values\":[100,110,105,115],\"period\":\"Q1\"}",
"call_id" : "call_vuD5KmaxyQ55WRKCMUZOJ4hz",
"name" : "analyze-data-trend"
} ],
"usage" : {
"input_tokens" : 139,
"input_tokens_details" : {
"cached_tokens" : 0
},
"output_tokens" : 86,
"output_tokens_details" : {
"reasoning_tokens" : 0
},
"total_tokens" : 225
},
"safety_identifier" : null,
"prompt_cache_key" : null,
"incomplete_details" : null,
"status" : "completed",
"id" : "resp_0a93101cc19fa9880168fb5bd1d7d481a0a2913223de357104",
"max_output_tokens" : null,
"max_tool_calls" : null,
"truncation" : "disabled",
"error" : null,
"user" : null,
"metadata" : { },
"object" : "response",
"temperature" : 1.0,
"created_at" : 1761303506,
"parallel_tool_calls" : true,
"text" : {
"format" : {
"type" : "text"
},
"verbosity" : "medium"
},
"model" : "gpt-4.1-mini-2025-04-14"
}
} ]