Liking cljdoc? Tell your friends :D

hive-providers

OAuth 2.0 and OpenAI-compatible LLM providers for Clojure harnesses, in one self-contained .cljc library that reads on every major Clojure dialect.

  • OAuth: PKCE authorization-code flow (RFC 7636), refresh and client-credentials grants (RFC 6749), device authorization (RFC 8628), JWT claim reading, a token authority that decides reuse vs refresh, and the credential files of Claude Code, the codex CLI and Kimi Code.
  • OpenAI-compatible chat: a registry of 30+ providers with their quirks (auth header schemes, URL templates, field allowlists, tool-incompatible models, reasoning fields), request building, response and SSE stream parsing with tool-call reassembly, retry policy, and an IChatProvider protocol for harnesses to depend on.

JSON, base64, URL encoding, UTF-8 and SHA-256 are portable Clojure in src/. The two dependencies are portable .cljc as well:

  • hive-dsl: Results are hive-dsl Results ({:ok v} / {:error cat ...}, railway-oriented), and the closed sums in the flows are hive-dsl ADTs (oauth.authority/Decision, oauth.device/PollOutcome, openai.retry/Step), matched exhaustively with adt-case.
  • malli: hive-providers.schema describes every boundary value (HTTP request/response, OAuth client and token, PKCE bundle, device authorization, registry entry, chat params, message). hive-providers.contracts registers function schemas for the public API, so malli.instrument can check a running harness.

hive-system is provided scope. Only hive-providers.host.hive-system needs it, and it is not in :deps: a harness that already runs hive-system (hive-agent, hive-mcp) gets pass:-backed secret refs and hive-system's file lock for the single-flight refresh. Every other namespace loads without it. Clojure loads per namespace but resolves dependencies per artifact, so keeping hive-system out of :deps is what keeps datahike, tink and the rest out of every consumer's classpath.

Design: effects as data

No flow performs I/O. Each one is a program: a value that is either done or asks the host for one operation and says how to continue (hive-providers.effect). A harness runs a program with a map of handlers:

(require '[hive-providers.effect :as fx]
         '[hive-providers.host.jvm :as jvm]
         '[hive-providers.openai.chat :as chat]
         '[hive-providers.openai.registry :as reg])

(def provider (chat/provider reg/seed (jvm/handlers)))

(chat/chat provider {:model "groq:llama-3.3-70b-versatile"
                     :messages [{:role "user" :content "hello"}]})
;; => {:ok {:role "assistant" :content "..." :usage {...} :model "..."}}

Ops: :http/request, :clock/now-ms, :entropy/bytes, :sleep/ms, :env/get, :secret/get, :store/load, :store/save. A registry entry may name :api-key-ref ("pass:Venice/key", "env:X"); the secret store answers before the environment, so a stale shell variable cannot shadow the canonical key. The same ports exist as protocols (hive-providers.ports) with stub records for tests, so a harness depends on abstractions and injects concretions (DIP). Failures are values: {:ok v} or {:error :category ...}, the same shape as hive-dsl.

Host adapters:

HostNamespaceNotes
JVM, Babashkahive-providers.host.jvmjava.net.http, SecureRandom, atomic owner-only JSON files, single-flight per-account lock (plus OS file lock)
Node.js 18+hive-providers.host.nodefetch with chunked SSE reading, crypto.randomBytes, async handlers for fx/run-async
JVM + hive-systemhive-providers.host.hive-systemprovided scope: pass: secrets via hive-system's registry, single flight under hive-system's fs-lock!
any otheryour handlersa map of op to fn; nothing else is required

OAuth example: share Claude Code's login

(require '[hive-providers.oauth.authority :as auth]
         '[hive-providers.oauth.clients :as clients]
         '[hive-providers.oauth.vendor :as vendor]
         '[hive-providers.result :as r])

(def h (jvm/handlers {:stores {:claude (str (jvm/home-path ".claude" ".credentials.json"))}}))

(jvm/single-flight h :claude
  (auth/access-program (r/value (clients/client :anthropic/claude-ai)) :claude
                       {:decode vendor/claude->token
                        :encode vendor/claude-file-encode}))
;; => {:ok {:access-token "..." :expires-at-ms ...}}

The token is refreshed only when it is near expiry, written back in Claude Code's own file shape, and concurrent callers on one account refresh once.

Dialects

bin/test-dialects runs the suite on every runtime it finds.

DialectRuntimeStatus
Clojure (JVM)clojurefull suite + JVM host
Babashkabbfull suite + JVM host
ClojureScriptclojure -M:cljs-test on Nodefull suite + Node host (real fetch stream)
ClojureWasmcljwfull portable suite
clojurustcljrsfull portable suite
Basilispbasilisp test (with pytest)full portable suite
ClojureElispclel + Emacscodecs, SHA-256, PKCE, JSON writing verified in Emacs; see below
ClojureCLR, ClojureDartuntestedno runtime here; the sources use no JVM interop outside platform and host/

hive-providers.platform is the only namespace that branches per dialect. Everything else is plain clojure.core, written to the common subset the runtimes above agree on: binary bit ops (ClojureWasm), no recur inside case (Basilisp), no regex literals (hive-providers.text splits and validates instead), booleans tested with boolean? (clojurust), no calls through let-bound fns and no t local (ClojureElisp).

ClojureElisp has no reader conditionals, so bin/emit-dialect cljel resolves them into target/cljel/**.cljel, compiles each namespace with clel, and loads the result in a batch Emacs with test-cljel/hive-providers-cljel-prelude.el (aliases for clojure.core fns the clel runtime has but its compiler does not map yet). Known clel gaps, reported by the smoke run rather than hidden: keyword namespaces are dropped, a core fn passed as a value compiles to a variable reference, keyword invocation fails in some positions, and openai.chat does not compile.

Development

bb test                      # fastest loop
clojure -M:test              # JVM
bin/test-dialects            # every installed dialect
bin/test-dialects cljw cljrs # some of them

Releases

CI owns VERSION. Every push to main that touches src/, test/, deps.edn or the workflows runs the clj, bb and cljs suites, then clojure -T:build bump :level :patch, commits chore(release): X.Y.Z, tags vX.Y.Z and deploys to Clojars (.github/workflows/release.yml). Pull requests run the same suites and build the jar without publishing (.github/workflows/ci.yml), so a merged PR mints exactly one patch version.

For a minor or major release, set VERSION to the new line by hand (0.3.0); CI publishes the next patch of it (0.3.1). Pull before touching VERSION, since CI's release commits land on main.

License

MIT. See LICENSE.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close