Skip to content

v0.16.0

Latest

Choose a tag to compare

@muthuishere muthuishere released this 17 Aug 10:42
6cb8bd1

Fixed — an in-process client no longer needs an API key

createInProcessClient advertised "no apiKey" and then failed without one: the client resolves a
key from the environment and errors when it finds none, so an in-process model — which has no
endpoint to authenticate to — still demanded one. Every local run passed because a developer shell
has OPENROUTER_API_KEY set; CI, which has none, failed the in-process tests in all seven ports
at once. The constructor now supplies its own sentinel so that resolution never runs, and js/python
pin it with a test that strips the variables from the environment first.

Added — createInProcessClient: a model in your process, with no wire to configure (all ports)

Running a model inside your own process was possible, and it made you lie three times: a baseUrl
that is never dialled, an apiKey for an endpoint with no auth, and a style for a wire that does
not exist. Two of those were already optional and were only ever noise in our own docs. baseUrl
was genuinely required — and omitting it did not error, it crashed with
Cannot read properties of undefined (reading 'replace').

The bigger tax was the envelope. The seam is HTTP-shaped, so a host that only wanted to answer a
question had to build an HTTP response first — a Response in JS, an *http.Response in Go, an
HttpResponseMessage in C#, and in Java a 94-line HttpClient subclass. Every example in our
own cookbook opened by defining the same LocalModel.generate wrapper to hide it. When every
example needs the same adapter, the adapter belongs in the library.

createInProcessClient({ model: "my-local", generate: (req) => ({ content: "…" }) })

generate returns one assistant message{content} to finish, {toolCalls} to call tools —
plus optional usage. The library derives finish_reason, builds the choices envelope, and
encodes tool arguments unless they are already a string. Tool calls are flat
({id, name, arguments}): the nested function:{} wrapper is a wire detail, not something a model
author should have to type.

It is an ordinary client. MCP servers, agent skills, sub-agents, hooks, metrics, conversation memory
and the completion gate all behave exactly as against a hosted model, because this is a second
constructor built on the shipped transport — not a second seam. That transport is unchanged and
remains the answer for proxy, mTLS, credential injection and record-replay.

Shaped against the ecosystem rather than invented: Vercel's LanguageModelV4
(doGenerate → content/finishReason/usage), Microsoft.Extensions.AI's IChatClient and Pydantic
AI's Model all take messages in and a response out, and none requires a base URL or key — that
is an SDK-level pattern, not a framework one. All three also ship a built-in fake model, treating
this case as first-class.

Failures are final, not retried. A network client rides out transient failures; an in-process
one has no wire, so there is nothing transient — whatever generate throws will throw again, and
retrying only buys backoff before you see your own bug. Measured: the streaming refusal took 3.7s
to surface before this default and 1ms after. A genuinely flaky model can opt back in with
retries — or, in golang, with OnError, because that port documents Retries: 0 ⇒ 2 and zero
cannot mean zero there without changing shipped semantics for every network client.

Streaming is refused, not faked. A generate returns a whole answer, and a single-chunk stream
is indistinguishable from a real one by content or delta count, so the streaming path raises with a
message naming the limitation. The exception is clojure, which has no streaming entry point at all,
so there is nothing there to refuse — stated rather than papered over.

docs/adr/0019 rejected a semantic callback as a replacement for the transport seam; layering
one on top is a different claim, and the ADR is amended rather than quietly contradicted. Tracked in
openspec/changes/add-in-process-client.