Feat: Support self-hosted and open-source LLMs (Ollama, vLLM, LM Studio) via a Chat Completions provider
- Dominant language
- TypeScript
- Stars
- 1.4k
- Forks
- 205
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 92
Description
## Summary
You cannot run an adk-js agent on a self-hosted or open-source model today. Gemini is the only real option.
This issue proposes adding one class, `ChatCompletionsLlm`, that speaks the Chat Completions wire format. Because Ollama, vLLM, LM Studio, and most other local model servers all expose that same format, a single class would make all of them work at once.
**Is your feature request related to a problem? Please describe.**
As of `main` (v1.4.0), `core/src/models/` ships exactly two backends: `google_llm.ts` (Gemini) and `apigee_llm.ts`. The registry confirms it:
```ts
/** Registers default LLM factories, e.g. for Gemini models. */
LLMRegistry.register(Gemini);
LLMRegistry.register(ApigeeLlm);
```
Searching the repo for `ollama`, `openai`, `vllm`, or `litellm` across `core/`, `integrations/`, and `docs/` returns zero results.
This is a gap against adk-python, which ships three model files that adk-js does not have:
| adk-python | adk-js | What it unlocks |
| --- | --- | --- |
| `models/lite_llm.py` | missing | Ollama, vLLM, LM Studio, OpenAI, Azure, and 100+ providers |
| `models/anthropic_llm.py` | missing | Claude |
| `models/gemma_llm.py` | missing | Gemma, Google's own open-weights model |
The Python version treats local models as a first-class case. `lite_llm.py` has a dedicated `_is_ollama_chat_provider()` check, special request normalization for `ollama_chat`, and comments covering LM Studio and vLLM behavior. TypeScript has no equivalent for any of it.
Here is what that means in practice. A team that wants to run Llama, Qwen, Mistral, DeepSeek, or Gemma on their own hardware, whether for cost, latency, data residency, or an air-gapped network, can do it in adk-python today. In adk-js they cannot do it at all unless they write their own `BaseLlm` subclass from scratch.
**Describe the solution you'd like**
A first-party `ChatCompletionsLlm` provider in `core/src/models/` that sends Chat Completions requests to a base URL you choose.
On naming: I have deliberately avoided putting a vendor name in the class. The format originated with OpenAI's Chat Completions API, but it is now the de facto interchange shape that local runtimes have standardized on, and the class is not tied to that vendor in any way. `ChatCompletionsLlm` describes the protocol without implying an affiliation. I am not attached to it, so if maintainers prefer something else the name is entirely yours to pick. The phrase "OpenAI-compatible" still appears below where it is the accurate technical description, since that is the term every runtime's own documentation uses and dropping it would make the proposal harder to verify.
One thing worth flagging early: **LiteLLM is a Python library and has no official JS/TS SDK**, so `lite_llm.py` cannot simply be ported. That turns out not to matter much. Local model servers have all standardized on the OpenAI request and response shape, so pointing at a configurable base URL gets you the same coverage:
| Runtime | Base URL |
| --- | --- |
| Ollama | `http://localhost:11434/v1` |
| vLLM | `http://localhost:8000/v1` |
| LM Studio | `http://localhost:1234/v1` |
| llama.cpp server | `http://localhost:8080/v1` |
| LiteLLM proxy | `http://localhost:4000` |
| OpenAI, Groq, Together, OpenRouter, Fireworks | vendor URL |
That is the whole idea. One class, one format, and every row in that table starts working.
Proposed usage:
```ts
import {LlmAgent} from '@google/adk';
import {ChatCompletionsLlm} from '@google/adk/models';
// A local open-source model running in Ollama
const agent = new LlmAgent({
name: 'local_assistant',
model: new ChatCompletionsLlm({
model: 'llama3.1:8b',
baseUrl: 'http://localhost:11434/v1',
apiKey: 'ollama', // Ollama ignores this, but the format expects a value
}),
instruction: 'You are a helpful assistant.',
});
```
Optionally, string-based resolution through the existing registry, following the same `provider/model` convention LiteLLM uses:
```ts
const agent = new LlmAgent({
name: 'local_assistant',
model: 'ollama/llama3.1:8b', // resolved by LLMRegistry
});
```
### What would need building
`BaseLlm` has two abstract methods, so a provider has to implement both.
**1. `generateContentAsync(llmRequest, stream?, abortSignal?)`**
This is the bulk of the work. `LlmRequest` is typed against `@google/genai`, so the class has to translate between the ADK shape and the OpenAI shape in both directions:
| ADK / genai | OpenAI |
| --- | --- |
| `Content[]` with `role` and `parts` | `messages[]` |
| `functionCall` and `functionResponse` parts | `tool_calls` and `role: 'tool'` messages |
| `config.systemInstruction` | a leading `role: 'system'` message |
| `config.tools[].functionDeclarations` | `tools[].function` with JSON Schema |
| `config.responseSchema` and `responseMimeType` | `response_format: {type: 'json_schema'}` |
| `LlmResponse` | streamed chunks, accumulating `tool_calls` deltas |
`abortSignal` gets passed through to `fetch`. Streaming responses set `partial` on each `LlmResponse`.
**2. `connect(llmRequest)`**
The Chat Completions format has no bidirectional live or audio mode, so this should throw a clear error explaining that instead of failing in a confusing way. adk-python does the same thing: `LiteLlm` does not implement live connections either.
### Smaller decisions
- **`supportedModels`**: register prefix patterns such as `/^ollama\/.+/` and `/^openai\/.+/` so `LLMRegistry.resolve()` handles the string form. Direct construction stays the main path, since self-hosted model names are arbitrary.
- **Config precedence**: constructor arguments first, then `OPENAI_BASE_URL` and `OPENAI_API_KEY` environment variables. This mirrors how `Gemini` falls back to `GOOGLE_GENAI_API_KEY` and `GEMINI_API_KEY`.
- **Dependencies**: this can be written with plain `fetch` and no new runtime dependency, or with the official `openai` npm package. Happy to go either way. Staying dependency-free keeps `core` lean, and the class could also live in `integrations/` instead.
**Describe alternatives you've considered**
**Writing your own `BaseLlm` subclass.** This genuinely works today. `LlmAgent.model` accepts `string | BaseLlm`, `LLMRegistry.register()` is public, and `isBaseLlm()` uses a `Symbol.for('google.adk.baseModel')` guard so it still works when class identities differ across bundles.
The problem is not that it is impossible, it is that everyone has to do it separately. The translation table above is where the real difficulty lives: tool call round-trips, streaming deltas, and schema coercion are all easy to get subtly wrong. Every team rebuilding that privately, untested, is a lot of duplicated risk. It is also undocumented. `docs/` currently holds a single file, and there is no guide anywhere for writing a custom model provider.
**The community `adk-llm-bridge` package**, suggested in #23. It covers this capability today: its `Custom(model, { baseURL })` provider works with Ollama, LM Studio, vLLM and llama.cpp, and it supports Anthropic natively through `@anthropic-ai/sdk`.
The argument for a first-party provider is therefore not capability, it is ownership. An unofficial package is not versioned against ADK releases, is not covered by ADK's tests or support, and leaves a core capability dependent on one volunteer. The package's author takes the same position in the comments below and has offered to contribute the converter work upstream.
**Filing this under #23** ("Support for other LLM Models"). #23 is a broad, vendor-agnostic request that has been open since December 2025. This issue is intentionally narrower and different in kind. It covers self-hosted open-source models through a single format, and it is the part of #23 with the cleanest story.
**Additional context**
Two earlier PRs tried to add third-party model support and both stalled:
- #204, *Added Anthropic (Claude) model provider support* (+1336/-3), now conflicting
- #281, *add Claude LLM integration* (+309), still mergeable, blocked on CLA, never reviewed
The reason given for holding #204 was:
> Specifically, ADK Typescript also has to coordinate with other ADK languages to ensure we have a consistent approach to models other than Google's Gemini. Since we're heading into Cloud Next (late April) [...] For the 1.0 release, we are not going to have this kind of alternate implementation. In the future, it will likely be included.
Both conditions in that comment have since been met. Cloud Next has passed, and the project is now at v1.4.0. So I would like to respectfully ask whether this can be looked at again for the post-1.0 timeline.
I would also point out that this request is narrower than the one that was deferred. The concern was about picking a consistent approach to specific model vendors. This is not that. It is a request for an **adapter to a request format**, not an integration with a company:
- No vendor SDK to depend on or maintain
- No business relationship involved
- No cross-language decision about which vendors to back
- The OpenAI Chat Completions shape is already the de facto standard that local runtimes converged on
And the most natural first thing to run through it is **Gemma**, Google's own open-weights model, which adk-python already supports via `gemma_llm.py` and which runs in Ollama today.
Separately, on #204 @cornellgit noted:
> This is a desirable feature, would be helpful to check into contrib/ if we haven't set it up yet.
There is still no `contrib/` directory in the repo. If landing this in `core/` is the sticking point, I am glad to target `contrib/` or `integrations/` instead. The location matters far less than having an official, tested path that works.
**I am happy to implement this and open a PR** (CLA already signed) if maintainers are open to the direction. Two questions before I start:
1. Where should it live: `core/src/models/`, `integrations/`, or a new `contrib/`?
2. Plain `fetch` with no new dependency, or the official `openai` npm package?
Contributor guide
Assessment
This issue has not been assessed yet.