A Clojure library inspired by DSPy, providing a declarative approach to building and optimizing language model pipelines.
DSCloj leverages litellm-clj to provide a unified interface for working with various LLM providers while bringing DSPy's powerful programming model to the Clojure ecosystem.
DSCloj brings the power of declarative LLM programming to Clojure. Inspired by Stanford's DSPy framework.
Add DSCloj to your deps.edn:
{:deps {tech.unravel/dscloj {:mvn/version "0.1.0-alpha.1"}}}DSCloj works by defining modules - declarative specifications of LLM tasks with typed inputs and outputs.
(require '[dscloj.core :as dscloj])
;; 1. Define a module with Malli specs
(def qa-module
{:inputs [{:name :question
:spec :string
:description "The question to answer"}]
:outputs [{:name :answer
:spec :string
:description "The answer to the question"}]
:instructions "Provide concise and accurate answers."})
;; 2. Register a provider (one-time setup)
(dscloj/register-provider! :gpt4
{:provider :openai
:model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})
;; Or use quick-setup to register common providers from environment
(dscloj/quick-setup!) ; Registers :openai, :anthropic, :gemini, etc.
;; 3. Use the module with predict
(def result (dscloj/predict :gpt4 qa-module
{:question "What is the capital of France?"}
:gpt4)) ; provider-config
;; 4. Access the structured output
(:answer result)
;; => "Paris"Modules are maps with:
:inputs- Vector of input field definitions:outputs- Vector of output field definitions:instructions- Optional string describing the task instructions, rules, and examples
Fields are maps with:
:name- Keyword identifier:spec- Malli spec (e.g.,:string,:int,:double,:boolean, or more complex specs like[:int {:min 0 :max 100}]):description- Human-readable description
Provider Management:
register-provider!- Register named provider configurationsquick-setup!- Auto-register providers from environment variableslist-providers- List all registered providersget-provider- Retrieve a specific provider config
The predict function:
- Validates input data against Malli specs
- Generates a prompt from the module specification
- Injects your input values
- Calls the LLM via router API
- Parses and type-converts the output
- Validates output data against Malli specs
DSCloj uses litellm-clj's router API for flexible provider management:
;; Option 1: Register providers (recommended)
(dscloj/register-provider! :gpt4
{:provider :openai
:model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})
(dscloj/register-provider! :claude
{:provider :anthropic
:model "claude-3-5-sonnet-20241022"
:config {:api-key (System/getenv "ANTHROPIC_API_KEY")}})
;; Use registered providers
(dscloj/predict :gpt4 qa-module {:question "..."} :gpt4)
(dscloj/predict :gpt4 qa-module {:question "..."} :claude)
;; Option 2: Quick setup from environment
(dscloj/quick-setup!) ; Reads OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.
;; Option 3: Ad-hoc provider (no registration)
(dscloj/predict :gpt4 qa-module
{:question "..."}
{:provider :openai
:model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})DSCloj uses Malli specs for defining field types with automatic validation:
;; Simple specs
(def qa-module
{:inputs [{:name :question
:spec :string
:description "The question to answer"}]
:outputs [{:name :answer
:spec :string
:description "The answer"}]
:instructions "Provide concise and accurate answers."})
;; Complex specs with constraints
(def constrained-module
{:inputs [{:name :age
:spec [:int {:min 0 :max 150}]}
{:name :email
:spec [:string {:pattern #"^[^@]+@[^@]+$"}]}]
:outputs [{:name :message
:spec :string}]})
;; Reusable specs
(def QuestionSpec [:string {:min 1 :description "The question"}])
(def AnswerSpec [:string {:min 1 :description "The answer"}])
(def module-with-reusable-specs
{:inputs [{:name :question :spec QuestionSpec}]
:outputs [{:name :answer :spec AnswerSpec}]})
;; Setup provider
(dscloj/register-provider! :gpt4
{:provider :openai :model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})
;; Invalid inputs/outputs are automatically validated
(dscloj/predict :gpt4 qa-module
{:question 123} ; Throws validation error - should be string
:gpt4)
;; Disable validation if needed
(dscloj/predict :gpt4 qa-module
{:question "..."}
:gpt4
{:validate? false}) ; Skip validation (4th argument for options)Output fields can use composite Malli specs (:vector, :map, :map-of, :sequential, :set, :tuple). These are rendered as json typed fields in the prompt (with a JSON Schema derived from the Malli spec), parsed from the LLM's JSON response, and validated with the same spec:
(def extraction-module
{:inputs [{:name :transcript
:spec :string
:description "Meeting transcript window"}]
:outputs [{:name :action_items
:spec [:vector
[:map
[:description :string]
[:owner [:maybe :string]]
[:priority [:int {:min 0 :max 3}]]]]
:description "Action items mentioned in the transcript"}]
:instructions "Extract action items from the transcript."})
(def result (dscloj/predict :gpt4 extraction-module
{:transcript "Asha will send the proposal."}))
(:action_items result)
;; => [{:description "Send proposal" :owner "asha" :priority 1}]LLMs occasionally emit JSON that fails validation. Use the :retries option (default 0) to automatically re-prompt the LLM with the validation error appended as feedback:
(dscloj/predict :gpt4 extraction-module
{:transcript "..."}
{:retries 2}) ; up to 2 additional attempts on validation failureDSCloj supports streaming structured output with progressive parsing and validation:
(require '[dscloj.core :as dscloj]
'[clojure.core.async :refer [go-loop <!]])
;; Define a module with structured outputs
(def whales-module
{:inputs [{:name :query :spec :string}]
:outputs [{:name :markdown_output :spec :string}]
:instructions "Generate whale species information in markdown table format."})
;; Register provider
(dscloj/register-provider! :gpt4
{:provider :openai
:model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})
;; Stream predictions
(let [stream-ch (dscloj/predict-stream
whales-module
{:query "Tell me about 3 whale species."}
:gpt4 ; provider-config
{:debounce-ms 100})] ; options
;; Consume the stream progressively
(go-loop []
(when-let [parsed (<! stream-ch)]
(println "Received update:" parsed)
(recur))))Streaming Features:
- Progressive parsing as tokens arrive
- Malli validation on final output (optional during stream)
- Debouncing to control emission rate
- core.async channels for composability
- Optional callbacks for chunk processing
See examples/streaming_whales.clj for a complete example inspired by Pydantic AI's streaming example.
chain-of-thought is predict that reasons first. It prepends a reasoning
string field to your module's outputs, so the model writes its rationale before
the declared answer; the returned map includes :reasoning alongside your
outputs. Mirrors DSPy's ChainOfThought.
(dscloj/chain-of-thought :gpt4 qa-module {:question "Why is the sky blue?"})
;; => {:reasoning "Sunlight scatters off air molecules; shorter (blue) ..."
;; :answer "Because blue light scatters more than other colors."}Everything else — provider config, Malli specs, :validate?, :retries —
works exactly as in predict.
react runs a Reasoning + Acting loop on top of predict. The LLM
interleaves a thought, a tool choice, and tool arguments each turn; the chosen
tool runs and its observation is appended to a growing trajectory; the loop
ends when the model calls the built-in finish tool (or :max-iters turns
elapse). A final extraction then turns the trajectory into your module's
declared, Malli-typed outputs. No native LLM tool-calling is required — it
works on every provider predict supports.
(require '[dscloj.core :as dscloj])
;; The module describes the task and its final output(s); tools are how the
;; agent gathers or changes information along the way.
(def research-module
{:inputs [{:name :question :spec :string :description "The question to answer"}]
:outputs [{:name :answer :spec :string :description "The final answer"}]
:instructions "Answer the question, using tools to look things up."})
(def tools
[{:name "get_weather"
:description "Look up the current weather for a city."
:args [{:name :city :spec :string :description "City name"}]
:handler (fn [{:keys [city]}]
(str "The weather in " city " is sunny."))}])
(dscloj/register-provider! :gpt4
{:provider :openai :model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})
(def result
(dscloj/react :gpt4 research-module
{:question "What is the weather in Tokyo?"}
tools
{:max-iters 10}))
(:answer result) ;; => "It is sunny in Tokyo."
(:reasoning result) ;; => rationale from the final chain-of-thought extraction
(:stopped result) ;; => :finished (or :max-iters if the budget ran out)
(:iterations result) ;; => number of turns taken
(:trajectory result) ;; => the full thought/tool/observation transcriptThe final extraction uses chain-of-thought (matching DSPy), so the result also
carries a :reasoning key.
Tool maps have:
:name— the string the model selects by:description— what it does / when to use it:args— optional vector of field defs (:name/:spec/:description), same shape as module fields; the model emits these as a JSON object:handler—(fn [args-map] observation-string).args-mapkeys are keywords parsed from the model's JSON. The return value is shown to the model as the observation; a thrown exception is caught and its message becomes the observation, so the agent can see the error and recover.
Options: :max-iters (default 20), :on-step (a callback invoked after each
turn with {:iteration :thought :tool-name :tool-args :observation} — useful for
progress display), plus :validate?/:retries (applied to the final
extraction) and any LLM options.
Side-effecting tools are fully supported — a handler may create files, hit a database, etc. Because the handler is plain Clojure, any invariants you want to enforce (validation, authorization, path containment) live in your code, not in the model's hands.
One of the key benefits of the router API is easy provider switching:
;; Register multiple providers
(dscloj/register-provider! :simple-llm
{:provider :openai :model "gpt-4"
:config {:api-key (System/getenv "OPENAI_API_KEY")}})
(def openai-result (dscloj/predict :simple-llm qa-module {:question "What is AI?"}))
(dscloj/register-provider! :simple-llm
{:provider :anthropic :model "claude-haiku-4-5"
:config {:api-key (System/getenv "ANTHROPIC_API_KEY")}})
(def anthropic-result (dscloj/predict :simple-llm qa-module {:question "What is AI?"}))
;; Compare results
(println "OpenAI:" (:answer openai-result))
(println "Anthropic:" (:answer anthropic-result))
See examples/basic_usage.clj for:
- Provider registration and management
- Simple Q&A modules
- Translation with custom LLM options
- Multiple output types (bool, float, str)
- Complex Malli specs with constraints
- Validation and error handling
- Spec reusability
- Disabling validation
- Inspecting generated prompts
- Switching between providers
See examples/streaming_whales.clj for:
- Streaming structured output
- Progressive parsing with Malli specs
- Real-time data display
- Handling optional fields during streaming
- core.async channel usage
DSCloj uses litellm-clj and supports:
- OpenAI - GPT-3.5, GPT-4, GPT-4o
- Anthropic - Claude 3.5 (Sonnet, Haiku), Claude 3 (Opus)
- Google - Gemini Pro, Gemini Pro Vision
- Mistral - Mistral Small/Medium/Large, Codestral
- OpenRouter - All OpenRouter models
- Ollama - Local models
- And many more...
This project is licensed under the MIT License - see the LICENSE file for details.
- DSPy - The original Python library that inspired this port
- litellm-clj - Unified LLM interface for Clojure