{"id":46111929,"url":"https://github.com/blockether/svar","last_synced_at":"2026-06-07T12:02:21.827Z","repository":{"id":339008138,"uuid":"1157135843","full_name":"Blockether/svar","owner":"Blockether","description":"Type‑safe LLM output for Clojure. Works with any text‑only model.","archived":false,"fork":false,"pushed_at":"2026-06-01T14:46:35.000Z","size":7032,"stargazers_count":13,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-01T16:26:36.209Z","etag":null,"topics":["ai","clojure","llm","structured-output"],"latest_commit_sha":null,"homepage":"","language":"Clojure","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Blockether.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-02-13T13:25:42.000Z","updated_at":"2026-06-01T14:57:49.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Blockether/svar","commit_stats":null,"previous_names":["blockether/svar"],"tags_count":42,"template":false,"template_full_name":null,"purl":"pkg:github/Blockether/svar","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Blockether%2Fsvar","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Blockether%2Fsvar/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Blockether%2Fsvar/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Blockether%2Fsvar/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Blockether","download_url":"https://codeload.github.com/Blockether/svar/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Blockether%2Fsvar/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34020187,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-07T02:00:07.652Z","response_time":124,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai","clojure","llm","structured-output"],"created_at":"2026-03-01T22:34:45.193Z","updated_at":"2026-06-07T12:02:21.820Z","avatar_url":"https://github.com/Blockether.png","language":"Clojure","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003ch2 align=\"center\"\u003e\n  \u003cimg width=\"40%\" alt=\"SVAR logo\" src=\"logo.png\"\u003e\u003cbr/\u003e\n\u003c/h2\u003e\n\n\u003cdiv align=\"center\"\u003e\n\u003ci\u003esvar\u003c/i\u003e — \"answer\" in Swedish. Type-safe LLM output for Clojure, inspired by \u003ca href=\"https://github.com/BoundaryML/baml\"\u003eBAML\u003c/a\u003e.\n\u003cbr/\u003e\n\u003csub\u003eWorks with any text-producing LLM — no structured output support required.\u003c/sub\u003e\n\u003c/div\u003e\n\n\u003cdiv align=\"center\"\u003e\n  \u003ch2\u003e\n    \u003ca href=\"https://clojars.org/com.blockether/svar\"\u003e\u003cimg src=\"https://img.shields.io/clojars/v/com.blockether/svar?color=%23007ec6\u0026label=clojars\" alt=\"Clojars version\"\u003e\u003c/a\u003e\n    \u003ca href=\"https://github.com/Blockether/svar/blob/main/LICENSE\"\u003e\n      \u003cimg src=\"https://img.shields.io/badge/license-Apache%202.0-green\" alt=\"License - Apache 2.0\"\u003e\n    \u003c/a\u003e\n  \u003c/h2\u003e\n\u003c/div\u003e\n\n\u003cdiv align=\"center\"\u003e\n\u003ch3\u003e\n\n[Rationale](#rationale) • [Functionalities](#functionalities) • [Quick Start](#quick-start) • [Router](#router) • [Usage](#usage) • [Spec DSL](#spec-dsl-reference)\n\n\u003c/h3\u003e\n\u003c/div\u003e\n\n## Rationale\n\nJSON Schema is the de facto way to get structured output from LLMs — but it's incomplete. Union types have poor cross-provider support, and the entire approach requires your LLM provider to support structured output mode. That rules out local models, smaller providers, and any setup where you just have a text completion endpoint.\n\nSVAR takes a different approach: let the LLM produce plain text, then parse and correct the output post-step. This works with **any** text-producing LLM — OpenAI, Anthropic, local Ollama, vLLM, whatever you have. No provider lock-in, no feature flags, no \"this model doesn't support JSON mode\" surprises.\n\n## Functionalities\n\n| Category | Functions | Description |\n|----------|-----------|-------------|\n| [**Router**](#router) | `make-router`, `router-stats`, `reset-budget!`, `reset-provider!` | Multi-provider routing with circuit breakers, cost budgets, automatic fallback. The entry point to the library. |\n| [**Structured Output**](#schemaless-adaptive-parsing-ask) | `ask!` | LLM → validated Clojure map via spec. Works with any text-producing LLM — SAP parser handles malformed JSON, unquoted keys, trailing commas, markdown blocks. Supports [streaming](#streaming) via `:on-chunk`. Token counting + cost estimation via JTokkit. |\n| [**Code Output**](#code-output-ask-code) | `ask-code!`, `extract-code-blocks` | Plain-text completion + fenced code-block extraction. Filters by `:lang`, keeps untagged fences, concatenates matching blocks, exposes raw text + parsed blocks. Supports [streaming](#streaming) via `:on-chunk`. |\n| [**Spec DSL**](#spec-dsl-reference) | `spec`, `field`, `spec-\u003eprompt`, `validate-data` | Define output shapes: types, enums, refs, optional fields, namespaced keys, fixed-size vectors. |\n| [**Parsing**](#parsing--validation) | `str-\u003edata`, `str-\u003edata-with-spec`, `data-\u003estr` | Schemaless and spec-validated JSON↔Clojure. Handles malformed JSON out of the box. |\n| [**Models**](#available-models-models) | `models!` | List available models from your provider. |\n\n## Quick Start\n\n```clojure lazytest/skip=true\n;; deps.edn\n{:deps {com.blockether/svar {:mvn/version \"0.7.7\"}}}\n```\n\n```clojure\n(require '[com.blockether.svar.core :as svar])\n\n;; Create a router — the single entry point for all LLM calls.\n;; Every function takes the router as its first argument.\n(comment\n  (def router (svar/make-router [{:id :openai\n                                  :api-key (System/getenv \"OPENAI_API_KEY\")\n                                  :models [{:name \"gpt-4o\"}]}])))\n```\n\n## Router\n\nThe router is the single entry point for all LLM calls. Create it once at boot, pass it to every function. It handles provider selection, circuit breaking, cost budgets, and automatic fallback.\n\n### Basic Setup\n\n```clojure\n(comment\n  ;; Single provider\n  (def router\n    (svar/make-router [{:id :openai\n                        :api-key (System/getenv \"OPENAI_API_KEY\")\n                        :models [{:name \"gpt-4o\"}\n                                 {:name \"gpt-4o-mini\"}]}])))\n```\n\n### Multi-Provider with Fallback\n\nVector order = priority. If the first provider fails (rate limit, outage), the router automatically falls back to the next:\n\n```clojure\n(comment\n  ;; First provider is preferred; second is fallback\n  (def router\n    (svar/make-router\n      [{:id :anthropic\n        :api-key (System/getenv \"ANTHROPIC_API_KEY\")\n        :models [{:name \"claude-sonnet-4-20250514\"}]}\n       {:id :openai\n        :api-key (System/getenv \"OPENAI_API_KEY\")\n        :models [{:name \"gpt-4o\"}]}])))\n```\n\n### Cost Budgets \u0026 Circuit Breakers\n\n```clojure\n(comment\n  ;; Spend limits + circuit breaker tuning\n  (def router\n    (svar/make-router\n      [{:id :openai\n        :api-key (System/getenv \"OPENAI_API_KEY\")\n        :models [{:name \"gpt-4o\"} {:name \"gpt-4o-mini\"}]}]\n      {:budget {:max-tokens 1000000 :max-cost 5.0}  ;; hard spend cap\n       :failure-threshold 5                          ;; failures before circuit opens\n       :recovery-ms 60000})))                        ;; ms before retry after open\n```\n\n### Routing Options\n\nEvery `ask!` call accepts `:routing` to control provider/model selection:\n\n```clojure\n(comment\n  ;; Let the router pick the cheapest model\n  (svar/ask! router {:spec my-spec\n                     :messages [(svar/user \"...\")]\n                     :routing {:optimize :cost}})\n\n  ;; Or the most capable\n  (svar/ask! router {:spec my-spec\n                     :messages [(svar/user \"...\")]\n                     :routing {:optimize :intelligence}})\n\n  ;; Pin to a specific provider + model\n  (svar/ask! router {:spec my-spec\n                     :messages [(svar/user \"...\")]\n                     :routing {:provider :openai :model \"gpt-4o-mini\"}}))\n```\n\n### Observability\n\n```clojure\n(comment\n  ;; Cumulative + windowed stats per provider:\n  ;;   :total      - {:requests N :tokens N}\n  ;;   :providers  - per-provider circuit-breaker state, windowed + cumulative stats\n  ;;   :budget     - {:limit ... :spent {:total-tokens N :total-cost N}}\n  (svar/router-stats router)\n\n  ;; Reset spend counters (e.g. start of billing cycle)\n  (svar/reset-budget! router)\n\n  ;; Manually close a circuit breaker after provider recovers\n  (svar/reset-provider! router :openai))\n```\n\n## Usage\n\n### API styles\n\nsvar now uses explicit transport names for OpenAI-compatible providers:\n\n- `:openai-compatible-chat` → `/chat/completions`\n- `:openai-compatible-responses` → `/responses`\n- `:anthropic` → `/messages`\n\nKnown provider profiles choose the right transport for you. For custom providers, set `:api-style` explicitly:\n\n```clojure\n(comment\n  (def router\n    (svar/make-router\n      [{:id :my-openai-gateway\n        :api-key (System/getenv \"MY_GATEWAY_API_KEY\")\n        :base-url \"https://gateway.example.com/v1\"\n        :api-style :openai-compatible-chat\n        :models [{:name \"gpt-4o\"}]}\n       {:id :my-responses-gateway\n        :api-key (System/getenv \"MY_RESPONSES_API_KEY\")\n        :base-url \"https://gateway.example.com/v1\"\n        :api-style :openai-compatible-responses\n        :models [{:name \"gpt-5.5\"}]}])))\n```\n\n### Message Helpers\n\nBuild message vectors for LLM interactions with `system`, `user`, `assistant`, and `image`:\n\n```clojure\n(svar/system \"You are a helpful assistant.\")\n;; =\u003e {:role \"system\", :content \"You are a helpful assistant.\"}\n\n(svar/user \"What is 2+2?\")\n;; =\u003e {:role \"user\", :content \"What is 2+2?\"}\n\n(svar/assistant \"The answer is 4.\")\n;; =\u003e {:role \"assistant\", :content \"The answer is 4.\"}\n\n(svar/image \"iVBORw0KGgo=\")\n;; =\u003e {:svar/type :image, :base64 \"iVBORw0KGgo=\", :media-type \"image/png\"}\n\n(svar/image \"iVBORw0KGgo=\" \"image/jpeg\")\n;; =\u003e {:svar/type :image, :base64 \"iVBORw0KGgo=\", :media-type \"image/jpeg\"}\n\n;; URLs are also supported — passed through directly to the LLM API\n(svar/image \"https://example.com/photo.jpg\")\n;; =\u003e {:svar/type :image, :url \"https://example.com/photo.jpg\"}\n\n;; Multimodal: user message with base64 image attachment\n(svar/user \"Describe this\" (svar/image \"iVBORw0KGgo=\" \"image/jpeg\"))\n;; =\u003e {:role \"user\", :content [{:type \"image_url\", :image_url {:url \"data:image/jpeg;base64,iVBORw0KGgo=\"}} {:type \"text\", :text \"Describe this\"}]}\n\n;; Multimodal: user message with URL image — no data URI wrapping\n(svar/user \"What's in this image?\" (svar/image \"https://example.com/photo.jpg\"))\n;; =\u003e {:role \"user\", :content [{:type \"image_url\", :image_url {:url \"https://example.com/photo.jpg\"}} {:type \"text\", :text \"What's in this image?\"}]}\n```\n\n### Schemaless Adaptive Parsing (`ask!`)\n\nSVAR doesn't require your LLM to support structured output mode. Instead, `ask!` sends a spec-generated prompt that instructs the LLM to respond in JSON, then parses the response with SAP (Schemaless Adaptive Parsing) — a Java-based parser that handles malformed JSON, unquoted keys, trailing commas, markdown code blocks, and more. This means `ask!` works with **any** text-producing LLM.\n\n```clojure\n(def person-spec\n  (svar/spec\n    (svar/field svar/NAME :name\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Full name\")\n    (svar/field svar/NAME :age\n                svar/TYPE svar/TYPE_INT\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Age in years\")))\n\n(comment\n  (def ask-result\n    (svar/ask! router {:spec person-spec\n                       :messages [(svar/system \"Extract person information from the text.\")\n                                  (svar/user \"John Smith is a 42-year-old engineer.\")]\n                       :model \"gpt-4o\"}))\n\n  (:result ask-result)\n  ;; =\u003e {:name \"John Smith\", :age 42}\n  )\n```\n\nUnder the hood, `spec-\u003eprompt` translates the spec into a schema the LLM can follow:\n\n```clojure lazytest/skip=true\n(println (svar/spec-\u003eprompt person-spec))\n;; Answer in JSON using this schema:\n;; {\n;;   // Full name (required)\n;;   name: string,\n;;   // Age in years (required)\n;;   age: int,\n;; }\n```\n\nReturns `{:result \u003cdata\u003e :tokens {:input N :output N :total N} :cost {:input-cost N :output-cost N :total-cost N} :duration-ms N}`.\n\n### Code Output (`ask-code!`)\n\nUse `ask-code!` when you want source text, not JSON. svar asks for plain text, extracts fenced code blocks, filters by `:lang`, keeps untagged fences as matches for any language, and concatenates the selected blocks into `:result`.\n\n```clojure\n(svar/extract-code-blocks \"Before\\n```clojure\\n(+ 1 1)\\n```\\nAfter\")\n;; =\u003e [{:lang \"clojure\", :source \"(+ 1 1)\"}]\n\n(svar/extract-code-blocks \"(println \\\"no fence\\\")\")\n;; =\u003e [{:lang nil, :source \"(println \\\"no fence\\\")\"}]\n```\n\n```clojure\n(comment\n  (def code-result\n    (svar/ask-code! router\n      {:messages [(svar/system \"Reply with Clojure code only.\")\n                  (svar/user \"Write a function `square`.\")]\n       :model \"gpt-4o\"\n       :lang \"clojure\"}))\n\n  (:result code-result)\n  ;; =\u003e \"(defn square [x] (* x x))\"\n\n  (:blocks code-result)\n  ;; =\u003e [{:lang \"clojure\", :source \"(defn square [x] (* x x))\"}]\n  )\n```\n\nReturns `{:result \u003csource\u003e :blocks [{:lang \u003cstr-or-nil\u003e :source \u003cstr\u003e} ...] :raw \u003cfull-assistant-text\u003e :reasoning \u003cprovider-reasoning-when-present\u003e :tokens {:input N :output N :reasoning N :total N} :cost {:input-cost N :output-cost N :total-cost N} :duration-ms N}`.\n\n`ask-code!` accepts the same routing, reasoning, verbosity, network, and streaming controls as `ask!`, minus the structured-output-only knobs (`:spec`, `:format-retries`, `:format-retry-on`, `:json-object-mode?`).\n\n### Reasoning depth and output verbosity\n\nTwo knobs, different jobs:\n\n- `:reasoning` = how hard the model thinks before answering. Use `:quick`, `:balanced`, or `:deep`.\n- `:verbosity` = how verbose the visible answer should be. Use `:low`, `:medium`, or `:high`.\n\nThey are independent. Example: `:reasoning :deep` + `:verbosity :low` means think hard, answer briefly.\n\n```clojure\n(comment\n  (svar/ask! router\n    {:spec person-spec\n     :messages [(svar/system \"Extract person info.\")\n                (svar/user \"John Smith is a 42-year-old engineer.\")]\n     :model \"gpt-5.5\"\n     :reasoning :deep\n     :verbosity :low}))\n\n(comment\n  (svar/ask-code! router\n    {:messages [(svar/user \"Write a compact Clojure fn that squares a number.\")]\n     :model \"gpt-5.5\"\n     :lang \"clojure\"\n     :reasoning :balanced\n     :verbosity :low}))\n```\n\n`:reasoning` is provider-agnostic — svar translates it to the right wire shape for the selected model. `:verbosity` is honored on providers that expose a visible-output verbosity control (notably OpenAI Responses-style endpoints such as `:openai-codex`) and ignored elsewhere.\n\n### Provider-noise hardening (`:format-retries`, `:json-object-mode?`, `:on-format-error`)\n\nSome providers — notably the GLM family (`glm-5.1`, `glm-4.7`, ...) under\n`:reasoning :deep` — occasionally emit a bare prose string in `content`\ninstead of the schema-conformant JSON object svar's spec asks for. The\nresponse looks like `\"Looking at the request, I think...\"` with the actual\nthinking dumped into `content` past the reasoning channel. svar rejects\nloudly with `:svar.spec/schema-rejected`, but if every rejection bubbles\nup to your agent loop you pay for provider noise out of your iteration\nbudget and lose post-mortem signal.\n\nThree opt-in tools absorb the noise inside a single `ask!` call:\n\n```clojure\n(comment\n  (svar/ask! router\n    {:spec my-spec\n     :messages [(svar/user \"...\")]\n     :model \"glm-5.1\"\n     :reasoning :deep\n     :format-retries 2                        ;; retry locally on schema-rejected\n     :json-object-mode? true                  ;; auto-on for GLM — explicit override\n     :on-format-error :fallback-provider}))   ;; if model is broken, try next\n```\n\n**`:format-retries N`** — when the provider returns content that fails\nschema parsing, svar appends a tiny FORMAT-RETRY turn (the model's bad\nresponse + a short corrective instruction) and re-calls the provider up\nto N times. Tokens for the bad attempts are still billed (the provider\nproduced them) but the caller sees one logical `ask!` call. Each attempt\nis recorded in `:format-attempts` on success or in the terminal\nexception's ex-data. Streaming (`:on-chunk`) forces retries to 0.\n\n**`:json-object-mode?`** — on `:openai-compatible-chat` api-style providers, injects\n`response_format: {type: \"json_object\"}` into the request body. GLM\nmodels (`glm-5.1`, `glm-4.7`, `glm-5-turbo`, `glm-4.6`, `glm-4.6v`) are\nopted in by default across `:zai` and `:zai-coding`\nproviders. Caller's `:extra-body :response_format` always wins; pass\n`:json-object-mode? false` to opt out a flagged model.\n\n**`:on-format-error :fallback-provider`** — if the chosen model fails\nformat parsing, treat it as a transient error and try the next provider\nin the fleet, excluding the offender. When all providers fail, svar\nthrows the LAST format error's full envelope with `:routed/trace`\nand `:format-failed` merged into ex-data. Default is `:fail`.\n\n#### Forensic envelope on every error\n\nAny exception thrown from `ask!` carries the full call context in\n`ex-data` — no truncation:\n\n```clojure\n(comment\n  (try (svar/ask! router opts)\n    (catch clojure.lang.ExceptionInfo e\n      (let [d (ex-data e)]\n        (:type d)            ;; :svar.spec/schema-rejected, :svar.llm/empty-content, ...\n        (:model d)           ;; \"glm-5.1\"\n        (:api-style d)       ;; :openai-compatible-chat\n        (:chat-url d)        ;; \"https://llm.blockether.com/v1/chat/completions\"\n        (:duration-ms d)     ;; 14696.749\n        (:api-usage d)       ;; provider tokens\n        (:reasoning d)       ;; full reasoning_content (or nil)\n        (:content d)         ;; FULL untruncated content of the last attempt\n        (:http-response d)   ;; {:parsed :raw-body :url :status}\n        (:format-attempts d) ;; vec of every attempt with full content/reasoning\n        ))))\n```\n\nUse this to persist the failing call into your DB / display it in a TUI /\nreproduce it without re-invoking the LLM.\n\n### Parsing \u0026 Validation\n\nSVAR works with any text-producing LLM because parsing happens post-step. The SAP parser handles malformed JSON out of the box:\n\n```clojure\n;; Spec-validated parsing — handles clean and malformed JSON\n(svar/str-\u003edata-with-spec \"{\\\"name\\\": \\\"John Smith\\\", \\\"age\\\": 42}\" person-spec)\n;; =\u003e {:name \"John Smith\", :age 42}\n\n(svar/str-\u003edata-with-spec \"{name: \\\"John Smith\\\", age: 42,}\" person-spec)\n;; =\u003e {:name \"John Smith\", :age 42}\n\n;; Schemaless parsing — no spec needed\n(svar/str-\u003edata \"{\\\"city\\\": \\\"Paris\\\", \\\"population\\\": 2161000}\")\n;; =\u003e {:value {:city \"Paris\", :population 2161000}, :warnings []}\n\n;; Serialize Clojure data to JSON\n(svar/data-\u003estr {:name \"John\" :age 42})\n;; =\u003e \"{\\\"name\\\":\\\"John\\\",\\\"age\\\":42}\"\n\n;; Validate parsed data against a spec\n(svar/validate-data person-spec {:name \"John Smith\" :age 42})\n;; =\u003e {:valid? true}\n\n(svar/validate-data person-spec {:name \"John Smith\"})\n;; =\u003e {:valid? false, :errors [{:error :missing-required-field, :field :age, :path \"age\"}]}\n```\n\n### Available Models (`models!`)\n\nLists all models available from your LLM provider.\n\n```clojure\n(comment\n  (def models (svar/models! router))\n\n  ;; Every model has an :id field\n  (every? :id models)\n  ;; =\u003e true\n  )\n```\n\n### Spec DSL Reference\n\nThe spec DSL defines the shape of LLM output. Every field has a name, type, cardinality, and description.\n\n#### All Types\n\n| Constant | Type |\n|----------|------|\n| `TYPE_STRING` | String |\n| `TYPE_INT` | Integer |\n| `TYPE_FLOAT` | Float |\n| `TYPE_BOOL` | Boolean |\n| `TYPE_DATE` | ISO date (YYYY-MM-DD) |\n| `TYPE_DATETIME` | ISO datetime |\n| `TYPE_KEYWORD` | Clojure keyword (rendered as string, keywordized on parse) |\n| `TYPE_REF` | Reference to another spec |\n| `TYPE_INT_V_1` … `TYPE_INT_V_12` | Fixed-size integer vectors (1–12 elements) |\n| `TYPE_STRING_V_1` … `TYPE_STRING_V_12` | Fixed-size string vectors |\n| `TYPE_DOUBLE_V_1` … `TYPE_DOUBLE_V_12` | Fixed-size double vectors |\n\n#### Keyword Type (`TYPE_KEYWORD`)\n\nString values automatically become Clojure keywords on parse — useful for status codes, categories, and enum-like fields that you want as keywords in your code:\n\n```clojure\n(def status-spec\n  (svar/spec\n    (svar/field svar/NAME :status\n                svar/TYPE svar/TYPE_KEYWORD\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Current status\")))\n\n;; String \"active\" in JSON becomes keyword :active in Clojure\n(svar/str-\u003edata-with-spec \"{\\\"status\\\": \\\"active\\\"}\" status-spec)\n;; =\u003e {:status :active}\n```\n\n#### Enums (`VALUES`)\n\nWhen a field should only contain one of a fixed set of values — status codes, categories, severity levels — use `VALUES` with a map of `{value description}`. The descriptions are included in the LLM prompt so it understands what each value means, which dramatically improves output accuracy:\n\n```clojure\n(def sentiment-spec\n  (svar/spec\n    (svar/field svar/NAME :sentiment\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Sentiment classification\"\n                svar/VALUES {\"positive\" \"Favorable or optimistic tone\"\n                             \"negative\" \"Unfavorable or critical tone\"\n                             \"neutral\" \"Balanced or factual tone\"})\n    (svar/field svar/NAME :confidence\n                svar/TYPE svar/TYPE_FLOAT\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Confidence score from 0.0 to 1.0\")))\n\n;; Valid enum value passes\n(svar/validate-data sentiment-spec {:sentiment \"positive\" :confidence 0.95})\n;; =\u003e {:valid? true}\n\n;; Invalid enum value caught\n(:valid? (svar/validate-data sentiment-spec {:sentiment \"happy\" :confidence 0.8}))\n;; =\u003e false\n```\n\n#### Optional Fields (`REQUIRED`)\n\nFields are required by default — the LLM must provide a value. Set `REQUIRED false` when a field might legitimately be absent (e.g., a phone number the source text doesn't mention). Optional fields parse as `nil` when missing, and validation passes without them:\n\n```clojure\n(def contact-spec\n  (svar/spec\n    (svar/field svar/NAME :name\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Full name\")\n    (svar/field svar/NAME :phone\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/REQUIRED false\n                svar/DESCRIPTION \"Phone number if available\")))\n\n;; Validation passes without optional fields\n(svar/validate-data contact-spec {:name \"Jane Doe\"})\n;; =\u003e {:valid? true}\n\n;; But fails without required fields\n(svar/validate-data contact-spec {:phone \"555-1234\"})\n;; =\u003e {:valid? false, :errors [{:error :missing-required-field, :field :name, :path \"name\"}]}\n```\n\n#### Collections (`CARDINALITY_MANY`)\n\nWhen a field holds multiple values — tags, authors, line items — use `CARDINALITY_MANY`. The LLM returns a JSON array, parsed as a Clojure vector:\n\n```clojure\n(def article-spec\n  (svar/spec\n    (svar/field svar/NAME :title\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Article title\")\n    (svar/field svar/NAME :tags\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_MANY\n                svar/DESCRIPTION \"List of tags\")))\n\n;; Arrays are parsed as Clojure vectors\n(svar/str-\u003edata-with-spec \"{\\\"title\\\": \\\"SVAR Guide\\\", \\\"tags\\\": [\\\"clojure\\\", \\\"llm\\\", \\\"parsing\\\"]}\" article-spec)\n;; =\u003e {:title \"SVAR Guide\", :tags [\"clojure\" \"llm\" \"parsing\"]}\n\n(svar/validate-data article-spec {:title \"SVAR Guide\" :tags [\"clojure\" \"llm\"]})\n;; =\u003e {:valid? true}\n```\n\n#### Nested Specs (`TYPE_REF` / `TARGET`)\n\nWhen your LLM output has nested objects — a company with an address, an order with line items — you define each sub-object as its own named spec, then reference it with `TYPE_REF` + `TARGET`. This keeps specs composable and reusable: define `Address` once, reference it from `Company`, `Person`, `Order`, etc.\n\nPass referenced specs via `{:refs [address-spec]}` so the prompt generator and parser know how to handle them. Combine with `CARDINALITY_MANY` for arrays of nested objects (e.g., branch offices).\n\n```clojure\n(def address-spec\n  (svar/spec :Address\n    (svar/field svar/NAME :street\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Street address\")\n    (svar/field svar/NAME :city\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"City name\")))\n\n(def company-spec\n  (svar/spec\n    {:refs [address-spec]}\n    (svar/field svar/NAME :name\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Company name\")\n    (svar/field svar/NAME :headquarters\n                svar/TYPE svar/TYPE_REF\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/TARGET :Address\n                svar/DESCRIPTION \"HQ address\")\n    (svar/field svar/NAME :branches\n                svar/TYPE svar/TYPE_REF\n                svar/CARDINALITY svar/CARDINALITY_MANY\n                svar/TARGET :Address\n                svar/DESCRIPTION \"Branch office addresses\")))\n\n;; Parse nested JSON — refs become nested maps/vectors automatically\n(svar/str-\u003edata-with-spec\n  \"{\\\"name\\\": \\\"Acme Corp\\\", \\\"headquarters\\\": {\\\"street\\\": \\\"123 Main St\\\", \\\"city\\\": \\\"SF\\\"}, \\\"branches\\\": [{\\\"street\\\": \\\"456 Oak Ave\\\", \\\"city\\\": \\\"LA\\\"}]}\"\n  company-spec)\n;; =\u003e {:name \"Acme Corp\", :headquarters {:street \"123 Main St\", :city \"SF\"}, :branches [{:street \"456 Oak Ave\", :city \"LA\"}]}\n\n;; Ref registry maps spec names to their definitions\n(vec (keys (svar/build-ref-registry company-spec)))\n;; =\u003e [:Address]\n```\n\n#### Namespaced Keys (`KEY-NS`)\n\nWhen LLM output maps directly to Datomic/Datalevin entities, you want namespaced keys (`:page.node/type` instead of `:type`). `KEY-NS` adds a namespace prefix to all keys during parsing, so you can transact LLM results straight into your database without manual key transformation.\n\nThis is especially useful when multiple specs share field names like `:type` or `:id` — namespacing disambiguates them.\n\n```clojure\n(def node-spec\n  (svar/spec :node\n    {svar/KEY-NS \"page.node\"}\n    (svar/field svar/NAME :type\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Node type\")\n    (svar/field svar/NAME :content\n                svar/TYPE svar/TYPE_STRING\n                svar/CARDINALITY svar/CARDINALITY_ONE\n                svar/DESCRIPTION \"Text content\")))\n\n;; Keys are automatically namespaced — ready for d/transact!\n(svar/str-\u003edata-with-spec \"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": \\\"Introduction\\\"}\" node-spec)\n;; =\u003e {:page.node/type \"heading\", :page.node/content \"Introduction\"}\n```\n\n### Streaming\n\nPass `:on-chunk` to `ask!` to enable SSE streaming. The callback fires with partially-parsed results as they arrive, then a final call with `:done? true` including token counts and cost:\n\n```clojure\n(comment\n  ;; Stream structured output — callback receives progressive partial results\n  (svar/ask! router\n    {:spec person-spec\n     :messages [(svar/system \"Extract person info.\")\n                (svar/user \"John Smith is a 42-year-old engineer.\")]\n     :model \"gpt-4o\"\n     :on-chunk (fn [{:keys [result reasoning tokens cost done?]}]\n                (if done?\n                  (println \"Final:\" result \"cost:\" (:total-cost cost))\n                  (println \"Partial:\" result)))}))\n```\n\nCallback shape:\n\n| Key | While streaming | Final (`:done? true`) |\n|-----|----------------|----------------------|\n| `:result` | Best-effort partial parse | Fully validated + coerced |\n| `:reasoning` | Accumulated reasoning text | Full reasoning |\n| `:tokens` | `nil` | `{:input N :output N :reasoning N :total N}` |\n| `:cost` | `nil` | `{:input-cost N :output-cost N :total-cost N}` |\n| `:done?` | `false` | `true` |\n\nStreaming works with all routing options — `:optimize`, provider pinning, fallback:\n\n```clojure\n(comment\n  (svar/ask! router\n    {:spec person-spec\n     :messages [(svar/user \"...\")]\n     :routing {:optimize :cost}\n     :on-chunk (fn [{:keys [result done?]}]\n                (when done? (println result)))}))\n```\n\n### `spec-\u003eprompt`\n\n`ask!` auto-generates the LLM prompt from your spec. You can inspect what gets sent:\n\n```clojure lazytest/skip=true\n(println (svar/spec-\u003eprompt company-spec))\n;; Answer in JSON using this schema:\n;; Address {\n;;   // Street address (required)\n;;   street: string,\n;;   // City name (required)\n;;   city: string,\n;; }\n;;\n;; {\n;;   // Company name (required)\n;;   name: string,\n;;   // HQ address (required)\n;;   headquarters: Address,\n;;   // Branch office addresses (required)\n;;   branches: Address[],\n;; }\n```\n\n## Further reading\n\n- [`CHANGELOG.md`](CHANGELOG.md) — version history\n\n## License\n\nApache License 2.0 — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fblockether%2Fsvar","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fblockether%2Fsvar","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fblockether%2Fsvar/lists"}