diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 9a3628b..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "env": {
- "browser": true,
- "es2021": true,
- "node": true,
- "jest": true
- },
- "extends": [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "prettier"
- ],
- "overrides": [],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module"
- },
- "plugins": [
- "@typescript-eslint",
- "simple-import-sort",
- "unused-imports"
- ],
- "rules": {
- "indent": [
- "error",
- 2
- ],
- "quotes": [
- "error",
- "single",
- {
- "avoidEscape": true,
- "allowTemplateLiterals": true
- }
- ],
- "quote-props": [
- "error",
- "as-needed"
- ],
- "semi": [
- "error",
- "always"
- ],
- "simple-import-sort/imports": 1,
- "simple-import-sort/exports": 1,
- "unused-imports/no-unused-imports": 1,
- "@typescript-eslint/no-unused-vars": [
- 1,
- {
- "argsIgnorePattern": "React|res|next|^_"
- }
- ],
- "@typescript-eslint/no-explicit-any": 0,
- "@typescript-eslint/no-var-requires": 0,
- "no-console": 0,
- "@typescript-eslint/ban-ts-comment": 0,
- "prefer-const": 0,
- "no-case-declarations": 0,
- "no-implicit-globals": 0,
- "@typescript-eslint/no-unsafe-declaration-merging": 0
- }
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index a1e0f09..5081b92 100644
--- a/README.md
+++ b/README.md
@@ -11,38 +11,66 @@
-A unified, streaming-capable interface for multiple LLM providers.
+A provider-portable LLM toolkit with structured streaming, model registries,
+cross-provider message normalization, and an optional stateful agent runtime.
## Packages
-- **agentic-kit** — core library with provider abstraction and `AgentKit` manager
+- **agentic-kit** — low-level portability layer with model descriptors, registries, structured event streams, and compatibility helpers
+- **@agentic-kit/agent** — minimal stateful runtime with sequential tool execution and lifecycle events
- **@agentic-kit/ollama** — adapter for local Ollama inference
- **@agentic-kit/anthropic** — adapter for Anthropic Claude models
-- **@agentic-kit/openai** — adapter for OpenAI and OpenAI-compatible APIs
+- **@agentic-kit/openai** — generalized adapter for OpenAI-compatible chat completion APIs
## Getting Started
```bash
git clone git@github.com:constructive-io/agentic-kit.git
cd agentic-kit
-yarn install
-yarn build
-yarn test
+pnpm install
+pnpm build
+pnpm test
```
## Usage
```typescript
-import { createOllamaKit, createMultiProviderKit, OllamaAdapter } from 'agentic-kit';
+import { complete, getModel } from "agentic-kit";
-const kit = createOllamaKit('http://localhost:11434');
-const text = await kit.generate({ model: 'mistral', prompt: 'Hello' });
+const model = getModel("openai", "gpt-4o-mini");
+const message = await complete(model!, {
+ messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
+});
-// Multi-provider
-const multi = createMultiProviderKit();
-multi.addProvider(new OllamaAdapter('http://localhost:11434'));
+console.log(message.content);
```
## Contributing
See individual package READMEs for docs and local dev instructions.
+
+## Testing
+
+Default tests stay deterministic and local:
+
+```bash
+pnpm test
+```
+
+There is also a local-only Ollama live lane that does not hit hosted
+providers. The default root command runs the fast smoke tier:
+
+```bash
+OLLAMA_LIVE_MODEL=qwen3.5:4b pnpm test:live:ollama
+```
+
+Run the broader lane explicitly when you want slower behavioral coverage:
+
+```bash
+OLLAMA_LIVE_MODEL=qwen3.5:4b pnpm test:live:ollama:extended
+```
+
+The Ollama live script performs a preflight against `OLLAMA_BASE_URL` and exits
+cleanly if the local server or requested model is unavailable. If
+`nomic-embed-text:latest` is installed, the lane also exercises local embedding
+generation.
diff --git a/REDESIGN_DECISIONS.md b/REDESIGN_DECISIONS.md
new file mode 100644
index 0000000..6f31c07
--- /dev/null
+++ b/REDESIGN_DECISIONS.md
@@ -0,0 +1,76 @@
+# Agentic Kit Redesign Decisions
+
+Date: 2026-04-18
+
+This document records the redesign decisions made while evaluating `agentic-kit`
+against the comparable `pi-mono` architecture, especially `packages/ai` and
+`packages/agent`.
+
+## Scope and Package Boundaries
+
+1. `agentic-kit` remains the low-level provider portability layer.
+2. Stateful orchestration moves into a separate `@agentic-kit/agent` package.
+3. Tool execution stays out of `agentic-kit` core; the core only models tools,
+ tool calls, and tool results.
+4. `@agentic-kit/agent` v1 should be intentionally minimal, shipping only the
+ sequential tool loop, lifecycle events, abort/continue, and pluggable context
+ transforms. Steering/follow-up queues and richer interruption policies are
+ deferred to phase 2.
+
+## Core Type System
+
+5. Core tool definitions use plain JSON Schema.
+6. TypeBox/Zod support stays as helper adapters, not the core contract.
+7. Core models are represented by a provider-independent `ModelDescriptor`
+ registry with capability metadata.
+8. The model registry must support both built-in descriptors and runtime
+ registration of custom models/providers from day one.
+9. The core message model treats `image` input and `thinking` output as
+ first-class content blocks in v1.
+10. `usage`, `cost`, `stopReason`, and abort-driven partial-result semantics are
+ mandatory parts of the core contract in v1.
+
+## Streaming and Conversation Semantics
+
+11. Structured event streams become the primary streaming primitive; text-only
+ chunk callbacks remain as convenience wrappers.
+12. Cross-provider replay and handoff is a hard requirement for v1, including
+ normalization for reasoning blocks, tool-call IDs, aborted turns, and
+ orphaned tool results.
+
+## Provider Strategy
+
+13. OpenAI-compatible backends should be handled by one generalized adapter path
+ with compatibility flags, not many first-class provider packages.
+14. Embeddings stay out of the primary conversational core and live behind a
+ separate optional capability interface or companion package.
+
+## Migration Strategy
+
+15. `agentic-kit` should ship a backward-compatibility layer for the current
+ `generate({ model, prompt }, { onChunk })` API for one transition release.
+
+## Architectural Implications
+
+These decisions imply the following target architecture:
+
+- `agentic-kit`
+ Low-level portability layer. Owns message/content types, model descriptors,
+ provider registry, streaming event protocol, compatibility transforms, usage,
+ and provider adapters.
+- `@agentic-kit/agent`
+ Optional stateful runtime. Owns tool execution, sequential loop semantics,
+ lifecycle events, context transforms, and abort/continue behavior.
+- Separate optional capabilities or companion packages
+ For non-conversational workloads such as embeddings, and optional schema
+ helpers such as TypeBox/Zod integration.
+
+## Design Principles Confirmed
+
+- Keep the protocol portable and runtime-agnostic.
+- Normalize provider differences in the core instead of leaking them upward.
+- Treat OpenAI-compatible APIs as a compatibility class, not a brand-specific
+ architecture.
+- Avoid coupling the low-level layer to any single schema library or vendor SDK.
+- Preserve a migration path from the existing text-only API while moving the
+ real architecture to structured messages and events.
diff --git a/apps/tanstack-chat-demo/.cta.json b/apps/tanstack-chat-demo/.cta.json
new file mode 100644
index 0000000..a50eba2
--- /dev/null
+++ b/apps/tanstack-chat-demo/.cta.json
@@ -0,0 +1,16 @@
+{
+ "projectName": "tanstack-chat-demo",
+ "mode": "file-router",
+ "typescript": true,
+ "tailwind": true,
+ "packageManager": "pnpm",
+ "git": false,
+ "install": false,
+ "addOnOptions": {},
+ "includeExamples": false,
+ "envVarValues": {},
+ "routerOnly": false,
+ "version": 1,
+ "framework": "react",
+ "chosenAddOns": []
+}
\ No newline at end of file
diff --git a/apps/tanstack-chat-demo/.gitignore b/apps/tanstack-chat-demo/.gitignore
new file mode 100644
index 0000000..8b25bb5
--- /dev/null
+++ b/apps/tanstack-chat-demo/.gitignore
@@ -0,0 +1,13 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+.env
+.nitro
+.tanstack
+.wrangler
+.output
+.vinxi
+__unconfig*
+todos.json
diff --git a/apps/tanstack-chat-demo/.vscode/settings.json b/apps/tanstack-chat-demo/.vscode/settings.json
new file mode 100644
index 0000000..00b5278
--- /dev/null
+++ b/apps/tanstack-chat-demo/.vscode/settings.json
@@ -0,0 +1,11 @@
+{
+ "files.watcherExclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "search.exclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "files.readonlyInclude": {
+ "**/routeTree.gen.ts": true
+ }
+}
diff --git a/apps/tanstack-chat-demo/README.md b/apps/tanstack-chat-demo/README.md
new file mode 100644
index 0000000..4aaad03
--- /dev/null
+++ b/apps/tanstack-chat-demo/README.md
@@ -0,0 +1,193 @@
+Welcome to your new TanStack Start app!
+
+# Getting Started
+
+To run this application:
+
+```bash
+pnpm install
+pnpm dev
+```
+
+# Building For Production
+
+To build this application for production:
+
+```bash
+pnpm build
+```
+
+## Testing
+
+This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
+
+```bash
+pnpm test
+```
+
+## Styling
+
+This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
+
+### Removing Tailwind CSS
+
+If you prefer not to use Tailwind CSS:
+
+1. Remove the demo pages in `src/routes/demo/`
+2. Replace the Tailwind import in `src/styles.css` with your own styles
+3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
+4. Uninstall the packages: `pnpm add @tailwindcss/vite tailwindcss --dev`
+
+
+
+## Routing
+
+This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
+
+### Adding A Route
+
+To add a new route to your application just add a new file in the `./src/routes` directory.
+
+TanStack will automatically generate the content of the route file for you.
+
+Now that you have two routes you can use a `Link` component to navigate between them.
+
+### Adding Links
+
+To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
+
+```tsx
+import { Link } from "@tanstack/react-router";
+```
+
+Then anywhere in your JSX you can use it like so:
+
+```tsx
+ About
+```
+
+This will create a link that will navigate to the `/about` route.
+
+More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
+
+### Using A Layout
+
+In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
+
+Here is an example layout that includes a header:
+
+```tsx
+import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: 'My App' },
+ ],
+ }),
+ shellComponent: ({ children }) => (
+
+
+
+
+
+
+ {children}
+
+
+
+ ),
+})
+```
+
+More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
+
+## Server Functions
+
+TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
+
+```tsx
+import { createServerFn } from '@tanstack/react-start'
+
+const getServerTime = createServerFn({
+ method: 'GET',
+}).handler(async () => {
+ return new Date().toISOString()
+})
+
+// Use in a component
+function MyComponent() {
+ const [time, setTime] = useState('')
+
+ useEffect(() => {
+ getServerTime().then(setTime)
+ }, [])
+
+ return Server time: {time}
+}
+```
+
+## API Routes
+
+You can create API routes by using the `server` property in your route definitions:
+
+```tsx
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+
+export const Route = createFileRoute('/api/hello')({
+ server: {
+ handlers: {
+ GET: () => json({ message: 'Hello, World!' }),
+ },
+ },
+})
+```
+
+## Data Fetching
+
+There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
+
+For example:
+
+```tsx
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/people')({
+ loader: async () => {
+ const response = await fetch('https://swapi.dev/api/people')
+ return response.json()
+ },
+ component: PeopleComponent,
+})
+
+function PeopleComponent() {
+ const data = Route.useLoaderData()
+ return (
+
+ {data.results.map((person) => (
+ {person.name}
+ ))}
+
+ )
+}
+```
+
+Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
+
+# Demo files
+
+Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
+
+# Learn More
+
+You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
+
+For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
diff --git a/apps/tanstack-chat-demo/components.json b/apps/tanstack-chat-demo/components.json
new file mode 100644
index 0000000..f5b98b3
--- /dev/null
+++ b/apps/tanstack-chat-demo/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "radix-nova",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/styles.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "#/components",
+ "utils": "#/lib/utils",
+ "ui": "#/components/ui",
+ "lib": "#/lib",
+ "hooks": "#/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/apps/tanstack-chat-demo/package.json b/apps/tanstack-chat-demo/package.json
new file mode 100644
index 0000000..c5ac7b3
--- /dev/null
+++ b/apps/tanstack-chat-demo/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "tanstack-chat-demo",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#/*": "./src/*"
+ },
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "build": "vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --fix",
+ "test": "vitest run --passWithNoTests"
+ },
+ "dependencies": {
+ "@agentic-kit/ollama": "workspace:*",
+ "@agentic-kit/openai": "workspace:*",
+ "@fontsource-variable/geist": "^5.2.8",
+ "@tailwindcss/vite": "^4.1.18",
+ "@tanstack/react-devtools": "latest",
+ "@tanstack/react-router": "latest",
+ "@tanstack/react-router-devtools": "latest",
+ "@tanstack/react-router-ssr-query": "latest",
+ "@tanstack/react-start": "latest",
+ "@tanstack/router-plugin": "^1.132.0",
+ "agentic-kit": "workspace:*",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.545.0",
+ "radix-ui": "^1.4.3",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "shadcn": "^4.3.0",
+ "tailwind-merge": "^3.5.0",
+ "tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.16",
+ "@tanstack/devtools-vite": "latest",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.0",
+ "@types/node": "^22.10.2",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^6.0.1",
+ "jsdom": "^28.1.0",
+ "typescript": "^5.7.2",
+ "vite": "^8.0.0",
+ "vitest": "^3.0.5"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "esbuild",
+ "lightningcss"
+ ]
+ }
+}
diff --git a/apps/tanstack-chat-demo/public/favicon.ico b/apps/tanstack-chat-demo/public/favicon.ico
new file mode 100644
index 0000000..a11777c
Binary files /dev/null and b/apps/tanstack-chat-demo/public/favicon.ico differ
diff --git a/apps/tanstack-chat-demo/public/logo192.png b/apps/tanstack-chat-demo/public/logo192.png
new file mode 100644
index 0000000..fc44b0a
Binary files /dev/null and b/apps/tanstack-chat-demo/public/logo192.png differ
diff --git a/apps/tanstack-chat-demo/public/logo512.png b/apps/tanstack-chat-demo/public/logo512.png
new file mode 100644
index 0000000..a4e47a6
Binary files /dev/null and b/apps/tanstack-chat-demo/public/logo512.png differ
diff --git a/apps/tanstack-chat-demo/public/manifest.json b/apps/tanstack-chat-demo/public/manifest.json
new file mode 100644
index 0000000..078ef50
--- /dev/null
+++ b/apps/tanstack-chat-demo/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "TanStack App",
+ "name": "Create TanStack App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/apps/tanstack-chat-demo/public/robots.txt b/apps/tanstack-chat-demo/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/apps/tanstack-chat-demo/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/apps/tanstack-chat-demo/src/components/chat/chat-input.tsx b/apps/tanstack-chat-demo/src/components/chat/chat-input.tsx
new file mode 100644
index 0000000..9973df7
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/chat/chat-input.tsx
@@ -0,0 +1,86 @@
+import { ArrowUp, Square } from 'lucide-react';
+import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
+
+import { cn } from '#/lib/utils';
+
+type Props = {
+ onSend: (text: string) => void;
+ onStop: () => void;
+ disabled?: boolean;
+ isStreaming: boolean;
+};
+
+export function ChatInput({ onSend, onStop, disabled, isStreaming }: Props) {
+ const [value, setValue] = useState('');
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = 'auto';
+ el.style.height = `${Math.min(el.scrollHeight, 140)}px`;
+ }, [value]);
+
+ const hasText = value.trim().length > 0;
+ const canSend = hasText && !disabled && !isStreaming;
+ const showAction = isStreaming || hasText;
+
+ function submit() {
+ if (!canSend) return;
+ onSend(value);
+ setValue('');
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/tanstack-chat-demo/src/components/chat/chat-panel.tsx b/apps/tanstack-chat-demo/src/components/chat/chat-panel.tsx
new file mode 100644
index 0000000..98aa0c9
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/chat/chat-panel.tsx
@@ -0,0 +1,189 @@
+import { AlertTriangle } from 'lucide-react';
+import { useEffect } from 'react';
+
+import { useChat } from '#/lib/use-chat';
+import { useModels } from '#/lib/use-models';
+import { useSettings } from '#/lib/use-settings';
+
+import { ChatInput } from './chat-input';
+import { MessageList } from './message-list';
+import { ModelPicker } from './model-picker';
+import { SettingsPopover } from './settings-popover';
+
+export function ChatPanel() {
+ const { settings, update, hydrated } = useSettings();
+ const { openai, ollama, ollamaError, refresh, loading } = useModels({
+ openaiEnabled: settings.openaiKey.length > 0,
+ ollamaBaseUrl: settings.ollamaBaseUrl,
+ });
+ const chat = useChat();
+
+ const selectedValue = `${settings.selectedProvider}:${settings.selectedModelId}`;
+ const hasAnyModel = openai.length + ollama.length > 0;
+
+ useEffect(() => {
+ if (!hydrated || loading) return;
+ const currentExists =
+ (settings.selectedProvider === 'openai' &&
+ openai.some((m) => m.id === settings.selectedModelId)) ||
+ (settings.selectedProvider === 'ollama' &&
+ ollama.some((m) => m.id === settings.selectedModelId));
+ if (currentExists) return;
+ const fallback = openai[0] ?? ollama[0];
+ if (fallback) {
+ update({ selectedProvider: fallback.provider, selectedModelId: fallback.id });
+ }
+ }, [
+ hydrated,
+ loading,
+ openai,
+ ollama,
+ settings.selectedProvider,
+ settings.selectedModelId,
+ update,
+ ]);
+
+ if (!hydrated) {
+ return (
+
+ Loading…
+
+ );
+ }
+
+ const canClear = chat.messages.length > 0 && !chat.isStreaming;
+
+ return (
+
+
+ update({ selectedProvider: provider, selectedModelId: id })
+ }
+ pickerDisabled={!hasAnyModel || chat.isStreaming}
+ isStreaming={chat.isStreaming}
+ settings={settings}
+ onSettingsChange={update}
+ onRefresh={refresh}
+ onClear={chat.clear}
+ canClear={canClear}
+ />
+
+ {ollamaError && settings.selectedProvider === 'ollama' && (
+
+ Can't reach Ollama at{' '}
+
+ {settings.ollamaBaseUrl}
+
+ . Run{' '}
+
+ ollama serve
+ {' '}
+ with{' '}
+
+ OLLAMA_ORIGINS=*
+
+ .
+
+ )}
+
+
+
+
+
+ {chat.error && !chat.messages.some((m) => m.error) && (
+
+ {chat.error}
+
+ )}
+
+
+ chat.send(text, {
+ provider: settings.selectedProvider,
+ modelId: settings.selectedModelId,
+ systemPrompt: settings.systemPrompt,
+ openaiKey: settings.openaiKey,
+ })
+ }
+ onStop={chat.stop}
+ isStreaming={chat.isStreaming}
+ disabled={!hasAnyModel}
+ />
+
+ );
+}
+
+type HeaderProps = {
+ openai: ReturnType['openai'];
+ ollama: ReturnType['ollama'];
+ selectedValue: string;
+ onModelChange: (provider: 'openai' | 'ollama', modelId: string) => void;
+ pickerDisabled: boolean;
+ isStreaming: boolean;
+ settings: ReturnType['settings'];
+ onSettingsChange: ReturnType['update'];
+ onRefresh: () => void;
+ onClear: () => void;
+ canClear: boolean;
+};
+
+function Header({
+ openai,
+ ollama,
+ selectedValue,
+ onModelChange,
+ pickerDisabled,
+ isStreaming,
+ settings,
+ onSettingsChange,
+ onRefresh,
+ onClear,
+ canClear,
+}: HeaderProps) {
+ return (
+
+ );
+}
+
+function Banner({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/tanstack-chat-demo/src/components/chat/message-list.tsx b/apps/tanstack-chat-demo/src/components/chat/message-list.tsx
new file mode 100644
index 0000000..eadfc5e
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/chat/message-list.tsx
@@ -0,0 +1,206 @@
+import { ChevronDown } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+import type { UIMessage } from '#/lib/use-chat';
+import { cn } from '#/lib/utils';
+
+type Props = {
+ messages: UIMessage[];
+ modelId: string;
+ hasAnyModel: boolean;
+};
+
+export function MessageList({ messages, modelId, hasAnyModel }: Props) {
+ const endRef = useRef(null);
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ }, [messages]);
+
+ if (messages.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {messages.map((m, i) => {
+ const prev = messages[i - 1];
+ const next = messages[i + 1];
+ const continuesPrev = prev?.role === m.role;
+ const continuesNext = next?.role === m.role;
+ return (
+
|
+ );
+ })}
+
+
+ );
+}
+
+function Row({
+ message,
+ isFirstOfGroup,
+ isLastOfGroup,
+}: {
+ message: UIMessage;
+ isFirstOfGroup: boolean;
+ isLastOfGroup: boolean;
+}) {
+ const isUser = message.role === 'user';
+ const empty = message.text.length === 0;
+ const hasThinking = !isUser && !!message.thinking;
+ const showTyping = !isUser && empty && !!message.streaming && !hasThinking;
+ const hideAnswerBubble = !isUser && empty && !!message.streaming && hasThinking && !message.error;
+
+ const tailClasses = isUser
+ ? cn(
+ 'rounded-[20px]',
+ !isFirstOfGroup && 'rounded-tr-[6px]',
+ !isLastOfGroup && 'rounded-br-[6px]',
+ )
+ : cn(
+ 'rounded-[20px]',
+ !isFirstOfGroup && 'rounded-tl-[6px]',
+ !isLastOfGroup && 'rounded-bl-[6px]',
+ );
+
+ return (
+
+
+ {hasThinking && (
+
+ )}
+ {hideAnswerBubble ? null : (
+
+ {showTyping ? (
+
+
+
+
+
+ ) : (
+ <>
+ {message.text}
+ {message.streaming && !message.error && !empty && (
+
+ )}
+ >
+ )}
+ {message.error &&
{message.error}
}
+
+ )}
+
+
+ );
+}
+
+function ThinkingTrail({
+ text,
+ streaming,
+ answerStarted,
+}: {
+ text: string;
+ streaming: boolean;
+ answerStarted: boolean;
+}) {
+ const active = streaming && !answerStarted;
+ const [open, setOpen] = useState(active);
+ const [userToggled, setUserToggled] = useState(false);
+
+ useEffect(() => {
+ if (!userToggled) setOpen(active);
+ }, [active, userToggled]);
+
+ return (
+
+
{
+ setUserToggled(true);
+ setOpen((v) => !v);
+ }}
+ className={cn(
+ 'press flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px] font-normal transition-colors',
+ active ? 'bg-ios-blue-tint text-ios-blue' : 'bg-fill-4 text-label-2 hover:bg-fill-3',
+ )}
+ aria-expanded={open}
+ >
+ {active ? 'Thinking' : 'Thought process'}
+ {active && (
+
+
+
+
+
+ )}
+
+
+ {open && (
+
+ {text}
+ {active && }
+
+ )}
+
+ );
+}
+
+function EmptyState({ modelId, hasAnyModel }: { modelId: string; hasAnyModel: boolean }) {
+ return (
+
+
+
+
+ {hasAnyModel ? 'New conversation' : 'Connect a model'}
+
+
+ {hasAnyModel ? (
+ <>
+ Messages are sent to {modelId} from your browser —
+ keys stay local.
+ >
+ ) : (
+ <>Add an OpenAI key or a local Ollama endpoint in Settings.>
+ )}
+
+
+
+ );
+}
diff --git a/apps/tanstack-chat-demo/src/components/chat/model-picker.tsx b/apps/tanstack-chat-demo/src/components/chat/model-picker.tsx
new file mode 100644
index 0000000..f1cf704
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/chat/model-picker.tsx
@@ -0,0 +1,101 @@
+import { ChevronDown } from 'lucide-react';
+
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from '#/components/ui/select';
+import type { ChatModelOption } from '#/lib/kit-client';
+
+type Props = {
+ openai: ChatModelOption[]
+ ollama: ChatModelOption[]
+ value: string
+ onChange: (provider: 'openai' | 'ollama', modelId: string) => void
+ disabled?: boolean
+ isStreaming?: boolean
+}
+
+export function ModelPicker({ openai, ollama, value, onChange, disabled, isStreaming }: Props) {
+ const hasModels = openai.length + ollama.length > 0;
+ return (
+ {
+ const [provider, ...idParts] = v.split(':');
+ onChange(provider as 'openai' | 'ollama', idParts.join(':'));
+ }}
+ >
+
+
+ {hasModels ? 'Select model' : 'No models'}
+
+ }
+ />
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+
+ {openai.length > 0 && (
+
+
+ OpenAI
+
+ {openai.map((m) => (
+
+ {m.name}
+
+ ))}
+
+ )}
+ {openai.length > 0 && ollama.length > 0 && (
+
+ )}
+ {ollama.length > 0 && (
+
+
+ Ollama
+
+ {ollama.map((m) => (
+
+ {m.name}
+
+ ))}
+
+ )}
+ {openai.length === 0 && ollama.length === 0 && (
+
+ No models available. Add one in Settings.
+
+ )}
+
+
+ );
+}
diff --git a/apps/tanstack-chat-demo/src/components/chat/settings-popover.tsx b/apps/tanstack-chat-demo/src/components/chat/settings-popover.tsx
new file mode 100644
index 0000000..339c04a
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/chat/settings-popover.tsx
@@ -0,0 +1,119 @@
+import { SlidersHorizontal } from 'lucide-react';
+import { useState } from 'react';
+
+import { Input } from '#/components/ui/input';
+import { Popover, PopoverContent, PopoverTrigger } from '#/components/ui/popover';
+import { Textarea } from '#/components/ui/textarea';
+import type { ChatSettings } from '#/lib/use-settings';
+
+type Props = {
+ settings: ChatSettings
+ onChange: (patch: Partial) => void
+ onRefresh: () => void
+}
+
+export function SettingsPopover({ settings, onChange, onRefresh }: Props) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+ Keys stay in your browser. Requests go straight to the provider.
+
+
+
+
+
+
+
+
+ System prompt
+
+
+
+
+
+ Cached in this browser
+ {
+ onRefresh();
+ setOpen(false);
+ }}
+ className="press rounded-full bg-ios-blue px-3 py-1 text-[13px] font-semibold text-white hover:bg-ios-blue-press"
+ >
+ Refresh models
+
+
+
+
+ );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
+
+function Divider() {
+ return
;
+}
diff --git a/apps/tanstack-chat-demo/src/components/ui/badge.tsx b/apps/tanstack-chat-demo/src/components/ui/badge.tsx
new file mode 100644
index 0000000..85709a1
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/badge.tsx
@@ -0,0 +1,49 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+const badgeVariants = cva(
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ secondary:
+ 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
+ destructive:
+ 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
+ outline:
+ 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+ ghost:
+ 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+);
+
+function Badge({
+ className,
+ variant = 'default',
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : 'span';
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/tanstack-chat-demo/src/components/ui/button.tsx b/apps/tanstack-chat-demo/src/components/ui/button.tsx
new file mode 100644
index 0000000..e6bc54d
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/button.tsx
@@ -0,0 +1,67 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ outline:
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
+ ghost:
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
+ destructive:
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default:
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ icon: 'size-8',
+ 'icon-xs':
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ 'icon-sm':
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
+ 'icon-lg': 'size-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+);
+
+function Button({
+ className,
+ variant = 'default',
+ size = 'default',
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : 'button';
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/apps/tanstack-chat-demo/src/components/ui/card.tsx b/apps/tanstack-chat-demo/src/components/ui/card.tsx
new file mode 100644
index 0000000..1390d13
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Card({
+ className,
+ size = 'default',
+ ...props
+}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+};
diff --git a/apps/tanstack-chat-demo/src/components/ui/dialog.tsx b/apps/tanstack-chat-demo/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..d717ad5
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/dialog.tsx
@@ -0,0 +1,166 @@
+import { XIcon } from 'lucide-react';
+import { Dialog as DialogPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+import { Button } from '#/components/ui/button';
+import { cn } from '#/lib/utils';
+
+function Dialog({
+ ...props
+}: React.ComponentProps
) {
+ return ;
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ );
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/apps/tanstack-chat-demo/src/components/ui/input.tsx b/apps/tanstack-chat-demo/src/components/ui/input.tsx
new file mode 100644
index 0000000..e78cc73
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/apps/tanstack-chat-demo/src/components/ui/label.tsx b/apps/tanstack-chat-demo/src/components/ui/label.tsx
new file mode 100644
index 0000000..42cb253
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import { Label as LabelPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/apps/tanstack-chat-demo/src/components/ui/popover.tsx b/apps/tanstack-chat-demo/src/components/ui/popover.tsx
new file mode 100644
index 0000000..d44c8c6
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/popover.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { Popover as PopoverPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = 'center',
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
+ return (
+
+ );
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: React.ComponentProps<'p'>) {
+ return (
+
+ );
+}
+
+export {
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+};
diff --git a/apps/tanstack-chat-demo/src/components/ui/scroll-area.tsx b/apps/tanstack-chat-demo/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..2fa7a0b
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/apps/tanstack-chat-demo/src/components/ui/select.tsx b/apps/tanstack-chat-demo/src/components/ui/select.tsx
new file mode 100644
index 0000000..fbf354d
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/select.tsx
@@ -0,0 +1,190 @@
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
+import { Select as SelectPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = 'default',
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: 'sm' | 'default'
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = 'item-aligned',
+ align = 'center',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/apps/tanstack-chat-demo/src/components/ui/separator.tsx b/apps/tanstack-chat-demo/src/components/ui/separator.tsx
new file mode 100644
index 0000000..a2bf8a1
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import { Separator as SeparatorPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/apps/tanstack-chat-demo/src/components/ui/textarea.tsx b/apps/tanstack-chat-demo/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..93d31e6
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+
+import { cn } from '#/lib/utils';
+
+function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/apps/tanstack-chat-demo/src/lib/kit-client.ts b/apps/tanstack-chat-demo/src/lib/kit-client.ts
new file mode 100644
index 0000000..f480428
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/lib/kit-client.ts
@@ -0,0 +1,66 @@
+import { OllamaClient } from '@agentic-kit/ollama';
+import {
+ type AssistantMessageEvent,
+ type Context,
+ getModel,
+ getModels,
+ type ModelDescriptor,
+ registerModel,
+ stream,
+ type StreamOptions,
+} from 'agentic-kit';
+
+export type ChatProvider = 'openai' | 'ollama'
+
+export type ChatModelOption = {
+ id: string
+ provider: ChatProvider
+ name: string
+ contextWindow?: number
+}
+
+const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
+
+export async function discoverOllamaModels(
+ baseUrl: string = DEFAULT_OLLAMA_URL,
+): Promise {
+ const trimmed = baseUrl.replace(/\/+$/, '') || DEFAULT_OLLAMA_URL;
+ const client = new OllamaClient(trimmed);
+ const ids = await client.listModels();
+ const details = await Promise.all(
+ ids.map((id) => client.showModel(id).catch(() => null)),
+ );
+ const options: ChatModelOption[] = [];
+ ids.forEach((id, i) => {
+ const caps = details[i]?.capabilities ?? [];
+ if (!caps.includes('completion')) return;
+ registerModel({
+ id,
+ name: id,
+ api: 'ollama-native',
+ provider: 'ollama',
+ baseUrl: trimmed,
+ input: caps.includes('vision') ? ['text', 'image'] : ['text'],
+ reasoning: caps.includes('thinking'),
+ tools: caps.includes('tools'),
+ });
+ options.push({ id, provider: 'ollama', name: id });
+ });
+ return options;
+}
+
+export function listOpenAIModelOptions(): ChatModelOption[] {
+ return getModels('openai').map((m) => ({
+ id: m.id,
+ provider: 'openai',
+ name: m.name,
+ contextWindow: m.contextWindow,
+ }));
+}
+
+export function resolveModel(provider: ChatProvider, id: string): ModelDescriptor | undefined {
+ return getModel(provider, id);
+}
+
+export type { AssistantMessageEvent, Context, ModelDescriptor, StreamOptions };
+export { stream };
diff --git a/apps/tanstack-chat-demo/src/lib/thinking.ts b/apps/tanstack-chat-demo/src/lib/thinking.ts
new file mode 100644
index 0000000..0e03a96
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/lib/thinking.ts
@@ -0,0 +1,35 @@
+const THINK_OPEN = '';
+const THINK_CLOSE = ' ';
+
+export function splitThinking(raw: string): { thinking: string; answer: string } {
+ if (!raw.includes(THINK_OPEN)) return { thinking: '', answer: raw };
+
+ let thinking = '';
+ let answer = '';
+ let cursor = 0;
+
+ while (cursor < raw.length) {
+ const openAt = raw.indexOf(THINK_OPEN, cursor);
+ if (openAt === -1) {
+ answer += raw.slice(cursor);
+ break;
+ }
+ answer += raw.slice(cursor, openAt);
+ const contentStart = openAt + THINK_OPEN.length;
+ const closeAt = raw.indexOf(THINK_CLOSE, contentStart);
+ if (closeAt === -1) {
+ if (thinking) thinking += '\n';
+ thinking += raw.slice(contentStart);
+ cursor = raw.length;
+ break;
+ }
+ if (thinking) thinking += '\n';
+ thinking += raw.slice(contentStart, closeAt);
+ cursor = closeAt + THINK_CLOSE.length;
+ }
+
+ return {
+ thinking: thinking.trim(),
+ answer: answer.replace(/^\s+/, ''),
+ };
+}
diff --git a/apps/tanstack-chat-demo/src/lib/use-chat.ts b/apps/tanstack-chat-demo/src/lib/use-chat.ts
new file mode 100644
index 0000000..5cfab93
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/lib/use-chat.ts
@@ -0,0 +1,171 @@
+import { useCallback, useRef, useState } from 'react';
+
+import { type ChatProvider, type Context,resolveModel, stream } from './kit-client';
+import { splitThinking } from './thinking';
+
+export type UIMessage = {
+ id: string
+ role: 'user' | 'assistant'
+ text: string
+ thinking?: string
+ streaming?: boolean
+ error?: string
+}
+
+type SendArgs = {
+ provider: ChatProvider
+ modelId: string
+ systemPrompt: string
+ openaiKey: string
+}
+
+function uid() {
+ return Math.random().toString(36).slice(2, 10);
+}
+
+export function useChat() {
+ const [messages, setMessages] = useState([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [error, setError] = useState(null);
+ const abortRef = useRef(null);
+ const rawBufRef = useRef>(new Map());
+ const nativeThinkRef = useRef>(new Map());
+
+ const send = useCallback(
+ async (text: string, args: SendArgs) => {
+ const trimmed = text.trim();
+ if (!trimmed || isStreaming) return;
+
+ const model = resolveModel(args.provider, args.modelId);
+ if (!model) {
+ setError(`Model ${args.provider}/${args.modelId} is not registered.`);
+ return;
+ }
+
+ setError(null);
+ const userMsg: UIMessage = { id: uid(), role: 'user', text: trimmed };
+ const assistantId = uid();
+ const assistantMsg: UIMessage = {
+ id: assistantId,
+ role: 'assistant',
+ text: '',
+ streaming: true,
+ };
+
+ const nextMessages = [...messages, userMsg, assistantMsg];
+ setMessages(nextMessages);
+ setIsStreaming(true);
+
+ const context: Context = {
+ systemPrompt: args.systemPrompt || undefined,
+ messages: nextMessages
+ .filter((m) => m.id !== assistantId && m.text.length > 0)
+ .map((m) =>
+ m.role === 'user'
+ ? {
+ role: 'user' as const,
+ content: m.text,
+ timestamp: Date.now(),
+ }
+ : {
+ role: 'assistant' as const,
+ content: [{ type: 'text' as const, text: m.text }],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop' as const,
+ timestamp: Date.now(),
+ },
+ ),
+ };
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ try {
+ const response = stream(model, context, {
+ apiKey: args.provider === 'openai' ? args.openaiKey : undefined,
+ signal: controller.signal,
+ });
+
+ const composeThinking = (id: string, tagThinking: string): string | undefined => {
+ const native = nativeThinkRef.current.get(id) ?? '';
+ if (native && tagThinking) return `${native}\n${tagThinking}`;
+ return native || tagThinking || undefined;
+ };
+
+ for await (const event of response) {
+ if (event.type === 'text_delta') {
+ const prev = rawBufRef.current.get(assistantId) ?? '';
+ const raw = prev + event.delta;
+ rawBufRef.current.set(assistantId, raw);
+ const { thinking: tagThinking, answer } = splitThinking(raw);
+ const thinking = composeThinking(assistantId, tagThinking);
+ setMessages((prev) =>
+ prev.map((m) => (m.id === assistantId ? { ...m, text: answer, thinking } : m)),
+ );
+ } else if (event.type === 'thinking_delta') {
+ const prev = nativeThinkRef.current.get(assistantId) ?? '';
+ const next = prev + event.delta;
+ nativeThinkRef.current.set(assistantId, next);
+ const raw = rawBufRef.current.get(assistantId) ?? '';
+ const { thinking: tagThinking } = splitThinking(raw);
+ const thinking = composeThinking(assistantId, tagThinking);
+ setMessages((prev) =>
+ prev.map((m) => (m.id === assistantId ? { ...m, thinking } : m)),
+ );
+ } else if (event.type === 'error') {
+ const errMsg = event.error.errorMessage ?? 'Stream error';
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantId ? { ...m, streaming: false, error: errMsg } : m,
+ ),
+ );
+ setError(errMsg);
+ }
+ }
+
+ setMessages((prev) =>
+ prev.map((m) => (m.id === assistantId ? { ...m, streaming: false } : m)),
+ );
+ rawBufRef.current.delete(assistantId);
+ nativeThinkRef.current.delete(assistantId);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantId ? { ...m, streaming: false, error: message } : m,
+ ),
+ );
+ setError(message);
+ } finally {
+ setIsStreaming(false);
+ abortRef.current = null;
+ }
+ },
+ [messages, isStreaming],
+ );
+
+ const stop = useCallback(() => {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ }, []);
+
+ const clear = useCallback(() => {
+ if (isStreaming) return;
+ setMessages([]);
+ setError(null);
+ rawBufRef.current.clear();
+ nativeThinkRef.current.clear();
+ }, [isStreaming]);
+
+ return { messages, send, stop, clear, isStreaming, error };
+}
diff --git a/apps/tanstack-chat-demo/src/lib/use-models.ts b/apps/tanstack-chat-demo/src/lib/use-models.ts
new file mode 100644
index 0000000..5104da7
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/lib/use-models.ts
@@ -0,0 +1,38 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import {
+ type ChatModelOption,
+ discoverOllamaModels,
+ listOpenAIModelOptions,
+} from './kit-client';
+
+type ModelsState = {
+ openai: ChatModelOption[]
+ ollama: ChatModelOption[]
+ ollamaError?: string
+ loading: boolean
+}
+
+export function useModels(args: { openaiEnabled: boolean; ollamaBaseUrl: string }) {
+ const { openaiEnabled, ollamaBaseUrl } = args;
+ const [state, setState] = useState({ openai: [], ollama: [], loading: true });
+
+ const refresh = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, ollamaError: undefined }));
+ const openai = openaiEnabled ? listOpenAIModelOptions() : [];
+ let ollama: ChatModelOption[] = [];
+ let ollamaError: string | undefined;
+ try {
+ ollama = await discoverOllamaModels(ollamaBaseUrl);
+ } catch (error) {
+ ollamaError = error instanceof Error ? error.message : 'Failed to reach Ollama';
+ }
+ setState({ openai, ollama, ollamaError, loading: false });
+ }, [openaiEnabled, ollamaBaseUrl]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { ...state, refresh };
+}
diff --git a/apps/tanstack-chat-demo/src/lib/use-settings.ts b/apps/tanstack-chat-demo/src/lib/use-settings.ts
new file mode 100644
index 0000000..748f732
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/lib/use-settings.ts
@@ -0,0 +1,53 @@
+import { useCallback, useEffect, useState } from 'react';
+
+const STORAGE_KEY = 'agentic-kit.chat.settings.v1';
+
+export type ChatSettings = {
+ openaiKey: string
+ ollamaBaseUrl: string
+ selectedProvider: 'openai' | 'ollama'
+ selectedModelId: string
+ systemPrompt: string
+}
+
+const DEFAULTS: ChatSettings = {
+ openaiKey: '',
+ ollamaBaseUrl: 'http://localhost:11434',
+ selectedProvider: 'openai',
+ selectedModelId: 'gpt-5.4-mini',
+ systemPrompt: 'You are a helpful assistant.',
+};
+
+function readFromStorage(): ChatSettings {
+ if (typeof window === 'undefined') return DEFAULTS;
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return DEFAULTS;
+ const parsed = JSON.parse(raw) as Partial;
+ return { ...DEFAULTS, ...parsed };
+ } catch {
+ return DEFAULTS;
+ }
+}
+
+export function useSettings() {
+ const [settings, setSettings] = useState(DEFAULTS);
+ const [hydrated, setHydrated] = useState(false);
+
+ useEffect(() => {
+ setSettings(readFromStorage());
+ setHydrated(true);
+ }, []);
+
+ const update = useCallback((patch: Partial) => {
+ setSettings((prev) => {
+ const next = { ...prev, ...patch };
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ }
+ return next;
+ });
+ }, []);
+
+ return { settings, update, hydrated };
+}
diff --git a/apps/tanstack-chat-demo/src/lib/utils.ts b/apps/tanstack-chat-demo/src/lib/utils.ts
new file mode 100644
index 0000000..1f5046c
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue,clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/tanstack-chat-demo/src/routeTree.gen.ts b/apps/tanstack-chat-demo/src/routeTree.gen.ts
new file mode 100644
index 0000000..dceedff
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/routeTree.gen.ts
@@ -0,0 +1,68 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as IndexRouteImport } from './routes/index'
+
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/'
+ id: '__root__' | '/'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/apps/tanstack-chat-demo/src/router.tsx b/apps/tanstack-chat-demo/src/router.tsx
new file mode 100644
index 0000000..5efba36
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/router.tsx
@@ -0,0 +1,20 @@
+import { createRouter as createTanStackRouter } from '@tanstack/react-router';
+
+import { routeTree } from './routeTree.gen';
+
+export function getRouter() {
+ const router = createTanStackRouter({
+ routeTree,
+ scrollRestoration: true,
+ defaultPreload: 'intent',
+ defaultPreloadStaleTime: 0,
+ });
+
+ return router;
+}
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: ReturnType
+ }
+}
diff --git a/apps/tanstack-chat-demo/src/routes/__root.tsx b/apps/tanstack-chat-demo/src/routes/__root.tsx
new file mode 100644
index 0000000..554bb6e
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/routes/__root.tsx
@@ -0,0 +1,37 @@
+import { TanStackDevtools } from '@tanstack/react-devtools';
+import { createRootRoute,HeadContent, Scripts } from '@tanstack/react-router';
+import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
+
+import appCss from '../styles.css?url';
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: 'Agentic Kit — Chat' },
+ ],
+ links: [{ rel: 'stylesheet', href: appCss }],
+ }),
+ shellComponent: RootDocument,
+});
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+ },
+ ]}
+ />
+
+
+
+ );
+}
diff --git a/apps/tanstack-chat-demo/src/routes/index.tsx b/apps/tanstack-chat-demo/src/routes/index.tsx
new file mode 100644
index 0000000..6f4d249
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/routes/index.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+import { ChatPanel } from '#/components/chat/chat-panel';
+
+export const Route = createFileRoute('/')({
+ component: ChatRoute,
+ ssr: false,
+});
+
+function ChatRoute() {
+ return ;
+}
diff --git a/apps/tanstack-chat-demo/src/styles.css b/apps/tanstack-chat-demo/src/styles.css
new file mode 100644
index 0000000..2325ade
--- /dev/null
+++ b/apps/tanstack-chat-demo/src/styles.css
@@ -0,0 +1,240 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+:root {
+ /* iOS system palette (light) */
+ --label-primary: #000000;
+ --label-secondary: rgba(60, 60, 67, 0.6);
+ --label-tertiary: rgba(60, 60, 67, 0.3);
+ --label-quaternary: rgba(60, 60, 67, 0.18);
+
+ --bg-primary: #ffffff;
+ --bg-secondary: #f2f2f7;
+ --bg-grouped: #f2f2f7;
+ --bg-elevated: #ffffff;
+
+ --fill-primary: rgba(120, 120, 128, 0.2);
+ --fill-secondary: rgba(120, 120, 128, 0.16);
+ --fill-tertiary: rgba(118, 118, 128, 0.12);
+ --fill-quaternary: rgba(116, 116, 128, 0.08);
+
+ --separator: rgba(60, 60, 67, 0.29);
+ --separator-opaque: #c6c6c8;
+ --hairline: rgba(60, 60, 67, 0.12);
+
+ --ios-blue: #007aff;
+ --ios-blue-press: #0062cc;
+ --ios-blue-tint: rgba(0, 122, 255, 0.1);
+ --ios-green: #34c759;
+ --ios-red: #ff3b30;
+ --ios-red-soft: rgba(255, 59, 48, 0.1);
+ --ios-orange: #ff9500;
+ --ios-orange-soft: rgba(255, 149, 0, 0.12);
+
+ --bubble-assistant: #e9e9eb;
+ --bubble-user: #007aff;
+
+ /* shadcn bridge tokens */
+ --background: var(--bg-primary);
+ --foreground: var(--label-primary);
+ --card: var(--bg-elevated);
+ --card-foreground: var(--label-primary);
+ --popover: var(--bg-elevated);
+ --popover-foreground: var(--label-primary);
+ --primary: var(--ios-blue);
+ --primary-foreground: #ffffff;
+ --secondary: var(--bg-secondary);
+ --secondary-foreground: var(--label-primary);
+ --muted: var(--bg-secondary);
+ --muted-foreground: var(--label-secondary);
+ --accent: var(--ios-blue-tint);
+ --accent-foreground: var(--ios-blue);
+ --destructive: var(--ios-red);
+ --input: var(--separator);
+ --border: var(--hairline);
+ --ring: var(--ios-blue);
+ --radius: 10px;
+
+ --sidebar: var(--bg-elevated);
+ --sidebar-foreground: var(--label-primary);
+ --sidebar-primary: var(--ios-blue);
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: var(--bg-secondary);
+ --sidebar-accent-foreground: var(--label-primary);
+ --sidebar-border: var(--hairline);
+ --sidebar-ring: var(--ios-blue);
+ --chart-1: var(--ios-blue);
+ --chart-2: var(--ios-green);
+ --chart-3: var(--ios-orange);
+ --chart-4: var(--ios-red);
+ --chart-5: var(--label-secondary);
+}
+
+@theme inline {
+ --font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'Helvetica Neue', sans-serif;
+ --font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
+
+ --color-label: var(--label-primary);
+ --color-label-2: var(--label-secondary);
+ --color-label-3: var(--label-tertiary);
+ --color-label-4: var(--label-quaternary);
+
+ --color-bg: var(--bg-primary);
+ --color-bg-2: var(--bg-secondary);
+ --color-bg-grouped: var(--bg-grouped);
+ --color-bg-elevated: var(--bg-elevated);
+
+ --color-fill-1: var(--fill-primary);
+ --color-fill-2: var(--fill-secondary);
+ --color-fill-3: var(--fill-tertiary);
+ --color-fill-4: var(--fill-quaternary);
+
+ --color-separator: var(--separator);
+ --color-hairline: var(--hairline);
+
+ --color-ios-blue: var(--ios-blue);
+ --color-ios-blue-press: var(--ios-blue-press);
+ --color-ios-blue-tint: var(--ios-blue-tint);
+ --color-ios-green: var(--ios-green);
+ --color-ios-red: var(--ios-red);
+ --color-ios-red-soft: var(--ios-red-soft);
+ --color-ios-orange: var(--ios-orange);
+ --color-ios-orange-soft: var(--ios-orange-soft);
+
+ --color-bubble-assistant: var(--bubble-assistant);
+ --color-bubble-user: var(--bubble-user);
+
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+
+ --radius-sm: 6px;
+ --radius-md: 8px;
+ --radius-lg: 10px;
+ --radius-xl: 13px;
+ --radius-2xl: 16px;
+ --radius-3xl: 20px;
+ --radius-4xl: 28px;
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/40;
+ }
+ html, body, #app {
+ height: 100%;
+ }
+ html {
+ background: var(--bg-secondary);
+ }
+ body {
+ @apply bg-bg text-label font-sans antialiased;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-size: 15px;
+ line-height: 1.35;
+ letter-spacing: -0.24px;
+ }
+ button, [role='button'] {
+ -webkit-tap-highlight-color: transparent;
+ }
+ code, kbd, pre, samp {
+ font-family: var(--font-mono);
+ }
+}
+
+.chrome-blur {
+ background: rgba(255, 255, 255, 0.78);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
+}
+
+.press {
+ transition: transform 150ms cubic-bezier(0.22, 1, 0.36, 1),
+ background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 150ms;
+}
+.press:active {
+ transform: scale(0.96);
+}
+
+@keyframes bubble-in {
+ 0% { opacity: 0; transform: translateY(6px) scale(0.96); }
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
+}
+.bubble-in {
+ animation: bubble-in 260ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+@keyframes typing-bounce {
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
+ 30% { transform: translateY(-3px); opacity: 1; }
+}
+.typing-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--label-secondary);
+ animation: typing-bounce 1.2s infinite cubic-bezier(0.22, 1, 0.36, 1);
+}
+.typing-dot:nth-child(2) { animation-delay: 0.16s; }
+.typing-dot:nth-child(3) { animation-delay: 0.32s; }
+
+.caret-at {
+ display: inline-block;
+ width: 2px;
+ height: 1em;
+ background: currentColor;
+ border-radius: 2px;
+ margin-left: 2px;
+ transform: translateY(0.17em);
+ animation: caret-blink 1s steps(1) infinite;
+ opacity: 0.7;
+}
+@keyframes caret-blink {
+ 0%, 49% { opacity: 0.7; }
+ 50%, 100% { opacity: 0; }
+}
+
+::selection {
+ background: color-mix(in oklab, var(--ios-blue) 22%, transparent);
+}
+
+.ios-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
+.ios-scroll::-webkit-scrollbar-track { background: transparent; }
+.ios-scroll::-webkit-scrollbar-thumb {
+ background: transparent;
+ border-radius: 999px;
+}
+.ios-scroll:hover::-webkit-scrollbar-thumb {
+ background: rgba(60, 60, 67, 0.25);
+}
diff --git a/apps/tanstack-chat-demo/tsconfig.json b/apps/tanstack-chat-demo/tsconfig.json
new file mode 100644
index 0000000..47543b2
--- /dev/null
+++ b/apps/tanstack-chat-demo/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "target": "ES2022",
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "baseUrl": ".",
+ "paths": {
+ "#/*": ["./src/*"],
+ "@/*": ["./src/*"]
+ },
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ /* Linting */
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ }
+}
diff --git a/apps/tanstack-chat-demo/vite.config.ts b/apps/tanstack-chat-demo/vite.config.ts
new file mode 100644
index 0000000..9197920
--- /dev/null
+++ b/apps/tanstack-chat-demo/vite.config.ts
@@ -0,0 +1,12 @@
+import tailwindcss from '@tailwindcss/vite';
+import { devtools } from '@tanstack/devtools-vite';
+import { tanstackStart } from '@tanstack/react-start/plugin/vite';
+import viteReact from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+const config = defineConfig({
+ resolve: { tsconfigPaths: true },
+ plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()],
+});
+
+export default config;
diff --git a/eslint.config.cjs b/eslint.config.cjs
new file mode 100644
index 0000000..6f86302
--- /dev/null
+++ b/eslint.config.cjs
@@ -0,0 +1,83 @@
+const js = require('@eslint/js');
+const globals = require('globals');
+const tsParser = require('@typescript-eslint/parser');
+const tsPlugin = require('@typescript-eslint/eslint-plugin');
+const simpleImportSort = require('eslint-plugin-simple-import-sort');
+const unusedImports = require('eslint-plugin-unused-imports');
+
+const sharedRules = {
+ indent: ['error', 2],
+ quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
+ 'quote-props': ['error', 'as-needed'],
+ semi: ['error', 'always'],
+ 'simple-import-sort/imports': 1,
+ 'simple-import-sort/exports': 1,
+ 'unused-imports/no-unused-imports': 1,
+ 'prefer-const': 0,
+ 'no-case-declarations': 0,
+ 'no-console': 0,
+};
+
+module.exports = [
+ {
+ ignores: [
+ '**/dist/**',
+ '**/node_modules/**',
+ '**/.tanstack/**',
+ '**/.output/**',
+ '**/routeTree.gen.ts',
+ ],
+ },
+ {
+ ...js.configs.recommended,
+ files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'commonjs',
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ ...globals.jest,
+ },
+ },
+ plugins: {
+ 'simple-import-sort': simpleImportSort,
+ 'unused-imports': unusedImports,
+ },
+ rules: sharedRules,
+ },
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parser: tsParser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ ...globals.jest,
+ },
+ },
+ plugins: {
+ '@typescript-eslint': tsPlugin,
+ 'simple-import-sort': simpleImportSort,
+ 'unused-imports': unusedImports,
+ },
+ rules: {
+ ...tsPlugin.configs.recommended.rules,
+ ...sharedRules,
+ '@typescript-eslint/no-unused-vars': [
+ 1,
+ { argsIgnorePattern: 'React|res|next|^_', varsIgnorePattern: '^_' },
+ ],
+ '@typescript-eslint/no-explicit-any': 0,
+ '@typescript-eslint/no-var-requires': 0,
+ '@typescript-eslint/ban-ts-comment': 0,
+ '@typescript-eslint/no-unsafe-declaration-merging': 0,
+ '@typescript-eslint/no-empty-object-type': 0,
+ 'no-implicit-globals': 0,
+ },
+ },
+];
diff --git a/package.json b/package.json
index 8c250ae..c5a1ff6 100644
--- a/package.json
+++ b/package.json
@@ -18,25 +18,31 @@
"clean": "pnpm -r run clean",
"build": "pnpm -r run build",
"build:dev": "pnpm -r run build:dev",
+ "test": "pnpm -r run test",
+ "typecheck": "node ./scripts/typecheck.js",
+ "test:live:ollama": "pnpm --filter @agentic-kit/ollama run test:live:smoke",
+ "test:live:ollama:extended": "pnpm --filter @agentic-kit/ollama run test:live:extended",
"lint": "pnpm -r run lint",
"internal:deps": "makage update-workspace",
"deps": "pnpm up -r -i -L"
},
"devDependencies": {
+ "@eslint/js": "^9.39.3",
"@types/jest": "^29.5.11",
"@types/node": "^20.12.7",
- "@typescript-eslint/eslint-plugin": "^8.53.1",
- "@typescript-eslint/parser": "^8.53.1",
+ "@typescript-eslint/eslint-plugin": "^8.58.2",
+ "@typescript-eslint/parser": "^8.58.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-simple-import-sort": "^12.1.0",
"eslint-plugin-unused-imports": "^4.0.0",
+ "globals": "^17.5.0",
"jest": "^29.6.2",
"lerna": "^8.2.3",
"makage": "^0.1.12",
"prettier": "^3.8.0",
- "ts-jest": "^29.4.6",
+ "ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
- "typescript": "^5.1.6"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/agent/README.md b/packages/agent/README.md
new file mode 100644
index 0000000..7b83ee3
--- /dev/null
+++ b/packages/agent/README.md
@@ -0,0 +1,13 @@
+# @agentic-kit/agent
+
+Minimal stateful agent runtime for `agentic-kit`.
+
+This package provides:
+
+- sequential tool execution
+- lifecycle events for UI and orchestration
+- abort and continue semantics
+- pluggable context transforms
+
+It is intentionally minimal in v1 and sits on top of the lower-level
+`agentic-kit` provider portability layer.
diff --git a/packages/agent/__tests__/agent.test.ts b/packages/agent/__tests__/agent.test.ts
new file mode 100644
index 0000000..aa6681c
--- /dev/null
+++ b/packages/agent/__tests__/agent.test.ts
@@ -0,0 +1,331 @@
+import {
+ type AssistantMessage,
+ type Context,
+ createAssistantMessageEventStream,
+ type ModelDescriptor,
+} from 'agentic-kit';
+
+import { Agent } from '../src';
+
+function createModel(): ModelDescriptor {
+ return {
+ id: 'demo',
+ name: 'Demo',
+ api: 'fake',
+ provider: 'fake',
+ baseUrl: 'http://fake.local',
+ input: ['text'],
+ reasoning: false,
+ tools: true,
+ };
+}
+
+describe('@agentic-kit/agent', () => {
+ it('runs a minimal sequential tool loop', async () => {
+ const responses = [
+ {
+ role: 'assistant' as const,
+ api: 'fake',
+ provider: 'fake',
+ model: 'demo',
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'toolUse' as const,
+ timestamp: Date.now(),
+ content: [
+ { type: 'toolCall' as const, id: 'tool_1', name: 'echo', arguments: { text: 'hello' } },
+ ],
+ },
+ {
+ role: 'assistant' as const,
+ api: 'fake',
+ provider: 'fake',
+ model: 'demo',
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop' as const,
+ timestamp: Date.now(),
+ content: [{ type: 'text' as const, text: 'done' }],
+ },
+ ];
+
+ let callIndex = 0;
+ const streamFn = (_model: ModelDescriptor, _context: Context) => {
+ const stream = createAssistantMessageEventStream();
+ const response = responses[callIndex++];
+
+ queueMicrotask(() => {
+ stream.push({ type: 'start', partial: response });
+ if (response.content[0].type === 'toolCall') {
+ stream.push({
+ type: 'toolcall_start',
+ contentIndex: 0,
+ partial: response,
+ });
+ stream.push({
+ type: 'toolcall_end',
+ contentIndex: 0,
+ toolCall: response.content[0],
+ partial: response,
+ });
+ } else {
+ stream.push({
+ type: 'text_start',
+ contentIndex: 0,
+ partial: response,
+ });
+ stream.push({
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'done',
+ partial: response,
+ });
+ stream.push({
+ type: 'text_end',
+ contentIndex: 0,
+ content: 'done',
+ partial: response,
+ });
+ }
+ stream.push({
+ type: 'done',
+ reason: response.stopReason === 'toolUse' ? 'toolUse' : 'stop',
+ message: response,
+ });
+ stream.end(response);
+ });
+
+ return stream;
+ };
+
+ const agent = new Agent({
+ initialState: {
+ model: createModel(),
+ },
+ streamFn,
+ });
+
+ agent.setTools([
+ {
+ name: 'echo',
+ label: 'Echo',
+ description: 'Echo text',
+ parameters: {
+ type: 'object',
+ properties: {
+ text: { type: 'string' },
+ },
+ required: ['text'],
+ },
+ execute: async (_toolCallId, params) => ({
+ content: [{ type: 'text', text: String(params.text) }],
+ }),
+ },
+ ]);
+
+ await agent.prompt('hello');
+
+ expect(agent.state.messages).toHaveLength(4);
+ expect(agent.state.messages[1]).toMatchObject({
+ role: 'assistant',
+ stopReason: 'toolUse',
+ });
+ expect(agent.state.messages[2]).toMatchObject({
+ role: 'toolResult',
+ toolName: 'echo',
+ content: [{ type: 'text', text: 'hello' }],
+ });
+ expect(agent.state.messages[3]).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'done' }],
+ });
+ });
+
+ it('turns tool argument validation failures into error tool results and continues', async () => {
+ const responses = [
+ createAssistantResponse({
+ stopReason: 'toolUse',
+ content: [{ type: 'toolCall', id: 'tool_1', name: 'echo', arguments: {} }],
+ }),
+ createAssistantResponse({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'recovered' }],
+ }),
+ ];
+
+ let callIndex = 0;
+ const agent = new Agent({
+ initialState: { model: createModel() },
+ streamFn: () => streamMessage(responses[callIndex++]),
+ });
+
+ const execute = jest.fn(async () => ({
+ content: [{ type: 'text' as const, text: 'should not run' }],
+ }));
+
+ agent.setTools([
+ {
+ name: 'echo',
+ label: 'Echo',
+ description: 'Echo text',
+ parameters: {
+ type: 'object',
+ properties: {
+ text: { type: 'string' },
+ },
+ required: ['text'],
+ },
+ execute,
+ },
+ ]);
+
+ await agent.prompt('hello');
+
+ expect(execute).not.toHaveBeenCalled();
+ expect(agent.state.messages[2]).toMatchObject({
+ role: 'toolResult',
+ toolName: 'echo',
+ isError: true,
+ });
+ expect(agent.state.messages[2].content[0]).toMatchObject({
+ type: 'text',
+ text: expect.stringContaining('Tool argument validation failed'),
+ });
+ expect(agent.state.messages[3]).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'recovered' }],
+ });
+ });
+
+ it('records aborted assistant turns when the active stream is cancelled', async () => {
+ const agent = new Agent({
+ initialState: { model: createModel() },
+ streamFn: (_model: ModelDescriptor, _context: Context, options) => {
+ const stream = createAssistantMessageEventStream();
+ const partial = createAssistantResponse({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: '' }],
+ });
+
+ queueMicrotask(() => {
+ stream.push({ type: 'start', partial });
+
+ options?.signal?.addEventListener(
+ 'abort',
+ () => {
+ const aborted = createAssistantResponse({
+ stopReason: 'aborted',
+ errorMessage: 'aborted by test',
+ content: [],
+ });
+ stream.push({ type: 'error', reason: 'aborted', error: aborted });
+ stream.end(aborted);
+ },
+ { once: true }
+ );
+ });
+
+ return stream;
+ },
+ });
+
+ const pending = agent.prompt('slow');
+ setTimeout(() => agent.abort(), 0);
+ await pending;
+
+ expect(agent.state.error).toBe('aborted by test');
+ expect(agent.state.messages.at(-1)).toMatchObject({
+ role: 'assistant',
+ stopReason: 'aborted',
+ errorMessage: 'aborted by test',
+ });
+ expect(agent.state.isStreaming).toBe(false);
+ expect(agent.state.streamMessage).toBeNull();
+ });
+});
+
+function createAssistantResponse(overrides: Partial): AssistantMessage {
+ return {
+ ...createAssistantResponseBase(),
+ ...overrides,
+ };
+}
+
+function createAssistantResponseBase(): AssistantMessage {
+ return {
+ role: 'assistant' as const,
+ api: 'fake',
+ provider: 'fake',
+ model: 'demo',
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop' as const,
+ timestamp: Date.now(),
+ content: [] as AssistantMessage['content'],
+ };
+}
+
+function streamMessage(message: AssistantMessage) {
+ const stream = createAssistantMessageEventStream();
+
+ queueMicrotask(() => {
+ stream.push({ type: 'start', partial: message });
+ if (message.content[0]?.type === 'toolCall') {
+ stream.push({
+ type: 'toolcall_start',
+ contentIndex: 0,
+ partial: message,
+ });
+ stream.push({
+ type: 'toolcall_end',
+ contentIndex: 0,
+ toolCall: message.content[0],
+ partial: message,
+ });
+ } else {
+ stream.push({
+ type: 'text_start',
+ contentIndex: 0,
+ partial: message,
+ });
+ stream.push({
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: message.content[0]?.type === 'text' ? message.content[0].text : '',
+ partial: message,
+ });
+ stream.push({
+ type: 'text_end',
+ contentIndex: 0,
+ content: message.content[0]?.type === 'text' ? message.content[0].text : '',
+ partial: message,
+ });
+ }
+ stream.push({
+ type: 'done',
+ reason: message.stopReason === 'toolUse' ? 'toolUse' : 'stop',
+ message,
+ });
+ stream.end(message);
+ });
+
+ return stream;
+}
diff --git a/packages/agent/__tests__/tsconfig.json b/packages/agent/__tests__/tsconfig.json
new file mode 100644
index 0000000..6c4fda5
--- /dev/null
+++ b/packages/agent/__tests__/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "..",
+ "types": ["jest", "node"]
+ },
+ "include": ["./**/*.ts", "../src/**/*.ts"],
+ "exclude": ["../dist", "../node_modules"]
+}
diff --git a/packages/agent/jest.config.js b/packages/agent/jest.config.js
new file mode 100644
index 0000000..6622fd1
--- /dev/null
+++ b/packages/agent/jest.config.js
@@ -0,0 +1,23 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: '__tests__/tsconfig.json',
+ },
+ ],
+ },
+ transformIgnorePatterns: ['/node_modules/*'],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*'],
+ moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^agentic-kit$': '/../agentic-kit/src',
+ '^@agentic-kit/(.*)$': '/../$1/src',
+ },
+};
diff --git a/packages/agent/package.json b/packages/agent/package.json
new file mode 100644
index 0000000..c4cbeb2
--- /dev/null
+++ b/packages/agent/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@agentic-kit/agent",
+ "version": "0.1.0",
+ "author": "Dan Lynch ",
+ "description": "Minimal stateful agent runtime for agentic-kit",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "homepage": "https://github.com/constructive-io/agentic-kit",
+ "license": "SEE LICENSE IN LICENSE",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/constructive-io/agentic-kit"
+ },
+ "bugs": {
+ "url": "https://github.com/constructive-io/agentic-kit/issues"
+ },
+ "scripts": {
+ "clean": "makage clean",
+ "prepack": "npm run build",
+ "build": "makage build",
+ "build:dev": "makage build --dev",
+ "lint": "eslint . --fix",
+ "test": "jest",
+ "test:watch": "jest --watch"
+ },
+ "dependencies": {
+ "agentic-kit": "workspace:*"
+ },
+ "keywords": []
+}
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts
new file mode 100644
index 0000000..8fc8503
--- /dev/null
+++ b/packages/agent/src/agent.ts
@@ -0,0 +1,300 @@
+import {
+ type AssistantMessage,
+ type Context,
+ createToolResultMessage,
+ createUserMessage,
+ type Message,
+ stream,
+ type StreamOptions,
+} from 'agentic-kit';
+
+import type {
+ AgentEvent,
+ AgentOptions,
+ AgentState,
+ AgentTool,
+ AgentToolResult,
+} from './types.js';
+import { validateToolArguments as defaultValidateToolArguments } from './validation.js';
+
+export class Agent {
+ private readonly listeners = new Set<(event: AgentEvent) => void>();
+ private readonly transformContext?: AgentOptions['transformContext'];
+ private readonly streamFn: NonNullable;
+ private readonly validateToolArguments: NonNullable;
+ private abortController?: AbortController;
+ private running?: Promise;
+
+ private _state: AgentState;
+
+ constructor(options: AgentOptions) {
+ this._state = {
+ systemPrompt: '',
+ tools: [],
+ messages: [],
+ isStreaming: false,
+ streamMessage: null,
+ streamOptions: undefined,
+ ...options.initialState,
+ };
+ this.streamFn = options.streamFn ?? stream;
+ this.transformContext = options.transformContext;
+ this.validateToolArguments = options.validateToolArguments ?? defaultValidateToolArguments;
+ }
+
+ get state(): AgentState {
+ return this._state;
+ }
+
+ subscribe(listener: (event: AgentEvent) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ setModel(model: AgentState['model']): void {
+ this._state.model = model;
+ }
+
+ setTools(tools: AgentTool[]): void {
+ this._state.tools = tools;
+ }
+
+ setSystemPrompt(systemPrompt: string): void {
+ this._state.systemPrompt = systemPrompt;
+ }
+
+ setStreamOptions(streamOptions: Omit | undefined): void {
+ this._state.streamOptions = streamOptions;
+ }
+
+ replaceMessages(messages: Message[]): void {
+ this._state.messages = [...messages];
+ }
+
+ appendMessage(message: Message): void {
+ this._state.messages = [...this._state.messages, message];
+ }
+
+ clearMessages(): void {
+ this._state.messages = [];
+ }
+
+ reset(): void {
+ this.abort();
+ this._state.messages = [];
+ this._state.streamMessage = null;
+ this._state.isStreaming = false;
+ this._state.error = undefined;
+ }
+
+ abort(): void {
+ this.abortController?.abort();
+ }
+
+ waitForIdle(): Promise {
+ return this.running ?? Promise.resolve();
+ }
+
+ async prompt(input: string | Message): Promise {
+ if (this._state.isStreaming) {
+ throw new Error('Agent is already processing a prompt');
+ }
+
+ const message = typeof input === 'string' ? createUserMessage(input) : input;
+ await this.runLoop([message]);
+ }
+
+ async continue(): Promise {
+ if (this._state.isStreaming) {
+ throw new Error('Agent is already processing');
+ }
+
+ const lastMessage = this._state.messages[this._state.messages.length - 1];
+ if (!lastMessage) {
+ throw new Error('No messages to continue from');
+ }
+ if (lastMessage.role === 'assistant') {
+ throw new Error('Cannot continue from message role: assistant');
+ }
+
+ await this.runLoop();
+ }
+
+ private async runLoop(initialMessages?: Message[]): Promise {
+ this.running = (async () => {
+ this.abortController = new AbortController();
+ this._state.isStreaming = true;
+ this._state.streamMessage = null;
+ this._state.error = undefined;
+
+ try {
+ this.emit({ type: 'agent_start' });
+
+ if (initialMessages && initialMessages.length > 0) {
+ for (const message of initialMessages) {
+ this.emit({ type: 'message_start', message });
+ this.appendMessage(message);
+ this.emit({ type: 'message_end', message });
+ }
+ }
+
+ while (true) {
+ this.emit({ type: 'turn_start' });
+
+ const assistantMessage = await this.generateAssistantMessage(this.abortController.signal);
+ this.appendMessage(assistantMessage);
+ this.emit({ type: 'message_end', message: assistantMessage });
+
+ if (assistantMessage.stopReason === 'error' || assistantMessage.stopReason === 'aborted') {
+ this._state.error = assistantMessage.errorMessage;
+ this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
+ break;
+ }
+
+ const toolCalls = assistantMessage.content.filter((block) => block.type === 'toolCall');
+ if (toolCalls.length === 0) {
+ this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
+ break;
+ }
+
+ const toolResults = await this.executeToolCalls(toolCalls, this.abortController.signal);
+ for (const toolResult of toolResults) {
+ this.emit({ type: 'message_start', message: toolResult });
+ this.appendMessage(toolResult);
+ this.emit({ type: 'message_end', message: toolResult });
+ }
+
+ this.emit({ type: 'turn_end', message: assistantMessage, toolResults });
+ }
+
+ this.emit({ type: 'agent_end', messages: [...this._state.messages] });
+ } finally {
+ this._state.isStreaming = false;
+ this._state.streamMessage = null;
+ this.abortController = undefined;
+ this.running = undefined;
+ }
+ })();
+
+ await this.running;
+ }
+
+ private async generateAssistantMessage(signal: AbortSignal): Promise {
+ const messages = this.transformContext
+ ? await this.transformContext(this._state.messages, signal)
+ : this._state.messages;
+
+ const context: Context = {
+ systemPrompt: this._state.systemPrompt,
+ tools: this._state.tools,
+ messages,
+ };
+
+ const streamResult = this.streamFn(this._state.model, context, {
+ ...(this._state.streamOptions ?? {}),
+ signal,
+ });
+
+ for await (const event of streamResult) {
+ switch (event.type) {
+ case 'start':
+ this._state.streamMessage = event.partial;
+ this.emit({ type: 'message_start', message: event.partial });
+ break;
+ case 'text_start':
+ case 'text_delta':
+ case 'text_end':
+ case 'thinking_start':
+ case 'thinking_delta':
+ case 'thinking_end':
+ case 'toolcall_start':
+ case 'toolcall_delta':
+ case 'toolcall_end':
+ this._state.streamMessage = event.partial;
+ this.emit({
+ type: 'message_update',
+ message: event.partial,
+ assistantMessageEvent: event,
+ });
+ break;
+ case 'done':
+ case 'error':
+ this._state.streamMessage = null;
+ break;
+ }
+ }
+
+ return streamResult.result();
+ }
+
+ private async executeToolCalls(
+ toolCalls: Array>,
+ signal: AbortSignal
+ ) {
+ const results = [];
+
+ for (const toolCall of toolCalls) {
+ const tool = this._state.tools.find((candidate) => candidate.name === toolCall.name);
+ this.emit({
+ type: 'tool_execution_start',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ args: toolCall.arguments as Record,
+ });
+
+ let result: AgentToolResult;
+ let isError = false;
+
+ try {
+ if (!tool) {
+ throw new Error(`Tool '${toolCall.name}' not found`);
+ }
+
+ const validatedArgs = this.validateToolArguments(
+ tool.parameters,
+ toolCall.arguments as Record
+ );
+
+ result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => {
+ this.emit({
+ type: 'tool_execution_update',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ args: validatedArgs,
+ partialResult,
+ });
+ });
+ } catch (error) {
+ result = {
+ content: [
+ {
+ type: 'text',
+ text: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ };
+ isError = true;
+ }
+
+ this.emit({
+ type: 'tool_execution_end',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ result,
+ isError,
+ });
+
+ results.push(
+ createToolResultMessage(toolCall.id, toolCall.name, result.content, isError)
+ );
+ }
+
+ return results;
+ }
+
+ private emit(event: AgentEvent): void {
+ for (const listener of this.listeners) {
+ listener(event);
+ }
+ }
+}
diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts
new file mode 100644
index 0000000..b8b99bb
--- /dev/null
+++ b/packages/agent/src/index.ts
@@ -0,0 +1,3 @@
+export * from './agent.js';
+export * from './types.js';
+export * from './validation.js';
diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts
new file mode 100644
index 0000000..1486987
--- /dev/null
+++ b/packages/agent/src/types.ts
@@ -0,0 +1,83 @@
+import type {
+ AssistantMessage,
+ AssistantMessageEvent,
+ Context,
+ JsonSchema,
+ Message,
+ ModelDescriptor,
+ StreamOptions,
+ ToolDefinition,
+ ToolResultMessage,
+} from 'agentic-kit';
+
+export interface AgentToolResult {
+ content: ToolResultMessage['content'];
+ details?: TDetails;
+}
+
+export type AgentToolUpdateCallback = (
+ partialResult: AgentToolResult
+) => void;
+
+export interface AgentTool extends ToolDefinition {
+ label: string;
+ execute: (
+ toolCallId: string,
+ params: Record,
+ signal?: AbortSignal,
+ onUpdate?: AgentToolUpdateCallback
+ ) => Promise>;
+}
+
+export interface AgentState {
+ error?: string;
+ isStreaming: boolean;
+ messages: Message[];
+ model: ModelDescriptor;
+ streamMessage: AssistantMessage | null;
+ streamOptions?: Omit;
+ systemPrompt: string;
+ tools: AgentTool[];
+}
+
+export interface AgentEventBase {
+ type: string;
+}
+
+export type AgentEvent =
+ | { type: 'agent_start' }
+ | { type: 'agent_end'; messages: Message[] }
+ | { type: 'turn_start' }
+ | { type: 'turn_end'; message: AssistantMessage; toolResults: ToolResultMessage[] }
+ | { type: 'message_start'; message: Message }
+ | { type: 'message_update'; message: AssistantMessage; assistantMessageEvent: AssistantMessageEvent }
+ | { type: 'message_end'; message: Message }
+ | { type: 'tool_execution_start'; toolCallId: string; toolName: string; args: Record }
+ | {
+ type: 'tool_execution_update';
+ toolCallId: string;
+ toolName: string;
+ args: Record;
+ partialResult: AgentToolResult;
+ }
+ | {
+ type: 'tool_execution_end';
+ toolCallId: string;
+ toolName: string;
+ result: AgentToolResult;
+ isError: boolean;
+ };
+
+export interface AgentOptions {
+ initialState: Pick & Partial>;
+ streamFn?: (
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+ ) => AsyncIterable & { result(): Promise };
+ transformContext?: (messages: Message[], signal?: AbortSignal) => Promise;
+ validateToolArguments?: (
+ schema: JsonSchema,
+ args: Record
+ ) => Record;
+}
diff --git a/packages/agent/src/validation.ts b/packages/agent/src/validation.ts
new file mode 100644
index 0000000..51634c7
--- /dev/null
+++ b/packages/agent/src/validation.ts
@@ -0,0 +1,123 @@
+import type { JsonSchema } from 'agentic-kit';
+
+export function validateToolArguments(
+ schema: JsonSchema,
+ args: Record
+): Record {
+ const errors = validateSchema(schema, args, 'root');
+ if (errors.length === 0) {
+ return args;
+ }
+
+ throw new Error(`Tool argument validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
+}
+
+function validateSchema(schema: JsonSchema, value: unknown, path: string): string[] {
+ if (!schema || Object.keys(schema).length === 0) {
+ return [];
+ }
+
+ const errors: string[] = [];
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
+
+ if (types.length > 0 && !types.some((type) => matchesType(type, value))) {
+ errors.push(`${path} should be ${types.join(' | ')}`);
+ return errors;
+ }
+
+ if (schema.enum && !schema.enum.some((candidate) => deepEqual(candidate, value))) {
+ errors.push(`${path} must be one of ${schema.enum.map(String).join(', ')}`);
+ }
+
+ if (typeof value === 'string') {
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
+ errors.push(`${path} must have length >= ${schema.minLength}`);
+ }
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
+ errors.push(`${path} must have length <= ${schema.maxLength}`);
+ }
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
+ errors.push(`${path} must match pattern ${schema.pattern}`);
+ }
+ }
+
+ if (typeof value === 'number') {
+ if (schema.minimum !== undefined && value < schema.minimum) {
+ errors.push(`${path} must be >= ${schema.minimum}`);
+ }
+ if (schema.maximum !== undefined && value > schema.maximum) {
+ errors.push(`${path} must be <= ${schema.maximum}`);
+ }
+ }
+
+ if (Array.isArray(value)) {
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
+ errors.push(`${path} must contain at least ${schema.minItems} items`);
+ }
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
+ errors.push(`${path} must contain at most ${schema.maxItems} items`);
+ }
+
+ if (schema.items && !Array.isArray(schema.items)) {
+ value.forEach((item, index) => {
+ errors.push(...validateSchema(schema.items as JsonSchema, item, `${path}[${index}]`));
+ });
+ }
+ }
+
+ if (isPlainObject(value)) {
+ const properties = schema.properties ?? {};
+ const required = schema.required ?? [];
+
+ for (const key of required) {
+ if (!(key in value)) {
+ errors.push(`${path}.${key} is required`);
+ }
+ }
+
+ for (const [key, childSchema] of Object.entries(properties)) {
+ if (key in value) {
+ errors.push(...validateSchema(childSchema, (value as Record)[key], `${path}.${key}`));
+ }
+ }
+
+ if (schema.additionalProperties === false) {
+ for (const key of Object.keys(value)) {
+ if (!(key in properties)) {
+ errors.push(`${path}.${key} is not allowed`);
+ }
+ }
+ }
+ }
+
+ return errors;
+}
+
+function matchesType(type: string, value: unknown): boolean {
+ switch (type) {
+ case 'array':
+ return Array.isArray(value);
+ case 'boolean':
+ return typeof value === 'boolean';
+ case 'integer':
+ return typeof value === 'number' && Number.isInteger(value);
+ case 'null':
+ return value === null;
+ case 'number':
+ return typeof value === 'number';
+ case 'object':
+ return isPlainObject(value);
+ case 'string':
+ return typeof value === 'string';
+ default:
+ return true;
+ }
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function deepEqual(a: unknown, b: unknown): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
diff --git a/packages/agent/tsconfig.esm.json b/packages/agent/tsconfig.esm.json
new file mode 100644
index 0000000..624ab17
--- /dev/null
+++ b/packages/agent/tsconfig.esm.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "es2022",
+ "outDir": "dist/esm"
+ }
+}
diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json
new file mode 100644
index 0000000..df063b5
--- /dev/null
+++ b/packages/agent/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/agentic-kit/README.md b/packages/agentic-kit/README.md
index 130b510..6d76101 100644
--- a/packages/agentic-kit/README.md
+++ b/packages/agentic-kit/README.md
@@ -12,7 +12,14 @@
-A unified, streaming-capable interface for multiple LLM providers. Plug in any supported adapter and swap between them at runtime.
+A low-level provider portability layer for LLM applications. `agentic-kit`
+provides:
+
+- provider-independent `ModelDescriptor` and `Context` types
+- structured streaming events for text, reasoning, and tool calls
+- model and provider registries
+- cross-provider message normalization for replay and handoff
+- a one-release compatibility wrapper for the legacy `AgentKit.generate()` API
## Installation
@@ -23,55 +30,60 @@ npm install agentic-kit
## Quick Start
+### Structured API
+
```typescript
-import { createOllamaKit, createMultiProviderKit, OllamaAdapter, AgentKit } from 'agentic-kit';
+import { complete, getModel } from 'agentic-kit';
-// Ollama
-const kit = createOllamaKit('http://localhost:11434');
-const text = await kit.generate({ model: 'mistral', prompt: 'Hello' });
+const model = getModel('openai', 'gpt-4o-mini');
+const message = await complete(model!, {
+ messages: [{ role: 'user', content: 'Hello', timestamp: Date.now() }],
+});
-// Multi-provider with fallback
-const multi = createMultiProviderKit();
-multi.addProvider(new OllamaAdapter('http://localhost:11434'));
-const reply = await multi.generate({ model: 'mistral', prompt: 'Hello' });
+console.log(message.content);
```
-## Streaming
+### Streaming
```typescript
-await kit.generate(
- { model: 'mistral', prompt: 'Hello', stream: true },
- { onChunk: (chunk) => process.stdout.write(chunk) }
-);
+import { stream, getModel } from 'agentic-kit';
+
+const model = getModel('openai', 'gpt-4o-mini');
+const result = stream(model!, {
+ messages: [{ role: 'user', content: 'Explain tool calling briefly.', timestamp: Date.now() }],
+});
+
+for await (const event of result) {
+ if (event.type === 'text_delta') {
+ process.stdout.write(event.delta);
+ }
+}
```
## API Reference
-### `AgentKit`
+### Core API
-- `.generate(input: GenerateInput, options?: StreamingOptions): Promise`
-- `.addProvider(provider: AgentProvider): void`
-- `.setProvider(name: string): void`
-- `.listProviders(): string[]`
-- `.getCurrentProvider(): AgentProvider | undefined`
+- `stream(model: ModelDescriptor, context: Context, options?: StreamOptions)`
+- `complete(model: ModelDescriptor, context: Context, options?: StreamOptions)`
+- `completeText(model: ModelDescriptor, context: Context, options?: StreamOptions)`
+- `registerModel(model: ModelDescriptor): void`
+- `registerProvider(provider: ProviderAdapter): void`
+- `getModel(provider: string, modelId: string): ModelDescriptor | undefined`
+- `getModels(provider?: string): ModelDescriptor[]`
-### `GenerateInput`
+### Legacy Compatibility API
+
+`AgentKit` is still available for one transition release:
```ts
interface GenerateInput {
model: string;
- prompt: string;
+ prompt?: string;
+ messages?: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
stream?: boolean;
}
```
-### `StreamingOptions`
-
-```ts
-interface StreamingOptions {
- onChunk?: (chunk: string) => void;
- onStateChange?: (state: string) => void;
- onError?: (error: Error) => void;
- onComplete?: () => void;
-}
-```
+Use `createOpenAIKit`, `createAnthropicKit`, `createOllamaKit`, or
+`createMultiProviderKit()` if you still need the old prompt-only entrypoint.
diff --git a/packages/agentic-kit/__tests__/adapter.test.ts b/packages/agentic-kit/__tests__/adapter.test.ts
index 89ed43e..b186f64 100644
--- a/packages/agentic-kit/__tests__/adapter.test.ts
+++ b/packages/agentic-kit/__tests__/adapter.test.ts
@@ -1,130 +1,414 @@
-import fetch from 'cross-fetch';
-import { TextEncoder } from 'util';
+import {
+ AgentKit,
+ type AssistantMessage,
+ createAssistantMessageEventStream,
+ getMessageText,
+ type ModelDescriptor,
+ type ProviderAdapter,
+ transformMessages,
+} from '../src';
-import { AgentKit, OllamaAdapter } from '../src';
+function createFakeModel(): ModelDescriptor {
+ return {
+ id: 'demo',
+ name: 'Demo',
+ api: 'fake-api',
+ provider: 'fake',
+ baseUrl: 'http://fake.local',
+ input: ['text'],
+ reasoning: false,
+ tools: true,
+ };
+}
-describe('AgentKit', () => {
- let kit: AgentKit;
+function createAssistantMessage(
+ overrides: Partial = {}
+): AssistantMessage {
+ return {
+ role: 'assistant',
+ api: 'fake-api',
+ provider: 'fake',
+ model: 'demo',
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop',
+ timestamp: Date.now(),
+ content: [{ type: 'text', text: 'hello world' }],
+ ...overrides,
+ };
+}
- beforeEach(() => {
- kit = new AgentKit();
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
+describe('agentic-kit core', () => {
+ it('transforms cross-provider thinking and inserts orphaned tool results', () => {
+ const sourceModel = createFakeModel();
+ const targetModel: ModelDescriptor = {
+ ...sourceModel,
+ provider: 'other',
+ api: 'other-api',
+ id: 'other-model',
+ };
- // ── Provider management ─────────────────────────────────────────────────────
+ const messages = transformMessages(
+ [
+ {
+ role: 'assistant',
+ api: sourceModel.api,
+ provider: sourceModel.provider,
+ model: sourceModel.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'toolUse',
+ timestamp: Date.now(),
+ content: [
+ { type: 'thinking', thinking: 'private chain' },
+ { type: 'toolCall', id: 'call|1', name: 'lookup', arguments: { city: 'Paris' } },
+ ],
+ },
+ { role: 'user', content: 'continue', timestamp: Date.now() },
+ ],
+ targetModel
+ );
- it('addProvider registers and sets as current', () => {
- const adapter = new OllamaAdapter();
- kit.addProvider(adapter);
- expect(kit.getCurrentProvider()).toBe(adapter);
- expect(kit.listProviders()).toEqual(['ollama']);
+ expect(messages[0]).toMatchObject({
+ role: 'assistant',
+ content: [
+ { type: 'text', text: 'private chain ' },
+ { type: 'toolCall', id: 'call|1', name: 'lookup' },
+ ],
+ });
+ expect(messages[1]).toMatchObject({
+ role: 'toolResult',
+ toolCallId: 'call|1',
+ isError: true,
+ });
});
- it('addProvider returns this for chaining', () => {
- const adapter = new OllamaAdapter();
- const result = kit.addProvider(adapter);
- expect(result).toBe(kit);
- });
+ it('drops aborted assistant turns and rewrites tool result ids for stricter providers', () => {
+ const sourceModel = createFakeModel();
+ const targetModel: ModelDescriptor = {
+ ...sourceModel,
+ provider: 'anthropic',
+ api: 'anthropic-messages',
+ id: 'claude-demo',
+ };
- it('setProvider switches current provider', () => {
- const a = new OllamaAdapter('http://a:11434');
- const b = new OllamaAdapter('http://b:11434');
- // Give b a different name via subclass to test switching
- (b as any).name = 'ollama-b';
- kit.addProvider(a).addProvider(b);
- kit.setProvider('ollama-b');
- expect(kit.getCurrentProvider()).toBe(b);
- });
+ const transformed = transformMessages(
+ [
+ {
+ role: 'assistant',
+ api: sourceModel.api,
+ provider: sourceModel.provider,
+ model: sourceModel.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'toolUse',
+ timestamp: Date.now(),
+ content: [
+ { type: 'toolCall', id: 'call|needs-normalizing', name: 'lookup', arguments: { city: 'Paris' } },
+ ],
+ },
+ {
+ role: 'toolResult',
+ toolCallId: 'call|needs-normalizing',
+ toolName: 'lookup',
+ content: [{ type: 'text', text: 'ok' }],
+ isError: false,
+ timestamp: Date.now(),
+ },
+ {
+ role: 'assistant',
+ api: sourceModel.api,
+ provider: sourceModel.provider,
+ model: sourceModel.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'aborted',
+ errorMessage: 'cancelled',
+ timestamp: Date.now(),
+ content: [{ type: 'text', text: 'partial' }],
+ },
+ ],
+ targetModel
+ );
- it('setProvider throws for unknown provider', () => {
- expect(() => kit.setProvider('unknown')).toThrow("Provider 'unknown' not found");
+ expect(transformed).toHaveLength(2);
+ expect(transformed[0]).toMatchObject({
+ role: 'assistant',
+ content: [
+ {
+ type: 'toolCall',
+ id: 'call_needs-normalizing',
+ name: 'lookup',
+ },
+ ],
+ });
+ expect(transformed[1]).toMatchObject({
+ role: 'toolResult',
+ toolCallId: 'call_needs-normalizing',
+ toolName: 'lookup',
+ isError: false,
+ });
});
- it('generate throws when no provider set', async () => {
- await expect(kit.generate({ model: 'llama3', prompt: 'hi' })).rejects.toThrow(
- 'No provider set'
- );
- });
+ it('normalizes short mistral tool-call ids without hanging and keeps tool results aligned', () => {
+ const sourceModel = createFakeModel();
+ const targetModel: ModelDescriptor = {
+ ...sourceModel,
+ provider: 'mistral',
+ api: 'openai-compatible',
+ id: 'mistral-demo',
+ compat: {
+ toolCallIdFormat: 'mistral9',
+ },
+ };
- // ── generate (non-streaming) ────────────────────────────────────────────────
+ const transformed = transformMessages(
+ [
+ {
+ ...createAssistantMessage({
+ api: sourceModel.api,
+ provider: sourceModel.provider,
+ model: sourceModel.id,
+ stopReason: 'toolUse',
+ content: [{ type: 'toolCall', id: '%', name: 'lookup', arguments: { city: 'Paris' } }],
+ }),
+ },
+ {
+ role: 'toolResult',
+ toolCallId: '%',
+ toolName: 'lookup',
+ content: [{ type: 'text', text: 'ok' }],
+ isError: false,
+ timestamp: Date.now(),
+ },
+ ],
+ targetModel
+ );
- it('generate returns text via current provider', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ response: 'hello world', done: true }),
+ expect(transformed).toHaveLength(2);
+ expect(transformed[0]?.role).toBe('assistant');
+ expect(transformed[1]).toMatchObject({
+ role: 'toolResult',
+ toolName: 'lookup',
+ isError: false,
});
- kit.addProvider(new OllamaAdapter());
- const result = await kit.generate({ model: 'llama3', prompt: 'hi' });
- expect(result).toBe('hello world');
- });
+ const assistant = transformed[0];
+ if (!assistant || assistant.role !== 'assistant') {
+ throw new Error('Expected assistant message');
+ }
- it('generate calls onComplete callback', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ response: 'ok', done: true }),
- });
+ const toolCall = assistant.content[0];
+ if (!toolCall || toolCall.type !== 'toolCall') {
+ throw new Error('Expected tool call content');
+ }
- const onComplete = jest.fn();
- kit.addProvider(new OllamaAdapter());
- await kit.generate({ model: 'llama3', prompt: 'hi' }, { onComplete });
- expect(onComplete).toHaveBeenCalledTimes(1);
+ expect(toolCall.id).toMatch(/^[A-Za-z0-9]{9}$/);
+ expect(toolCall.id).not.toContain('_');
+ expect(transformed[1]).toMatchObject({
+ role: 'toolResult',
+ toolCallId: toolCall.id,
+ });
});
- // ── generate (streaming) ────────────────────────────────────────────────────
+ it('keeps the legacy AgentKit generate API working through structured streams', async () => {
+ const provider: ProviderAdapter & { name: string } = {
+ api: 'fake-api',
+ provider: 'fake',
+ name: 'fake',
+ createModel: () => createFakeModel(),
+ stream: () => {
+ const stream = createAssistantMessageEventStream();
+ const message = createAssistantMessage();
- it('generate streams chunks via onChunk', async () => {
- const chunkData = JSON.stringify({ response: 'chunk1', done: false }) + '\n';
- const encoded = new TextEncoder().encode(chunkData);
- const mockReader = {
- read: jest.fn()
- .mockResolvedValueOnce({ done: false, value: encoded })
- .mockResolvedValueOnce({ done: true, value: undefined }),
+ queueMicrotask(() => {
+ stream.push({ type: 'start', partial: { ...message, content: [{ type: 'text', text: '' }] } });
+ stream.push({
+ type: 'text_start',
+ contentIndex: 0,
+ partial: { ...message, content: [{ type: 'text', text: '' }] },
+ });
+ stream.push({
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'hello world',
+ partial: message,
+ });
+ stream.push({
+ type: 'text_end',
+ contentIndex: 0,
+ content: 'hello world',
+ partial: message,
+ });
+ stream.push({ type: 'done', reason: 'stop', message });
+ stream.end(message);
+ });
+
+ return stream;
+ },
};
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- body: { getReader: () => mockReader },
- });
- kit.addProvider(new OllamaAdapter());
+ const kit = new AgentKit().addProvider(provider);
const chunks: string[] = [];
await kit.generate(
- { model: 'llama3', prompt: 'hi', stream: true },
- { onChunk: (c) => chunks.push(c) }
+ { model: 'demo', prompt: 'hi', stream: true },
+ { onChunk: (chunk) => chunks.push(chunk) }
);
- expect(chunks).toEqual(['chunk1']);
+
+ expect(chunks).toEqual(['hello world']);
+ await expect(kit.generate({ model: 'demo', prompt: 'hi' })).resolves.toBe('hello world');
});
- it('generate calls onError and rethrows on failure', async () => {
- (fetch as jest.Mock).mockRejectedValueOnce(new Error('network error'));
+ it('rejects legacy generate when a provider returns a terminal error in non-stream mode', async () => {
+ const provider: ProviderAdapter & { name: string } = {
+ api: 'fake-api',
+ provider: 'fake',
+ name: 'fake',
+ createModel: () => createFakeModel(),
+ stream: () => {
+ const stream = createAssistantMessageEventStream();
+ const failure = createAssistantMessage({
+ stopReason: 'error',
+ errorMessage: 'provider failed',
+ content: [{ type: 'text', text: '' }],
+ });
+
+ queueMicrotask(() => {
+ stream.push({ type: 'error', reason: 'error', error: failure });
+ stream.end(failure);
+ });
+
+ return stream;
+ },
+ };
+ const kit = new AgentKit().addProvider(provider);
+ const onComplete = jest.fn();
const onError = jest.fn();
- kit.addProvider(new OllamaAdapter());
+ const onStateChange = jest.fn();
+
await expect(
- kit.generate({ model: 'llama3', prompt: 'hi' }, { onError })
- ).rejects.toThrow('network error');
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
+ kit.generate(
+ { model: 'demo', prompt: 'hi' },
+ { onComplete, onError, onStateChange }
+ )
+ ).rejects.toThrow('provider failed');
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onComplete).not.toHaveBeenCalled();
+ expect(onStateChange).not.toHaveBeenCalledWith('complete');
});
- // ── listModels ──────────────────────────────────────────────────────────────
+ it('rejects legacy generate when a provider returns a terminal error in stream mode', async () => {
+ const provider: ProviderAdapter & { name: string } = {
+ api: 'fake-api',
+ provider: 'fake',
+ name: 'fake',
+ createModel: () => createFakeModel(),
+ stream: () => {
+ const stream = createAssistantMessageEventStream();
+ const partial = createAssistantMessage({
+ content: [{ type: 'text', text: 'partial' }],
+ });
+ const failure = createAssistantMessage({
+ stopReason: 'error',
+ errorMessage: 'provider failed',
+ content: [{ type: 'text', text: 'partial' }],
+ });
- it('listModels delegates to current provider', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ models: [{ name: 'llama3' }, { name: 'mistral' }] }),
- });
+ queueMicrotask(() => {
+ stream.push({ type: 'start', partial: { ...partial, content: [{ type: 'text', text: '' }] } });
+ stream.push({
+ type: 'text_start',
+ contentIndex: 0,
+ partial: { ...partial, content: [{ type: 'text', text: '' }] },
+ });
+ stream.push({
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'partial',
+ partial,
+ });
+ stream.push({ type: 'error', reason: 'error', error: failure });
+ stream.end(failure);
+ });
- kit.addProvider(new OllamaAdapter());
- const models = await kit.listModels();
- expect(models).toEqual(['llama3', 'mistral']);
+ return stream;
+ },
+ };
+
+ const kit = new AgentKit().addProvider(provider);
+ const chunks: string[] = [];
+ const onComplete = jest.fn();
+ const onError = jest.fn();
+ const onStateChange = jest.fn();
+
+ await expect(
+ kit.generate(
+ { model: 'demo', prompt: 'hi', stream: true },
+ {
+ onChunk: (chunk) => chunks.push(chunk),
+ onComplete,
+ onError,
+ onStateChange,
+ }
+ )
+ ).rejects.toThrow('provider failed');
+
+ expect(chunks).toEqual(['partial']);
+ expect(onStateChange).toHaveBeenCalledWith('streaming');
+ expect(onComplete).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledTimes(1);
});
- it('listModels returns empty array when no provider set', async () => {
- const models = await kit.listModels();
- expect(models).toEqual([]);
+ it('extracts assistant text from mixed content blocks', () => {
+ const text = getMessageText({
+ role: 'assistant',
+ api: 'fake-api',
+ provider: 'fake',
+ model: 'demo',
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop',
+ timestamp: Date.now(),
+ content: [
+ { type: 'thinking', thinking: 'ignore me' },
+ { type: 'text', text: 'hello ' },
+ { type: 'toolCall', id: 'tool_1', name: 'lookup', arguments: { city: 'Paris' } },
+ { type: 'text', text: 'world' },
+ ],
+ });
+
+ expect(text).toBe('hello world');
});
});
diff --git a/packages/agentic-kit/__tests__/tsconfig.json b/packages/agentic-kit/__tests__/tsconfig.json
new file mode 100644
index 0000000..6c4fda5
--- /dev/null
+++ b/packages/agentic-kit/__tests__/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "..",
+ "types": ["jest", "node"]
+ },
+ "include": ["./**/*.ts", "../src/**/*.ts"],
+ "exclude": ["../dist", "../node_modules"]
+}
diff --git a/packages/agentic-kit/jest.config.js b/packages/agentic-kit/jest.config.js
index 596537b..c539b86 100644
--- a/packages/agentic-kit/jest.config.js
+++ b/packages/agentic-kit/jest.config.js
@@ -7,7 +7,7 @@ module.exports = {
'ts-jest',
{
babelConfig: false,
- tsconfig: 'tsconfig.json',
+ tsconfig: '__tests__/tsconfig.json',
},
],
},
@@ -16,6 +16,7 @@ module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1',
'^@agentic-kit/(.*)$': '/../$1/src',
},
setupFilesAfterEnv: ['/jest.setup.js']
diff --git a/packages/agentic-kit/src/event-stream.ts b/packages/agentic-kit/src/event-stream.ts
new file mode 100644
index 0000000..fd73bc7
--- /dev/null
+++ b/packages/agentic-kit/src/event-stream.ts
@@ -0,0 +1,105 @@
+import type {
+ AssistantMessage,
+ AssistantMessageEvent,
+ AssistantMessageEventStream,
+} from './types.js';
+
+export class EventStream implements AsyncIterable {
+ private readonly queue: TEvent[] = [];
+ private readonly waiting: Array<(result: IteratorResult) => void> = [];
+ private done = false;
+ private readonly finalResultPromise: Promise;
+ private resolveFinalResult!: (value: TResult) => void;
+
+ constructor(
+ private readonly isTerminal: (event: TEvent) => boolean,
+ private readonly extractResult: (event: TEvent) => TResult
+ ) {
+ this.finalResultPromise = new Promise((resolve) => {
+ this.resolveFinalResult = resolve;
+ });
+ }
+
+ push(event: TEvent): void {
+ if (this.done) {
+ return;
+ }
+
+ if (this.isTerminal(event)) {
+ this.done = true;
+ this.resolveFinalResult(this.extractResult(event));
+ }
+
+ const waiter = this.waiting.shift();
+ if (waiter) {
+ waiter({ value: event, done: false });
+ return;
+ }
+
+ this.queue.push(event);
+ }
+
+ end(result?: TResult): void {
+ this.done = true;
+ if (result !== undefined) {
+ this.resolveFinalResult(result);
+ }
+
+ while (this.waiting.length > 0) {
+ this.waiting.shift()!({ value: undefined as never, done: true });
+ }
+ }
+
+ async *[Symbol.asyncIterator](): AsyncIterator {
+ while (true) {
+ if (this.queue.length > 0) {
+ yield this.queue.shift()!;
+ continue;
+ }
+
+ if (this.done) {
+ return;
+ }
+
+ const next = await new Promise>((resolve) => {
+ this.waiting.push(resolve);
+ });
+
+ if (next.done) {
+ return;
+ }
+
+ yield next.value;
+ }
+ }
+
+ result(): Promise {
+ return this.finalResultPromise;
+ }
+}
+
+export class DefaultAssistantMessageEventStream
+ extends EventStream
+ implements AssistantMessageEventStream
+{
+ constructor() {
+ super(
+ (event) => event.type === 'done' || event.type === 'error',
+ (event) => {
+ if (event.type === 'done') {
+ return event.message;
+ }
+
+ if (event.type === 'error') {
+ return event.error;
+ }
+
+ throw new Error('Unexpected terminal event');
+ }
+ );
+ }
+}
+
+export function createAssistantMessageEventStream(): DefaultAssistantMessageEventStream {
+ return new DefaultAssistantMessageEventStream();
+}
diff --git a/packages/agentic-kit/src/index.ts b/packages/agentic-kit/src/index.ts
index ffc6926..f8b0fd2 100644
--- a/packages/agentic-kit/src/index.ts
+++ b/packages/agentic-kit/src/index.ts
@@ -1,104 +1,180 @@
-import OllamaClient, { ChatMessage, GenerateInput } from '@agentic-kit/ollama';
-import { AnthropicAdapter, type AnthropicOptions } from '@agentic-kit/anthropic';
-import { OpenAIAdapter, type OpenAIOptions } from '@agentic-kit/openai';
-
-export type { ChatMessage, GenerateInput };
-export { OllamaClient };
-export { AnthropicAdapter, type AnthropicOptions };
-export { OpenAIAdapter, type OpenAIOptions };
-
-// ─── Interfaces ───────────────────────────────────────────────────────────────
-
-export interface StreamingOptions {
- onChunk?: (chunk: string) => void;
- onStateChange?: (state: string) => void;
- onError?: (error: Error) => void;
- onComplete?: () => void;
-}
+import { ANTHROPIC_MODELS, AnthropicAdapter, type AnthropicOptions } from '@agentic-kit/anthropic';
+import { OLLAMA_MODELS,OllamaAdapter, OllamaClient } from '@agentic-kit/ollama';
+import {
+ OPENAI_COMPATIBLE_MODELS,
+ OpenAIAdapter,
+ type OpenAIOptions,
+} from '@agentic-kit/openai';
-export interface AgentProvider {
- readonly name: string;
- generate(input: GenerateInput): Promise;
- generateStreaming(input: GenerateInput, onChunk: (chunk: string) => void): Promise;
- listModels?(): Promise;
-}
+import { createAssistantMessageEventStream, EventStream } from './event-stream.js';
+import { getMessageText, normalizeContext } from './messages.js';
+import {
+ clearModels,
+ getModel,
+ getModels,
+ getProviders as getModelProviders,
+ registerModel,
+ registerModels,
+} from './model-registry.js';
+import {
+ clearProviders,
+ getProvider as getRegisteredProvider,
+ getProviders as getRegisteredProviders,
+ registerProvider,
+ unregisterProviders,
+} from './provider-registry.js';
+import { transformMessages } from './transform-messages.js';
+import type {
+ AssistantMessage,
+ AssistantMessageEventStream,
+ Context,
+ LegacyChatMessage,
+ LegacyGenerateInput,
+ LegacyStreamingOptions,
+ ModelDescriptor,
+ ProviderAdapter,
+ StreamOptions,
+} from './types.js';
-// ─── Ollama Adapter ───────────────────────────────────────────────────────────
+export * from './event-stream.js';
+export * from './messages.js';
+export * from './transform-messages.js';
+export * from './types.js';
-export class OllamaAdapter implements AgentProvider {
- public readonly name = 'ollama';
- private client: OllamaClient;
+export { createAssistantMessageEventStream, EventStream, OllamaClient };
+export { AnthropicAdapter, OllamaAdapter, OpenAIAdapter };
+export type { AnthropicOptions, OpenAIOptions };
- constructor(baseUrl?: string) {
- this.client = new OllamaClient(baseUrl);
- }
+export type ChatMessage = LegacyChatMessage;
+export type GenerateInput = LegacyGenerateInput;
- async generate(input: GenerateInput): Promise {
- return this.client.generate(input) as Promise;
- }
+type NamedProviderAdapter = ProviderAdapter & { name?: string };
- async generateStreaming(input: GenerateInput, onChunk: (chunk: string) => void): Promise {
- return this.client.generate(input, onChunk);
- }
+registerModels([...OPENAI_COMPATIBLE_MODELS, ...ANTHROPIC_MODELS, ...OLLAMA_MODELS]);
+registerProvider(new OpenAIAdapter());
+registerProvider(new AnthropicAdapter({ apiKey: '' }));
+registerProvider(new OllamaAdapter());
- async listModels(): Promise {
- return this.client.listModels();
+export function stream(
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+): AssistantMessageEventStream {
+ const provider = getRegisteredProvider(model.api);
+ if (!provider) {
+ throw new Error(`No provider registered for api '${model.api}'`);
}
+
+ return streamWithProvider(provider, model, context, options);
}
-// ─── AgentKit ─────────────────────────────────────────────────────────────────
+export async function complete(
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+): Promise {
+ const response = stream(model, context, options);
+ return response.result();
+}
+
+export async function completeText(
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+): Promise {
+ const message = await complete(model, context, options);
+ return getMessageText(message);
+}
export class AgentKit {
- private providers = new Map();
- private current?: AgentProvider;
+ private readonly providers = new Map();
+ private current?: NamedProviderAdapter;
- addProvider(provider: AgentProvider): this {
- this.providers.set(provider.name, provider);
- if (!this.current) this.current = provider;
+ addProvider(provider: NamedProviderAdapter): this {
+ this.providers.set(getProviderName(provider), provider);
+ if (!this.current) {
+ this.current = provider;
+ }
return this;
}
setProvider(name: string): this {
const provider = this.providers.get(name);
- if (!provider) throw new Error(`Provider '${name}' not found`);
+ if (!provider) {
+ throw new Error(`Provider '${name}' not found`);
+ }
this.current = provider;
return this;
}
- getCurrentProvider(): AgentProvider | undefined { return this.current; }
- listProviders(): string[] { return Array.from(this.providers.keys()); }
+ getCurrentProvider(): NamedProviderAdapter | undefined {
+ return this.current;
+ }
+
+ listProviders(): string[] {
+ return Array.from(this.providers.keys());
+ }
+
+ async generate(
+ input: LegacyGenerateInput,
+ options?: LegacyStreamingOptions
+ ): Promise {
+ if (!this.current) {
+ throw new Error('No provider set. Call addProvider() first.');
+ }
+
+ const provider = this.current;
+ const model = createLegacyModel(provider, input.model, input.maxTokens);
+ const context = legacyInputToContext(input);
+ const streamOptions: StreamOptions = {
+ maxTokens: input.maxTokens,
+ temperature: input.temperature,
+ };
- async generate(input: GenerateInput, options?: StreamingOptions): Promise {
- if (!this.current) throw new Error('No provider set. Call addProvider() first.');
+ if (options?.onChunk || input.stream) {
+ options?.onStateChange?.('streaming');
- if (options?.onChunk) {
try {
- await this.current.generateStreaming(input, options.onChunk);
- options.onComplete?.();
- } catch (err) {
- options.onError?.(err as Error);
- throw err;
+ const response = streamWithProvider(provider, model, context, streamOptions);
+ for await (const event of response) {
+ if (event.type === 'text_delta') {
+ options?.onChunk?.(event.delta);
+ }
+ }
+ assertLegacyGenerateSucceeded(await response.result());
+ options?.onComplete?.();
+ } catch (error) {
+ options?.onError?.(error as Error);
+ throw error;
}
+
return;
}
try {
- const result = await this.current.generate(input);
+ const message = assertLegacyGenerateSucceeded(
+ await completeWithProvider(provider, model, context, streamOptions)
+ );
+ options?.onStateChange?.('complete');
+ const text = getMessageText(message);
options?.onComplete?.();
- return result;
- } catch (err) {
- options?.onError?.(err as Error);
- throw err;
+ return text;
+ } catch (error) {
+ options?.onError?.(error as Error);
+ throw error;
}
}
async listModels(): Promise {
- return this.current?.listModels?.() ?? [];
+ if (!this.current?.listModels) {
+ return [];
+ }
+
+ const models = await this.current.listModels();
+ return models.map((model) => (typeof model === 'string' ? model : model.id));
}
}
-// ─── Factory helpers ──────────────────────────────────────────────────────────
-
export function createOllamaKit(baseUrl?: string): AgentKit {
return new AgentKit().addProvider(new OllamaAdapter(baseUrl));
}
@@ -107,10 +183,128 @@ export function createAnthropicKit(options: AnthropicOptions | string): AgentKit
return new AgentKit().addProvider(new AnthropicAdapter(options));
}
-export function createOpenAIKit(options: OpenAIOptions | string): AgentKit {
+export function createOpenAIKit(options?: OpenAIOptions | string): AgentKit {
return new AgentKit().addProvider(new OpenAIAdapter(options));
}
export function createMultiProviderKit(): AgentKit {
return new AgentKit();
}
+
+export {
+ clearModels,
+ clearProviders,
+ getModel,
+ getModelProviders,
+ getModels,
+ getRegisteredProvider as getProvider,
+ getRegisteredProviders,
+ registerModel,
+ registerModels,
+ registerProvider,
+ unregisterProviders,
+};
+
+function legacyInputToContext(input: LegacyGenerateInput): Context {
+ const messages: Context['messages'] = input.messages
+ ? input.messages
+ .filter((message) => message.role !== 'system')
+ .map((message) =>
+ message.role === 'assistant'
+ ? {
+ role: 'assistant' as const,
+ api: 'legacy',
+ provider: 'legacy',
+ model: input.model,
+ content: [{ type: 'text' as const, text: message.content }],
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop' as const,
+ timestamp: Date.now(),
+ }
+ : {
+ role: 'user' as const,
+ content: message.content,
+ timestamp: Date.now(),
+ }
+ )
+ : [{ role: 'user' as const, content: input.prompt ?? '', timestamp: Date.now() }];
+
+ const systemPrompt =
+ input.system ?? input.messages?.find((message) => message.role === 'system')?.content;
+
+ return {
+ systemPrompt,
+ messages,
+ };
+}
+
+function createLegacyModel(
+ provider: NamedProviderAdapter,
+ modelId: string,
+ maxTokens?: number
+): ModelDescriptor {
+ if (provider.createModel) {
+ return provider.createModel(modelId, {
+ maxOutputTokens: maxTokens,
+ });
+ }
+
+ return {
+ id: modelId,
+ name: modelId,
+ api: provider.api,
+ provider: getProviderName(provider),
+ baseUrl: '',
+ input: ['text'],
+ reasoning: false,
+ tools: true,
+ maxOutputTokens: maxTokens,
+ };
+}
+
+function streamWithProvider(
+ provider: ProviderAdapter,
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+): AssistantMessageEventStream {
+ const normalized = normalizeContext(context);
+ const transformedContext: Context = {
+ ...normalized,
+ messages: transformMessages(normalized.messages, model),
+ };
+ return provider.stream(model, transformedContext, options);
+}
+
+async function completeWithProvider(
+ provider: ProviderAdapter,
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+): Promise {
+ const response = streamWithProvider(provider, model, context, options);
+ return response.result();
+}
+
+function getProviderName(provider: NamedProviderAdapter): string {
+ return provider.name ?? provider.provider;
+}
+
+function assertLegacyGenerateSucceeded(message: AssistantMessage): AssistantMessage {
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
+ throw new Error(
+ message.errorMessage ??
+ (message.stopReason === 'aborted' ? 'Generation aborted.' : 'Generation failed.'),
+ { cause: message }
+ );
+ }
+
+ return message;
+}
diff --git a/packages/agentic-kit/src/messages.ts b/packages/agentic-kit/src/messages.ts
new file mode 100644
index 0000000..5a01e72
--- /dev/null
+++ b/packages/agentic-kit/src/messages.ts
@@ -0,0 +1,115 @@
+import type {
+ AssistantMessage,
+ Context,
+ ImageContent,
+ Message,
+ ModelDescriptor,
+ TextContent,
+ ToolCallContent,
+ ToolResultMessage,
+ Usage,
+ UserMessage,
+} from './types.js';
+
+export function createEmptyUsage(): Usage {
+ return {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ total: 0,
+ },
+ };
+}
+
+export function calculateUsageCost(model: ModelDescriptor, usage: Usage): Usage['cost'] {
+ const schedule = model.cost;
+ usage.cost.input = ((schedule?.input ?? 0) / 1_000_000) * usage.input;
+ usage.cost.output = ((schedule?.output ?? 0) / 1_000_000) * usage.output;
+ usage.cost.cacheRead = ((schedule?.cacheRead ?? 0) / 1_000_000) * usage.cacheRead;
+ usage.cost.cacheWrite = ((schedule?.cacheWrite ?? 0) / 1_000_000) * usage.cacheWrite;
+ usage.cost.total =
+ usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
+ return usage.cost;
+}
+
+export function createAssistantMessage(model: ModelDescriptor): AssistantMessage {
+ return {
+ role: 'assistant',
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ content: [],
+ usage: createEmptyUsage(),
+ stopReason: 'stop',
+ timestamp: Date.now(),
+ };
+}
+
+export function createTextContent(text = ''): TextContent {
+ return { type: 'text', text };
+}
+
+export function createImageContent(data: string, mimeType: string): ImageContent {
+ return { type: 'image', data, mimeType };
+}
+
+export function createToolCall(id: string, name: string): ToolCallContent {
+ return { type: 'toolCall', id, name, arguments: {}, rawArguments: '' };
+}
+
+export function createUserMessage(content: UserMessage['content']): UserMessage {
+ return {
+ role: 'user',
+ content,
+ timestamp: Date.now(),
+ };
+}
+
+export function createToolResultMessage(
+ toolCallId: string,
+ toolName: string,
+ content: ToolResultMessage['content'],
+ isError = false
+): ToolResultMessage {
+ return {
+ role: 'toolResult',
+ toolCallId,
+ toolName,
+ content,
+ isError,
+ timestamp: Date.now(),
+ };
+}
+
+export function getMessageText(message: AssistantMessage): string {
+ return message.content
+ .filter((block): block is TextContent => block.type === 'text')
+ .map((block) => block.text)
+ .join('');
+}
+
+export function cloneMessage(message: TMessage): TMessage {
+ return JSON.parse(JSON.stringify(message)) as TMessage;
+}
+
+export function normalizeContext(context: Context): Context {
+ return {
+ systemPrompt: context.systemPrompt,
+ tools: context.tools ?? [],
+ messages: context.messages.map((message) => ensureTimestamp(cloneMessage(message))),
+ };
+}
+
+function ensureTimestamp(message: TMessage): TMessage {
+ if (typeof message.timestamp !== 'number') {
+ (message as Message).timestamp = Date.now();
+ }
+ return message;
+}
diff --git a/packages/agentic-kit/src/model-registry.ts b/packages/agentic-kit/src/model-registry.ts
new file mode 100644
index 0000000..10e50fd
--- /dev/null
+++ b/packages/agentic-kit/src/model-registry.ts
@@ -0,0 +1,35 @@
+import type { ModelDescriptor } from './types.js';
+
+const modelsByProvider = new Map>();
+
+export function registerModel(model: ModelDescriptor): void {
+ const providerModels = modelsByProvider.get(model.provider) ?? new Map();
+ providerModels.set(model.id, model);
+ modelsByProvider.set(model.provider, providerModels);
+}
+
+export function registerModels(models: ModelDescriptor[]): void {
+ for (const model of models) {
+ registerModel(model);
+ }
+}
+
+export function getModel(provider: string, modelId: string): ModelDescriptor | undefined {
+ return modelsByProvider.get(provider)?.get(modelId);
+}
+
+export function getModels(provider?: string): ModelDescriptor[] {
+ if (provider) {
+ return Array.from(modelsByProvider.get(provider)?.values() ?? []);
+ }
+
+ return Array.from(modelsByProvider.values()).flatMap((entries) => Array.from(entries.values()));
+}
+
+export function getProviders(): string[] {
+ return Array.from(modelsByProvider.keys());
+}
+
+export function clearModels(): void {
+ modelsByProvider.clear();
+}
diff --git a/packages/agentic-kit/src/provider-registry.ts b/packages/agentic-kit/src/provider-registry.ts
new file mode 100644
index 0000000..7d77214
--- /dev/null
+++ b/packages/agentic-kit/src/provider-registry.ts
@@ -0,0 +1,32 @@
+import type { ProviderAdapter } from './types.js';
+
+type RegisteredProvider = {
+ adapter: ProviderAdapter;
+ sourceId?: string;
+};
+
+const providersByApi = new Map();
+
+export function registerProvider(adapter: ProviderAdapter, sourceId?: string): void {
+ providersByApi.set(adapter.api, { adapter, sourceId });
+}
+
+export function getProvider(api: string): ProviderAdapter | undefined {
+ return providersByApi.get(api)?.adapter;
+}
+
+export function getProviders(): ProviderAdapter[] {
+ return Array.from(providersByApi.values(), (entry) => entry.adapter);
+}
+
+export function unregisterProviders(sourceId: string): void {
+ for (const [api, entry] of providersByApi.entries()) {
+ if (entry.sourceId === sourceId) {
+ providersByApi.delete(api);
+ }
+ }
+}
+
+export function clearProviders(): void {
+ providersByApi.clear();
+}
diff --git a/packages/agentic-kit/src/transform-messages.ts b/packages/agentic-kit/src/transform-messages.ts
new file mode 100644
index 0000000..589f325
--- /dev/null
+++ b/packages/agentic-kit/src/transform-messages.ts
@@ -0,0 +1,193 @@
+import type {
+ Message,
+ ModelDescriptor,
+ TextContent,
+ ThinkingContent,
+ ToolCallContent,
+ ToolResultMessage,
+} from './types.js';
+
+export function transformMessages(messages: Message[], model: ModelDescriptor): Message[] {
+ const toolCallIdMap = new Map();
+
+ const transformed: Message[] = messages.flatMap((message): Message[] => {
+ if (message.role !== 'assistant') {
+ if (message.role === 'toolResult') {
+ const normalizedId = toolCallIdMap.get(message.toolCallId);
+ if (normalizedId && normalizedId !== message.toolCallId) {
+ return [{ ...message, toolCallId: normalizedId }];
+ }
+ }
+
+ return [message];
+ }
+
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
+ return [];
+ }
+
+ const sameModel =
+ message.provider === model.provider &&
+ message.api === model.api &&
+ message.model === model.id;
+
+ const content: Array = message.content.flatMap(
+ (block): Array => {
+ if (block.type === 'thinking') {
+ if (sameModel) {
+ return [block];
+ }
+
+ if (!block.thinking.trim()) {
+ return [];
+ }
+
+ const thinkingAsText: TextContent = {
+ type: 'text',
+ text: `${block.thinking} `,
+ };
+ return [thinkingAsText];
+ }
+
+ if (block.type === 'toolCall') {
+ const normalizedId = normalizeToolCallId(block.id, model);
+ if (normalizedId !== block.id) {
+ toolCallIdMap.set(block.id, normalizedId);
+ }
+
+ const toolCall: ToolCallContent =
+ normalizedId === block.id ? block : { ...block, id: normalizedId };
+ return [toolCall];
+ }
+
+ return [block];
+ }
+ );
+
+ return [{ ...message, content }];
+ });
+
+ return insertSyntheticToolResults(transformed);
+}
+
+function insertSyntheticToolResults(messages: Message[]): Message[] {
+ const result: Message[] = [];
+ let pendingToolCalls: ToolCallContent[] = [];
+ let seenToolResults = new Set();
+
+ for (const message of messages) {
+ if (message.role === 'assistant') {
+ flushPendingToolCalls(result, pendingToolCalls, seenToolResults);
+
+ pendingToolCalls = message.content.filter(
+ (block): block is ToolCallContent => block.type === 'toolCall'
+ );
+ seenToolResults = new Set();
+ result.push(message);
+ continue;
+ }
+
+ if (message.role === 'toolResult') {
+ seenToolResults.add(message.toolCallId);
+ result.push(message);
+ continue;
+ }
+
+ flushPendingToolCalls(result, pendingToolCalls, seenToolResults);
+ pendingToolCalls = [];
+ seenToolResults = new Set();
+ result.push(message);
+ }
+
+ flushPendingToolCalls(result, pendingToolCalls, seenToolResults);
+ return result;
+}
+
+function flushPendingToolCalls(
+ result: Message[],
+ pendingToolCalls: ToolCallContent[],
+ seenToolResults: Set
+): void {
+ if (pendingToolCalls.length === 0) {
+ return;
+ }
+
+ for (const toolCall of pendingToolCalls) {
+ if (seenToolResults.has(toolCall.id)) {
+ continue;
+ }
+
+ const synthetic: ToolResultMessage = {
+ role: 'toolResult',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ content: [{ type: 'text', text: 'No result provided.' }],
+ isError: true,
+ timestamp: Date.now(),
+ };
+ result.push(synthetic);
+ }
+}
+
+function normalizeToolCallId(id: string, model: ModelDescriptor): string {
+ const format = model.compat?.toolCallIdFormat ?? defaultToolCallIdFormat(model);
+ if (format === 'passthrough') {
+ return id;
+ }
+
+ if (format === 'mistral9') {
+ const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, '');
+ if (alphanumeric.length >= 9) {
+ return alphanumeric.slice(0, 9);
+ }
+
+ return stableAlphanumericId(id, 9);
+ }
+
+ const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
+ if (safe.length > 0) {
+ return safe;
+ }
+
+ return stableId(id, 24, /^[a-zA-Z0-9_-]+$/);
+}
+
+function defaultToolCallIdFormat(model: ModelDescriptor): 'passthrough' | 'safe64' | 'mistral9' {
+ if (model.provider === 'anthropic' || model.api === 'anthropic-messages') {
+ return 'safe64';
+ }
+
+ return 'passthrough';
+}
+
+function stableId(input: string, length: number, pattern: RegExp): string {
+ let hash = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
+ }
+
+ let value = `tc_${hash.toString(36)}`;
+ while (!pattern.test(value)) {
+ value = value.replace(/[^a-zA-Z0-9_-]/g, '_');
+ }
+
+ if (value.length >= length) {
+ return value.slice(0, length);
+ }
+
+ return value.padEnd(length, '0');
+}
+
+function stableAlphanumericId(input: string, length: number): string {
+ let hash = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
+ }
+
+ const value = `tc${hash.toString(36)}`.replace(/[^a-zA-Z0-9]/g, '');
+ if (value.length >= length) {
+ return value.slice(0, length);
+ }
+
+ return value.padEnd(length, '0');
+}
diff --git a/packages/agentic-kit/src/types.ts b/packages/agentic-kit/src/types.ts
new file mode 100644
index 0000000..00b432c
--- /dev/null
+++ b/packages/agentic-kit/src/types.ts
@@ -0,0 +1,215 @@
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
+
+export interface JsonObject {
+ [key: string]: JsonValue | undefined;
+}
+
+export interface JsonSchema {
+ $id?: string;
+ $ref?: string;
+ additionalProperties?: boolean | JsonSchema;
+ allOf?: JsonSchema[];
+ anyOf?: JsonSchema[];
+ const?: JsonValue;
+ default?: JsonValue;
+ description?: string;
+ enum?: JsonValue[];
+ format?: string;
+ items?: JsonSchema | JsonSchema[];
+ maxItems?: number;
+ maxLength?: number;
+ maximum?: number;
+ minItems?: number;
+ minLength?: number;
+ minimum?: number;
+ oneOf?: JsonSchema[];
+ pattern?: string;
+ properties?: Record;
+ required?: string[];
+ title?: string;
+ type?: string | string[];
+}
+
+export type InputCapability = 'text' | 'image';
+
+export interface CostSchedule {
+ input?: number;
+ output?: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+}
+
+export interface OpenAICompatibleCompat {
+ maxTokensField?: 'max_completion_tokens' | 'max_tokens';
+ reasoningFormat?: 'none' | 'openai';
+ supportsReasoningEffort?: boolean;
+ supportsStrictToolSchema?: boolean;
+ supportsUsageInStreaming?: boolean;
+ toolCallIdFormat?: 'passthrough' | 'safe64' | 'mistral9';
+ requiresToolResultName?: boolean;
+}
+
+export interface ModelDescriptor {
+ id: string;
+ name: string;
+ api: string;
+ provider: string;
+ baseUrl: string;
+ input: InputCapability[];
+ reasoning: boolean;
+ tools?: boolean;
+ contextWindow?: number;
+ maxOutputTokens?: number;
+ cost?: CostSchedule;
+ headers?: Record;
+ compat?: OpenAICompatibleCompat;
+}
+
+export interface TextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface ImageContent {
+ type: 'image';
+ data: string;
+ mimeType: string;
+}
+
+export interface ThinkingContent {
+ type: 'thinking';
+ thinking: string;
+ signature?: string;
+}
+
+export interface ToolCallContent {
+ type: 'toolCall';
+ id: string;
+ name: string;
+ arguments: Record;
+ rawArguments?: string;
+}
+
+export interface Usage {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ totalTokens: number;
+ cost: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
+}
+
+export type StopReason = 'stop' | 'length' | 'toolUse' | 'error' | 'aborted';
+
+export interface UserMessage {
+ role: 'user';
+ content: string | Array;
+ timestamp: number;
+}
+
+export interface AssistantMessage {
+ role: 'assistant';
+ content: Array;
+ api: string;
+ provider: string;
+ model: string;
+ usage: Usage;
+ stopReason: StopReason;
+ errorMessage?: string;
+ timestamp: number;
+}
+
+export interface ToolResultMessage {
+ role: 'toolResult';
+ toolCallId: string;
+ toolName: string;
+ content: Array;
+ isError: boolean;
+ details?: TDetails;
+ timestamp: number;
+}
+
+export type Message = UserMessage | AssistantMessage | ToolResultMessage;
+
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: TSchema;
+}
+
+export interface Context {
+ systemPrompt?: string;
+ messages: Message[];
+ tools?: ToolDefinition[];
+}
+
+export type CacheRetention = 'none' | 'short' | 'long';
+
+export interface StreamOptions {
+ apiKey?: string;
+ cacheRetention?: CacheRetention;
+ headers?: Record;
+ maxRetryDelayMs?: number;
+ maxTokens?: number;
+ metadata?: Record;
+ onPayload?: (payload: unknown) => void;
+ reasoning?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+ sessionId?: string;
+ signal?: AbortSignal;
+ temperature?: number;
+}
+
+export type AssistantMessageEvent =
+ | { type: 'start'; partial: AssistantMessage }
+ | { type: 'text_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'text_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'text_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'thinking_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'thinking_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'thinking_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'toolcall_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'toolcall_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'toolcall_end'; contentIndex: number; toolCall: ToolCallContent; partial: AssistantMessage }
+ | { type: 'done'; reason: Extract; message: AssistantMessage }
+ | { type: 'error'; reason: Extract; error: AssistantMessage };
+
+export interface ProviderAdapter {
+ api: string;
+ provider: string;
+ createModel?: (modelId: string, overrides?: Partial) => ModelDescriptor;
+ listModels?: (options?: Pick) => Promise>;
+ stream: (model: ModelDescriptor, context: Context, options?: TOptions) => AssistantMessageEventStream;
+}
+
+export interface LegacyChatMessage {
+ role: 'system' | 'user' | 'assistant';
+ content: string;
+}
+
+export interface LegacyGenerateInput {
+ model: string;
+ prompt?: string;
+ messages?: LegacyChatMessage[];
+ system?: string;
+ stream?: boolean;
+ temperature?: number;
+ maxTokens?: number;
+}
+
+export interface LegacyStreamingOptions {
+ onChunk?: (chunk: string) => void;
+ onComplete?: () => void;
+ onError?: (error: Error) => void;
+ onStateChange?: (state: string) => void;
+}
+
+export interface AssistantMessageEventStream extends AsyncIterable {
+ result(): Promise;
+}
diff --git a/packages/anthropic/__tests__/anthropic.test.ts b/packages/anthropic/__tests__/anthropic.test.ts
index f378099..82c9881 100644
--- a/packages/anthropic/__tests__/anthropic.test.ts
+++ b/packages/anthropic/__tests__/anthropic.test.ts
@@ -1,145 +1,87 @@
import fetch from 'cross-fetch';
+import { TextEncoder } from 'util';
+
import { AnthropicAdapter } from '../src';
-const apiKey = 'sk-ant-test';
+function createStreamingResponse(frames: string[]) {
+ const encoded = new TextEncoder().encode(frames.join('\n\n'));
+ const reader = {
+ read: jest
+ .fn()
+ .mockResolvedValueOnce({ done: false, value: encoded })
+ .mockResolvedValueOnce({ done: true, value: undefined }),
+ };
-describe('AnthropicAdapter', () => {
- let adapter: AnthropicAdapter;
+ return {
+ ok: true,
+ body: { getReader: () => reader },
+ };
+}
+describe('AnthropicAdapter', () => {
beforeEach(() => {
- adapter = new AnthropicAdapter(apiKey);
jest.clearAllMocks();
});
- afterEach(() => { jest.resetAllMocks(); });
-
- // ── Constructor ─────────────────────────────────────────────────────────────
-
- it('accepts a plain string as apiKey', () => {
- expect(new AnthropicAdapter('key').name).toBe('anthropic');
- });
-
- it('accepts an options object', () => {
- expect(new AnthropicAdapter({ apiKey: 'key', defaultModel: 'claude-haiku-4-5' }).name).toBe('anthropic');
- });
-
- // ── listModels ──────────────────────────────────────────────────────────────
-
- it('listModels returns Claude model names', async () => {
- const models = await adapter.listModels();
- expect(models).toContain('claude-opus-4-5');
- expect(models).toContain('claude-sonnet-4-5');
- });
-
- // ── generate ────────────────────────────────────────────────────────────────
-
- it('generate returns content text', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => msgResponse('Hello from Claude'),
+ it('streams text and tool use events', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce(
+ createStreamingResponse([
+ 'event: message_start\ndata: {"type":"message_start","index":0,"message":{"usage":{"input_tokens":12,"output_tokens":0}}}',
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
+ 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}',
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_1","name":"lookup","input":{}}}',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"city\\":\\"Pa"}}',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ris\\"}"}}',
+ 'event: content_block_stop\ndata: {"type":"content_block_stop","index":1}',
+ 'event: message_delta\ndata: {"type":"message_delta","index":0,"delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":4}}',
+ 'event: message_stop\ndata: {"type":"message_stop","index":0}',
+ ])
+ );
+
+ const adapter = new AnthropicAdapter({ apiKey: 'test-key' });
+ const model = adapter.createModel('claude-sonnet-4-5');
+ const stream = adapter.stream(model, {
+ systemPrompt: 'Be useful',
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ tools: [
+ {
+ name: 'lookup',
+ description: 'Lookup a city',
+ parameters: {
+ type: 'object',
+ properties: {
+ city: { type: 'string' },
+ },
+ required: ['city'],
+ },
+ },
+ ],
});
- expect(await adapter.generate({ model: 'claude-opus-4-5', prompt: 'Hi' })).toBe('Hello from Claude');
- });
-
- it('generate sends correct headers', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => msgResponse('ok') });
- await adapter.generate({ model: 'claude-opus-4-5', prompt: 'hi' });
- const [url, opts] = (fetch as jest.Mock).mock.calls[0];
- expect(url).toContain('/v1/messages');
- expect(opts.headers['x-api-key']).toBe(apiKey);
- expect(opts.headers['anthropic-version']).toBe('2023-06-01');
- });
-
- it('generate uses defaultModel when model is empty', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => msgResponse('ok') });
- const a = new AnthropicAdapter({ apiKey, defaultModel: 'claude-haiku-4-5' });
- await a.generate({ model: '', prompt: 'hi' });
- expect(JSON.parse((fetch as jest.Mock).mock.calls[0][1].body).model).toBe('claude-haiku-4-5');
- });
-
- it('generate places system at top-level body field', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => msgResponse('ok') });
- await adapter.generate({ model: 'claude-opus-4-5', prompt: 'hi', system: 'Be concise.' });
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.system).toBe('Be concise.');
- expect(body.messages.every((m: { role: string }) => m.role !== 'system')).toBe(true);
- });
- it('generate filters system role out of messages[]', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => msgResponse('ok') });
- await adapter.generate({
- model: 'claude-opus-4-5',
- messages: [{ role: 'system', content: 'Be helpful.' }, { role: 'user', content: 'Hello' }],
- system: 'Be helpful.',
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ }
+
+ const message = await stream.result();
+ const toolCall = message.content.find((block) => block.type === 'toolCall');
+
+ expect(eventTypes).toEqual(
+ expect.arrayContaining(['text_start', 'text_delta', 'toolcall_start', 'toolcall_delta', 'toolcall_end', 'done'])
+ );
+ expect(message.stopReason).toBe('toolUse');
+ expect(message.usage.input).toBe(12);
+ expect(message.usage.output).toBe(4);
+ expect(toolCall).toMatchObject({
+ type: 'toolCall',
+ id: 'tool_1',
+ name: 'lookup',
+ arguments: { city: 'Paris' },
});
- const { messages } = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(messages).toEqual([{ role: 'user', content: 'Hello' }]);
- });
-
- it('generate passes temperature and maxTokens', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => msgResponse('ok') });
- await adapter.generate({ model: 'claude-opus-4-5', prompt: 'hi', temperature: 0.3, maxTokens: 256 });
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.temperature).toBe(0.3);
- expect(body.max_tokens).toBe(256);
- });
-
- it('generate throws on non-ok response', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' });
- await expect(adapter.generate({ model: 'claude-opus-4-5', prompt: 'hi' })).rejects.toThrow('Anthropic error 401');
- });
-
- // ── generateStreaming ───────────────────────────────────────────────────────
-
- it('generateStreaming yields text_delta chunks', async () => {
- const sse = sseReader([
- { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } },
- { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world' } },
- { type: 'message_stop' },
- ]);
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, body: { getReader: () => sse } });
- const chunks: string[] = [];
- await adapter.generateStreaming({ model: 'claude-opus-4-5', prompt: 'hi' }, (c) => chunks.push(c));
- expect(chunks).toEqual(['Hello', ' world']);
- });
- it('generateStreaming ignores non-delta events', async () => {
- const sse = sseReader([
- { type: 'ping' },
- { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hi' } },
- { type: 'message_stop' },
- ]);
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, body: { getReader: () => sse } });
- const chunks: string[] = [];
- await adapter.generateStreaming({ model: 'claude-opus-4-5', prompt: 'hi' }, (c) => chunks.push(c));
- expect(chunks).toEqual(['Hi']);
- });
-
- // ── proxy baseUrl ───────────────────────────────────────────────────────────
-
- it('uses custom baseUrl', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => msgResponse('ok') });
- const a = new AnthropicAdapter({ apiKey, baseUrl: 'https://my-proxy.example.com' });
- await a.generate({ model: 'claude-opus-4-5', prompt: 'hi' });
- expect((fetch as jest.Mock).mock.calls[0][0]).toBe('https://my-proxy.example.com/v1/messages');
+ const request = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
+ expect(request.system).toBe('Be useful');
+ expect(request.tools[0].input_schema.required).toEqual(['city']);
});
});
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-function msgResponse(text: string) {
- return { content: [{ type: 'text', text }], stop_reason: 'end_turn' };
-}
-
-function sseReader(events: object[]) {
- const lines = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('');
- const encoded = new TextEncoder().encode(lines);
- let done = false;
- return {
- read: jest.fn().mockImplementation(async () => {
- if (done) return { done: true, value: undefined };
- done = true;
- return { done: false, value: encoded };
- }),
- };
-}
diff --git a/packages/anthropic/__tests__/tsconfig.json b/packages/anthropic/__tests__/tsconfig.json
new file mode 100644
index 0000000..6c4fda5
--- /dev/null
+++ b/packages/anthropic/__tests__/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "..",
+ "types": ["jest", "node"]
+ },
+ "include": ["./**/*.ts", "../src/**/*.ts"],
+ "exclude": ["../dist", "../node_modules"]
+}
diff --git a/packages/anthropic/jest.config.js b/packages/anthropic/jest.config.js
index 787afe1..e11f478 100644
--- a/packages/anthropic/jest.config.js
+++ b/packages/anthropic/jest.config.js
@@ -7,7 +7,7 @@ module.exports = {
'ts-jest',
{
babelConfig: false,
- tsconfig: 'tsconfig.json',
+ tsconfig: '__tests__/tsconfig.json',
},
],
},
diff --git a/packages/anthropic/src/index.ts b/packages/anthropic/src/index.ts
index 7354607..79efe7c 100644
--- a/packages/anthropic/src/index.ts
+++ b/packages/anthropic/src/index.ts
@@ -1,152 +1,889 @@
import fetch from 'cross-fetch';
-// ─── Types ────────────────────────────────────────────────────────────────────
+type JsonPrimitive = string | number | boolean | null;
+type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
-export interface ChatMessage {
- role: 'system' | 'user' | 'assistant';
- content: string;
+interface JsonObject {
+ [key: string]: JsonValue | undefined;
}
-export interface GenerateInput {
- model: string;
- prompt?: string;
- messages?: ChatMessage[];
- system?: string;
- stream?: boolean;
- temperature?: number;
+interface JsonSchema {
+ additionalProperties?: boolean | JsonSchema;
+ description?: string;
+ enum?: JsonValue[];
+ items?: JsonSchema | JsonSchema[];
+ properties?: Record;
+ required?: string[];
+ type?: string | string[];
+}
+
+interface ModelDescriptor {
+ id: string;
+ name: string;
+ api: string;
+ provider: string;
+ baseUrl: string;
+ input: Array<'text' | 'image'>;
+ reasoning: boolean;
+ tools?: boolean;
+ contextWindow?: number;
+ maxOutputTokens?: number;
+ cost?: {
+ input?: number;
+ output?: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+ };
+ headers?: Record;
+}
+
+interface TextContent {
+ type: 'text';
+ text: string;
+}
+
+interface ImageContent {
+ type: 'image';
+ data: string;
+ mimeType: string;
+}
+
+interface ThinkingContent {
+ type: 'thinking';
+ thinking: string;
+}
+
+interface ToolCallContent {
+ type: 'toolCall';
+ id: string;
+ name: string;
+ arguments: Record;
+ rawArguments?: string;
+}
+
+interface Usage {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ totalTokens: number;
+ cost: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
+}
+
+type Message =
+ | {
+ role: 'user';
+ content: string | Array;
+ timestamp: number;
+ }
+ | {
+ role: 'assistant';
+ content: Array;
+ api: string;
+ provider: string;
+ model: string;
+ usage: Usage;
+ stopReason: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted';
+ errorMessage?: string;
+ timestamp: number;
+ }
+ | {
+ role: 'toolResult';
+ toolCallId: string;
+ toolName: string;
+ content: Array;
+ isError: boolean;
+ details?: unknown;
+ timestamp: number;
+ };
+
+interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: JsonSchema;
+}
+
+interface Context {
+ systemPrompt?: string;
+ messages: Message[];
+ tools?: ToolDefinition[];
+}
+
+interface StreamOptions {
+ apiKey?: string;
+ headers?: Record;
maxTokens?: number;
+ onPayload?: (payload: unknown) => void;
+ reasoning?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+ signal?: AbortSignal;
+ temperature?: number;
+}
+
+type AssistantMessage = Extract;
+
+type AssistantMessageEvent =
+ | { type: 'start'; partial: AssistantMessage }
+ | { type: 'text_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'text_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'text_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'thinking_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'thinking_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'thinking_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'toolcall_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'toolcall_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'toolcall_end'; contentIndex: number; toolCall: ToolCallContent; partial: AssistantMessage }
+ | { type: 'done'; reason: 'stop' | 'length' | 'toolUse'; message: AssistantMessage }
+ | { type: 'error'; reason: 'error' | 'aborted'; error: AssistantMessage };
+
+interface AssistantMessageEventStream extends AsyncIterable {
+ result(): Promise;
+}
+
+class EventStream implements AsyncIterable {
+ private readonly queue: TEvent[] = [];
+ private readonly waiting: Array<(result: IteratorResult) => void> = [];
+ private done = false;
+ private readonly finalResultPromise: Promise;
+ private resolveFinalResult!: (result: TResult) => void;
+
+ constructor(
+ private readonly isTerminal: (event: TEvent) => boolean,
+ private readonly extractResult: (event: TEvent) => TResult
+ ) {
+ this.finalResultPromise = new Promise((resolve) => {
+ this.resolveFinalResult = resolve;
+ });
+ }
+
+ push(event: TEvent): void {
+ if (this.done) {
+ return;
+ }
+
+ if (this.isTerminal(event)) {
+ this.done = true;
+ this.resolveFinalResult(this.extractResult(event));
+ }
+
+ const waiter = this.waiting.shift();
+ if (waiter) {
+ waiter({ value: event, done: false });
+ return;
+ }
+
+ this.queue.push(event);
+ }
+
+ end(result?: TResult): void {
+ this.done = true;
+ if (result !== undefined) {
+ this.resolveFinalResult(result);
+ }
+
+ while (this.waiting.length > 0) {
+ this.waiting.shift()!({ value: undefined as never, done: true });
+ }
+ }
+
+ async *[Symbol.asyncIterator](): AsyncIterator {
+ while (true) {
+ if (this.queue.length > 0) {
+ yield this.queue.shift()!;
+ continue;
+ }
+
+ if (this.done) {
+ return;
+ }
+
+ const next = await new Promise>((resolve) => {
+ this.waiting.push(resolve);
+ });
+
+ if (next.done) {
+ return;
+ }
+
+ yield next.value;
+ }
+ }
+
+ result(): Promise {
+ return this.finalResultPromise;
+ }
+}
+
+class DefaultAssistantMessageEventStream
+ extends EventStream
+ implements AssistantMessageEventStream
+{
+ constructor() {
+ super(
+ (event) => event.type === 'done' || event.type === 'error',
+ (event) => {
+ if (event.type === 'done') {
+ return event.message;
+ }
+ if (event.type === 'error') {
+ return event.error;
+ }
+ throw new Error('Unexpected terminal event');
+ }
+ );
+ }
}
export interface AnthropicOptions {
apiKey: string;
- /** Override base URL — useful for proxies */
baseUrl?: string;
- /** Default model when GenerateInput.model is not set */
defaultModel?: string;
- /** Default max_tokens (Anthropic requires this field) */
+ headers?: Record;
maxTokens?: number;
+ provider?: string;
}
-// ─── Internal response types ──────────────────────────────────────────────────
+export const ANTHROPIC_MODELS: ModelDescriptor[] = [
+ {
+ id: 'claude-sonnet-4-5',
+ name: 'Claude Sonnet 4.5',
+ api: 'anthropic-messages',
+ provider: 'anthropic',
+ baseUrl: 'https://api.anthropic.com/v1',
+ input: ['text', 'image'],
+ reasoning: true,
+ tools: true,
+ contextWindow: 200000,
+ maxOutputTokens: 8192,
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+ },
+ {
+ id: 'claude-haiku-4-5',
+ name: 'Claude Haiku 4.5',
+ api: 'anthropic-messages',
+ provider: 'anthropic',
+ baseUrl: 'https://api.anthropic.com/v1',
+ input: ['text', 'image'],
+ reasoning: false,
+ tools: true,
+ contextWindow: 200000,
+ maxOutputTokens: 8192,
+ cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
+ },
+];
+
+export class AnthropicAdapter {
+ public readonly api = 'anthropic-messages';
+ public readonly provider: string;
+ public readonly name: string;
+
+ private readonly apiKey: string;
+ private readonly baseUrl: string;
+ private readonly defaultHeaders?: Record;
+ private readonly defaultModel: string;
+ private readonly defaultMaxTokens: number;
+
+ constructor(options: AnthropicOptions | string) {
+ const normalized: AnthropicOptions =
+ typeof options === 'string' ? { apiKey: options } : options;
+
+ this.apiKey = normalized.apiKey;
+ this.baseUrl = normalizeBaseUrl(normalized.baseUrl ?? 'https://api.anthropic.com/v1');
+ this.provider = normalized.provider ?? 'anthropic';
+ this.name = this.provider;
+ this.defaultHeaders = normalized.headers;
+ this.defaultModel = normalized.defaultModel ?? 'claude-sonnet-4-5';
+ this.defaultMaxTokens = normalized.maxTokens ?? 4096;
+ }
+
+ createModel(modelId: string, overrides?: Partial): ModelDescriptor {
+ const builtIn = ANTHROPIC_MODELS.find(
+ (model) => model.provider === this.provider && model.id === modelId
+ );
+
+ if (builtIn) {
+ return {
+ ...builtIn,
+ baseUrl: this.baseUrl,
+ headers: { ...(builtIn.headers ?? {}), ...(this.defaultHeaders ?? {}), ...(overrides?.headers ?? {}) },
+ ...overrides,
+ };
+ }
+
+ return {
+ id: modelId,
+ name: modelId,
+ api: this.api,
+ provider: this.provider,
+ baseUrl: this.baseUrl,
+ input: ['text', 'image'],
+ reasoning: false,
+ tools: true,
+ maxOutputTokens: overrides?.maxOutputTokens ?? this.defaultMaxTokens,
+ headers: { ...(this.defaultHeaders ?? {}), ...(overrides?.headers ?? {}) },
+ ...overrides,
+ };
+ }
+
+ async listModels(): Promise> {
+ return ANTHROPIC_MODELS.filter((model) => model.provider === this.provider);
+ }
+
+ stream(
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+ ): AssistantMessageEventStream {
+ const stream = new DefaultAssistantMessageEventStream();
+ const output = createAssistantMessage(model);
+
+ void (async () => {
+ const anthropicIndexMap = new Map();
+ const blockKinds = new Map();
+ let doneReason: 'stop' | 'length' | 'toolUse' = 'stop';
+
+ try {
+ const body = buildRequestBody(model, context, {
+ ...options,
+ maxTokens: options?.maxTokens ?? model.maxOutputTokens ?? this.defaultMaxTokens,
+ });
+ options?.onPayload?.(body);
+
+ const response = await fetch(`${model.baseUrl}/messages`, {
+ method: 'POST',
+ headers: this.buildHeaders(model, options),
+ body: JSON.stringify(body),
+ signal: options?.signal,
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(`Anthropic error ${response.status}: ${text}`);
+ }
+
+ stream.push({ type: 'start', partial: clone(output) });
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error('No response body');
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+ const frames = buffer.split('\n\n');
+ buffer = frames.pop() ?? '';
+
+ for (const frame of frames) {
+ const parsed = parseSseFrame(frame);
+ if (!parsed?.data) {
+ continue;
+ }
+
+ if (parsed.data === '[DONE]') {
+ calculateUsageCost(model, output.usage);
+ stream.push({ type: 'done', reason: doneReason, message: clone(output) });
+ stream.end(output);
+ return;
+ }
+
+ const event = JSON.parse(parsed.data) as AnthropicStreamEvent;
+
+ if (event.type === 'message_start') {
+ output.usage.input = event.message?.usage?.input_tokens ?? 0;
+ output.usage.output = event.message?.usage?.output_tokens ?? 0;
+ output.usage.cacheRead = event.message?.usage?.cache_read_input_tokens ?? 0;
+ output.usage.cacheWrite = event.message?.usage?.cache_creation_input_tokens ?? 0;
+ output.usage.totalTokens =
+ output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
+ calculateUsageCost(model, output.usage);
+ continue;
+ }
+
+ if (event.type === 'content_block_start') {
+ const contentIndex = output.content.length;
+ anthropicIndexMap.set(event.index, contentIndex);
+
+ if (event.content_block?.type === 'text') {
+ output.content.push({ type: 'text', text: event.content_block.text ?? '' });
+ blockKinds.set(event.index, 'text');
+ stream.push({ type: 'text_start', contentIndex, partial: clone(output) });
+ if (event.content_block.text) {
+ stream.push({
+ type: 'text_delta',
+ contentIndex,
+ delta: event.content_block.text,
+ partial: clone(output),
+ });
+ }
+ } else if (event.content_block?.type === 'thinking') {
+ output.content.push({ type: 'thinking', thinking: event.content_block.thinking ?? '' });
+ blockKinds.set(event.index, 'thinking');
+ stream.push({ type: 'thinking_start', contentIndex, partial: clone(output) });
+ if (event.content_block.thinking) {
+ stream.push({
+ type: 'thinking_delta',
+ contentIndex,
+ delta: event.content_block.thinking,
+ partial: clone(output),
+ });
+ }
+ } else if (event.content_block?.type === 'tool_use') {
+ const initialInput = event.content_block.input ?? {};
+ const toolCall: ToolCallContent = {
+ type: 'toolCall',
+ id: event.content_block.id ?? `tool_${event.index}`,
+ name: event.content_block.name ?? '',
+ arguments: initialInput,
+ rawArguments:
+ Object.keys(initialInput).length > 0 ? JSON.stringify(initialInput) : '',
+ };
+ output.content.push(toolCall);
+ blockKinds.set(event.index, 'toolCall');
+ stream.push({ type: 'toolcall_start', contentIndex, partial: clone(output) });
+ }
+ continue;
+ }
+
+ if (event.type === 'content_block_delta') {
+ const contentIndex = anthropicIndexMap.get(event.index);
+ if (contentIndex === undefined) {
+ continue;
+ }
+
+ const kind = blockKinds.get(event.index);
+ if (kind === 'text' && event.delta?.type === 'text_delta' && event.delta.text) {
+ const block = output.content[contentIndex] as TextContent;
+ block.text += event.delta.text;
+ stream.push({
+ type: 'text_delta',
+ contentIndex,
+ delta: event.delta.text,
+ partial: clone(output),
+ });
+ } else if (
+ kind === 'thinking' &&
+ event.delta?.type === 'thinking_delta' &&
+ event.delta.thinking
+ ) {
+ const block = output.content[contentIndex] as ThinkingContent;
+ block.thinking += event.delta.thinking;
+ stream.push({
+ type: 'thinking_delta',
+ contentIndex,
+ delta: event.delta.thinking,
+ partial: clone(output),
+ });
+ } else if (
+ kind === 'toolCall' &&
+ event.delta?.type === 'input_json_delta' &&
+ event.delta.partial_json !== undefined
+ ) {
+ const block = output.content[contentIndex] as ToolCallContent;
+ block.rawArguments = `${block.rawArguments ?? ''}${event.delta.partial_json}`;
+ block.arguments = parsePartialJson(block.rawArguments);
+ stream.push({
+ type: 'toolcall_delta',
+ contentIndex,
+ delta: event.delta.partial_json,
+ partial: clone(output),
+ });
+ }
+ continue;
+ }
+
+ if (event.type === 'content_block_stop') {
+ const contentIndex = anthropicIndexMap.get(event.index);
+ if (contentIndex === undefined) {
+ continue;
+ }
-interface MessageResponse {
- content: Array<{ type: 'text'; text: string }>;
- stop_reason: string;
+ const kind = blockKinds.get(event.index);
+ if (kind === 'text') {
+ stream.push({
+ type: 'text_end',
+ contentIndex,
+ content: (output.content[contentIndex] as TextContent).text,
+ partial: clone(output),
+ });
+ } else if (kind === 'thinking') {
+ stream.push({
+ type: 'thinking_end',
+ contentIndex,
+ content: (output.content[contentIndex] as ThinkingContent).thinking,
+ partial: clone(output),
+ });
+ } else if (kind === 'toolCall') {
+ const toolCall = output.content[contentIndex] as ToolCallContent;
+ toolCall.arguments = parsePartialJson(toolCall.rawArguments ?? '');
+ stream.push({
+ type: 'toolcall_end',
+ contentIndex,
+ toolCall: clone(toolCall),
+ partial: clone(output),
+ });
+ }
+ continue;
+ }
+
+ if (event.type === 'message_delta') {
+ if (event.delta?.stop_reason === 'max_tokens') {
+ doneReason = 'length';
+ output.stopReason = 'length';
+ } else if (event.delta?.stop_reason === 'tool_use') {
+ doneReason = 'toolUse';
+ output.stopReason = 'toolUse';
+ }
+
+ if (event.usage) {
+ output.usage.input =
+ event.usage.input_tokens ?? output.usage.input;
+ output.usage.output =
+ event.usage.output_tokens ?? output.usage.output;
+ output.usage.cacheRead =
+ event.usage.cache_read_input_tokens ?? output.usage.cacheRead;
+ output.usage.cacheWrite =
+ event.usage.cache_creation_input_tokens ?? output.usage.cacheWrite;
+ output.usage.totalTokens =
+ output.usage.input +
+ output.usage.output +
+ output.usage.cacheRead +
+ output.usage.cacheWrite;
+ calculateUsageCost(model, output.usage);
+ }
+ continue;
+ }
+
+ if (event.type === 'message_stop') {
+ calculateUsageCost(model, output.usage);
+ stream.push({ type: 'done', reason: doneReason, message: clone(output) });
+ stream.end(output);
+ return;
+ }
+ }
+ }
+
+ calculateUsageCost(model, output.usage);
+ stream.push({ type: 'done', reason: doneReason, message: clone(output) });
+ stream.end(output);
+ } catch (error) {
+ output.stopReason = options?.signal?.aborted ? 'aborted' : 'error';
+ output.errorMessage = error instanceof Error ? error.message : String(error);
+ calculateUsageCost(model, output.usage);
+ stream.push({
+ type: 'error',
+ reason: output.stopReason === 'aborted' ? 'aborted' : 'error',
+ error: clone(output),
+ });
+ stream.end(output);
+ }
+ })();
+
+ return stream;
+ }
+
+ private buildHeaders(
+ model?: ModelDescriptor,
+ options?: Pick
+ ): Record {
+ return {
+ 'Content-Type': 'application/json',
+ 'x-api-key': options?.apiKey ?? this.apiKey,
+ 'anthropic-version': '2023-06-01',
+ ...(this.defaultHeaders ?? {}),
+ ...(model?.headers ?? {}),
+ ...(options?.headers ?? {}),
+ };
+ }
}
-interface StreamEvent {
+interface AnthropicStreamEvent {
type: string;
- index?: number;
- delta?: { type: string; text: string };
+ index: number;
+ message?: {
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_input_tokens?: number;
+ cache_creation_input_tokens?: number;
+ };
+ };
+ content_block?: {
+ type?: string;
+ id?: string;
+ name?: string;
+ text?: string;
+ thinking?: string;
+ input?: Record;
+ };
+ delta?: {
+ type?: string;
+ text?: string;
+ thinking?: string;
+ partial_json?: string;
+ stop_reason?: string | null;
+ };
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_input_tokens?: number;
+ cache_creation_input_tokens?: number;
+ };
}
-// ─── AnthropicAdapter ─────────────────────────────────────────────────────────
+function buildRequestBody(
+ model: ModelDescriptor,
+ context: Context,
+ options?: StreamOptions
+): Record {
+ const body: Record = {
+ model: model.id,
+ messages: context.messages.map(toAnthropicMessage),
+ max_tokens: options?.maxTokens ?? model.maxOutputTokens ?? 4096,
+ stream: true,
+ };
-export class AnthropicAdapter {
- public readonly name = 'anthropic';
+ if (context.systemPrompt) {
+ body.system = context.systemPrompt;
+ }
+ if (options?.temperature !== undefined) {
+ body.temperature = options.temperature;
+ }
+ if (model.reasoning && options?.reasoning) {
+ body.thinking = {
+ type: 'enabled',
+ budget_tokens: clampThinkingBudget(options.reasoning),
+ };
+ }
+ if (context.tools && context.tools.length > 0) {
+ body.tools = context.tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.parameters,
+ }));
+ }
- private apiKey: string;
- private baseUrl: string;
- private defaultModel: string;
- private defaultMaxTokens: number;
+ return body;
+}
- constructor(options: AnthropicOptions | string) {
- const opts: AnthropicOptions =
- typeof options === 'string' ? { apiKey: options } : options;
+function toAnthropicMessage(message: Message): Record {
+ if (message.role === 'user') {
+ return {
+ role: 'user',
+ content:
+ typeof message.content === 'string'
+ ? [{ type: 'text', text: message.content }]
+ : message.content.map((block) =>
+ block.type === 'text'
+ ? { type: 'text', text: block.text }
+ : {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: block.mimeType,
+ data: block.data,
+ },
+ }
+ ),
+ };
+ }
- this.apiKey = opts.apiKey;
- this.baseUrl = opts.baseUrl ?? 'https://api.anthropic.com';
- this.defaultModel = opts.defaultModel ?? 'claude-opus-4-5';
- this.defaultMaxTokens = opts.maxTokens ?? 4096;
+ if (message.role === 'toolResult') {
+ return {
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: message.toolCallId,
+ is_error: message.isError,
+ content: message.content
+ .map((block) =>
+ block.type === 'text' ? block.text : `[image:${block.mimeType};bytes=${block.data.length}]`
+ )
+ .join('\n'),
+ },
+ ],
+ };
}
- async generate(input: GenerateInput): Promise {
- return this._request(input) as Promise;
+ return {
+ role: 'assistant',
+ content: message.content.map((block) => {
+ if (block.type === 'text') {
+ return { type: 'text', text: block.text };
+ }
+ if (block.type === 'thinking') {
+ return { type: 'text', text: `${block.thinking} ` };
+ }
+ return {
+ type: 'tool_use',
+ id: block.id,
+ name: block.name,
+ input: block.arguments,
+ };
+ }),
+ };
+}
+
+function clampThinkingBudget(reasoning: NonNullable): number {
+ switch (reasoning) {
+ case 'minimal':
+ return 256;
+ case 'low':
+ return 1024;
+ case 'medium':
+ return 4096;
+ case 'high':
+ return 8192;
+ case 'xhigh':
+ return 16384;
}
+}
+
+function createAssistantMessage(model: ModelDescriptor): AssistantMessage {
+ return {
+ role: 'assistant',
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ content: [],
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop',
+ timestamp: Date.now(),
+ };
+}
+
+function calculateUsageCost(model: ModelDescriptor, usage: Usage): void {
+ usage.cost.input = ((model.cost?.input ?? 0) / 1_000_000) * usage.input;
+ usage.cost.output = ((model.cost?.output ?? 0) / 1_000_000) * usage.output;
+ usage.cost.cacheRead = ((model.cost?.cacheRead ?? 0) / 1_000_000) * usage.cacheRead;
+ usage.cost.cacheWrite = ((model.cost?.cacheWrite ?? 0) / 1_000_000) * usage.cacheWrite;
+ usage.cost.total =
+ usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
+}
- async generateStreaming(input: GenerateInput, onChunk: (chunk: string) => void): Promise {
- return this._request(input, onChunk) as Promise;
+function normalizeBaseUrl(baseUrl: string): string {
+ const trimmed = baseUrl.replace(/\/+$/, '');
+ if (/\/v\d+$/.test(trimmed)) {
+ return trimmed;
}
+ return `${trimmed}/v1`;
+}
- async listModels(): Promise {
- return ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5'];
+function parsePartialJson(raw: string): Record {
+ const trimmed = raw.trim();
+ if (!trimmed) {
+ return {};
}
- // ── Private ───────────────────────────────────────────────────────────────────
+ try {
+ return JSON.parse(trimmed) as Record;
+ } catch {
+ // continue
+ }
- private async _request(
- input: GenerateInput,
- onChunk?: (chunk: string) => void
- ): Promise {
- const body: Record = {
- model: input.model || this.defaultModel,
- max_tokens: input.maxTokens ?? this.defaultMaxTokens,
- messages: this._buildMessages(input),
- stream: !!onChunk,
- ...(input.system && { system: input.system }),
- ...(input.temperature !== undefined && { temperature: input.temperature }),
- };
+ const completed = completePartialJson(trimmed);
+ if (!completed) {
+ return {};
+ }
- const res = await fetch(`${this.baseUrl}/v1/messages`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-api-key': this.apiKey,
- 'anthropic-version': '2023-06-01',
- },
- body: JSON.stringify(body),
- });
+ try {
+ return JSON.parse(completed) as Record;
+ } catch {
+ return {};
+ }
+}
- if (!res.ok) {
- const text = await res.text().catch(() => '');
- throw new Error(`Anthropic error ${res.status}: ${text}`);
+function completePartialJson(input: string): string | undefined {
+ let output = input;
+ let inString = false;
+ let escaping = false;
+ const stack: string[] = [];
+
+ for (const char of input) {
+ if (escaping) {
+ escaping = false;
+ continue;
}
- if (onChunk) return this._stream(res, onChunk);
+ if (char === '\\') {
+ escaping = true;
+ continue;
+ }
- const data: MessageResponse = await res.json();
- return data.content.map((b) => b.text).join('');
- }
+ if (char === '"') {
+ inString = !inString;
+ continue;
+ }
+
+ if (inString) {
+ continue;
+ }
- private _buildMessages(input: GenerateInput): Array<{ role: string; content: string }> {
- // Anthropic: system goes top-level, only user/assistant in messages[]
- if (input.messages) {
- return input.messages
- .filter((m) => m.role !== 'system')
- .map((m) => ({ role: m.role, content: m.content }));
+ if (char === '{') {
+ stack.push('}');
+ } else if (char === '[') {
+ stack.push(']');
+ } else if (char === '}' || char === ']') {
+ stack.pop();
}
- if (input.prompt) return [{ role: 'user', content: input.prompt }];
- return [];
}
- private async _stream(res: Response, onChunk: (chunk: string) => void): Promise {
- const reader = res.body?.getReader();
- if (!reader) throw new Error('No response body');
+ if (inString) {
+ output += '"';
+ }
- const decoder = new TextDecoder();
- let buffer = '';
+ while (stack.length > 0) {
+ output += stack.pop();
+ }
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- if (!line.startsWith('data: ')) continue;
- const payload = line.slice(6).trim();
- if (!payload) continue;
- try {
- const event: StreamEvent = JSON.parse(payload);
- if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
- if (event.delta.text) onChunk(event.delta.text);
- }
- } catch { /* malformed — skip */ }
- }
+ return output;
+}
+
+function parseSseFrame(frame: string): { event?: string; data?: string } | undefined {
+ const lines = frame.split('\n');
+ const data: string[] = [];
+ let event: string | undefined;
+
+ for (const line of lines) {
+ if (line.startsWith('event:')) {
+ event = line.slice(6).trim();
+ } else if (line.startsWith('data:')) {
+ data.push(line.slice(5).trim());
}
}
+
+ if (!event && data.length === 0) {
+ return undefined;
+ }
+
+ return {
+ event,
+ data: data.join('\n'),
+ };
+}
+
+function clone(value: TValue): TValue {
+ return JSON.parse(JSON.stringify(value)) as TValue;
}
export default AnthropicAdapter;
diff --git a/packages/ollama/README.md b/packages/ollama/README.md
index da8b315..a875078 100644
--- a/packages/ollama/README.md
+++ b/packages/ollama/README.md
@@ -12,7 +12,9 @@
-A JavaScript/TypeScript client for the Ollama LLM server, supporting model listing, text generation, streaming responses, embeddings, and model management.
+A JavaScript/TypeScript client and provider adapter for the Ollama API,
+supporting model listing, structured streaming text generation, embeddings, and
+model management.
## Installation
@@ -52,13 +54,6 @@ await client.pullModel('mistral');
const embedding = await client.generateEmbedding('Compute embeddings');
console.log('Embedding vector length:', embedding.length);
-// Generate a conversational response with context
-const response = await client.generateResponse(
- 'What is the capital of France?',
- 'Geography trivia'
-);
-console.log(response);
-
// Delete a pulled model when done
await client.deleteModel('mistral');
```
@@ -68,12 +63,45 @@ await client.deleteModel('mistral');
- `new OllamaClient(baseUrl?: string)` – defaults to `http://localhost:11434`
- `.listModels(): Promise`
- `.generate(input: GenerateInput, onChunk?: (chunk: string) => void): Promise`
-- `.generateStreamingResponse(prompt: string, onChunk: (chunk: string) => void, context?: string): Promise`
- `.generateEmbedding(text: string): Promise`
-- `.generateResponse(prompt: string, context?: string): Promise`
- `.pullModel(model: string): Promise`
- `.deleteModel(model: string): Promise`
+## Provider Adapter
+
+```typescript
+import { OllamaAdapter } from '@agentic-kit/ollama';
+
+const provider = new OllamaAdapter('http://localhost:11434');
+const model = provider.createModel('llama3');
+```
+
+## Local Live Tests
+
+The package includes a local-only live lane that never talks to hosted
+providers.
+
+```bash
+OLLAMA_LIVE_MODEL=qwen3.5:4b pnpm --filter @agentic-kit/ollama test:live
+```
+
+That default command runs the fast smoke tier. Run the broader suite explicitly
+when you want slower behavioral coverage:
+
+```bash
+OLLAMA_LIVE_MODEL=qwen3.5:4b pnpm --filter @agentic-kit/ollama test:live:extended
+```
+
+Notes:
+
+- The preflight checks `OLLAMA_BASE_URL` first and defaults to `http://127.0.0.1:11434`.
+- The default live model is `qwen3.5:4b`; override `OLLAMA_LIVE_MODEL` only if you want a different local model.
+- If `nomic-embed-text:latest` is installed, the live lane also covers local embeddings. Override it with `OLLAMA_LIVE_EMBED_MODEL` if needed.
+- `smoke` covers fast adapter invariants; `extended` runs the smoke tier plus slower behavioral checks such as reasoning metadata, legacy generate, short multi-turn context, and embeddings.
+- If Ollama is not running, or the configured model is not installed, the live
+ script exits cleanly with a skip message.
+- Normal `pnpm test` runs do not include the live lane.
+
## GenerateInput type
```ts
diff --git a/packages/ollama/__tests__/first.test.ts b/packages/ollama/__tests__/first.test.ts
deleted file mode 100644
index 10df0e8..0000000
--- a/packages/ollama/__tests__/first.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import fetch from 'cross-fetch';
-import { TextEncoder } from 'util';
-
-import OllamaClient, { GenerateInput } from '../src';
-
-describe('OllamaClient', () => {
- let client: OllamaClient;
-
- beforeEach(() => {
- client = new OllamaClient('http://localhost:11434');
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- // ── listModels ──────────────────────────────────────────────────────────────
-
- it('listModels returns names from models array', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- models: [{ name: 'model1' }, { name: 'model2' }],
- }),
- });
- const models = await client.listModels();
- expect(models).toEqual(['model1', 'model2']);
- expect(fetch).toHaveBeenCalledWith('http://localhost:11434/api/tags');
- });
-
- it('listModels falls back to tags array', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ tags: ['model1', 'model2'] }),
- });
- const models = await client.listModels();
- expect(models).toEqual(['model1', 'model2']);
- });
-
- // ── generate (single-shot, /api/generate) ───────────────────────────────────
-
- it('generate returns response text', async () => {
- const input: GenerateInput = { model: 'llama3', prompt: 'hello' };
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ response: 'hi there', done: true }),
- });
- const text = await client.generate(input);
- expect(text).toBe('hi there');
- expect(fetch).toHaveBeenCalledWith('http://localhost:11434/api/generate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ model: 'llama3', prompt: 'hello', stream: false }),
- });
- });
-
- it('generate passes system prompt to /api/generate', async () => {
- const input: GenerateInput = { model: 'llama3', prompt: 'hello', system: 'Be concise.' };
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ response: 'ok', done: true }),
- });
- await client.generate(input);
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.system).toBe('Be concise.');
- });
-
- it('generate streams via /api/generate', async () => {
- const chunkData = JSON.stringify({ response: 'chunk1', done: false }) + '\n';
- const encoded = new TextEncoder().encode(chunkData);
- const mockReader = {
- read: jest.fn()
- .mockResolvedValueOnce({ done: false, value: encoded })
- .mockResolvedValueOnce({ done: true, value: undefined }),
- };
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- body: { getReader: () => mockReader },
- });
-
- const chunks: string[] = [];
- await client.generate({ model: 'llama3', prompt: 'hello', stream: true }, (c) => chunks.push(c));
- expect(chunks).toEqual(['chunk1']);
- });
-
- // ── generate (multi-turn, /api/chat) ────────────────────────────────────────
-
- it('generate routes to /api/chat when messages provided', async () => {
- const input: GenerateInput = {
- model: 'llama3',
- messages: [{ role: 'user', content: 'hello' }],
- };
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- message: { role: 'assistant', content: 'hi!' },
- done: true,
- }),
- });
- const text = await client.generate(input);
- expect(text).toBe('hi!');
- expect(fetch).toHaveBeenCalledWith('http://localhost:11434/api/chat', expect.any(Object));
- });
-
- it('generate prepends system message in /api/chat', async () => {
- const input: GenerateInput = {
- model: 'llama3',
- messages: [{ role: 'user', content: 'hello' }],
- system: 'You are helpful.',
- };
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ message: { role: 'assistant', content: 'sure' }, done: true }),
- });
- await client.generate(input);
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.messages[0]).toEqual({ role: 'system', content: 'You are helpful.' });
- expect(body.messages[1]).toEqual({ role: 'user', content: 'hello' });
- });
-
- it('generate streams via /api/chat', async () => {
- const chunkData = JSON.stringify({ message: { content: 'chunk1' }, done: false }) + '\n';
- const encoded = new TextEncoder().encode(chunkData);
- const mockReader = {
- read: jest.fn()
- .mockResolvedValueOnce({ done: false, value: encoded })
- .mockResolvedValueOnce({ done: true, value: undefined }),
- };
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- body: { getReader: () => mockReader },
- });
-
- const chunks: string[] = [];
- await client.generate(
- { model: 'llama3', messages: [{ role: 'user', content: 'hi' }] },
- (c) => chunks.push(c)
- );
- expect(chunks).toEqual(['chunk1']);
- });
-
- // ── pullModel / deleteModel ──────────────────────────────────────────────────
-
- it('pullModel resolves', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
- await expect(client.pullModel('llama3')).resolves.toBeUndefined();
- expect(fetch).toHaveBeenCalledWith('http://localhost:11434/api/pull', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: 'llama3' }),
- });
- });
-
- it('deleteModel resolves', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
- await expect(client.deleteModel('llama3')).resolves.toBeUndefined();
- expect(fetch).toHaveBeenCalledWith('http://localhost:11434/api/delete', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: 'llama3' }),
- });
- });
-
- // ── generateEmbedding ───────────────────────────────────────────────────────
-
- it('generateEmbedding returns embedding with default model', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ embedding: [0.1, 0.2, 0.3] }),
- });
- const embedding = await client.generateEmbedding('test text');
- expect(embedding).toEqual([0.1, 0.2, 0.3]);
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.model).toBe('nomic-embed-text');
- expect(body.prompt).toBe('test text');
- });
-
- it('generateEmbedding accepts custom model', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ embedding: [1, 2, 3] }),
- });
- await client.generateEmbedding('text', 'mxbai-embed-large');
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.model).toBe('mxbai-embed-large');
- });
-});
diff --git a/packages/ollama/__tests__/ollama.live.test.ts b/packages/ollama/__tests__/ollama.live.test.ts
new file mode 100644
index 0000000..16b151f
--- /dev/null
+++ b/packages/ollama/__tests__/ollama.live.test.ts
@@ -0,0 +1,215 @@
+import OllamaClient, { OllamaAdapter } from '../src';
+
+const baseUrl = process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434';
+const modelId = process.env.OLLAMA_LIVE_MODEL ?? 'qwen3.5:4b';
+const embedModel = process.env.OLLAMA_LIVE_EMBED_MODEL ?? 'nomic-embed-text:latest';
+const hasEmbedModel = process.env.OLLAMA_LIVE_HAS_EMBED_MODEL === '1';
+const liveSuite = process.env.OLLAMA_LIVE_SUITE ?? 'smoke';
+const runSmoke = liveSuite === 'smoke' || liveSuite === 'extended';
+const runExtended = liveSuite === 'extended';
+const describeSmoke = runSmoke ? describe : describe.skip;
+const describeExtended = runExtended ? describe : describe.skip;
+const itWithEmbeddings = hasEmbedModel ? it : it.skip;
+
+describeSmoke('Ollama live smoke', () => {
+ jest.setTimeout(60_000);
+
+ it('lists the configured live model', async () => {
+ const client = new OllamaClient(baseUrl);
+ const models = await client.listModels();
+
+ expect(models).toContain(modelId);
+ });
+
+ it('streams a constrained single-word response', async () => {
+ const adapter = new OllamaAdapter(baseUrl);
+ const model = adapter.createModel(modelId);
+ const stream = adapter.stream(
+ model,
+ {
+ systemPrompt: 'Follow the user instruction exactly.',
+ messages: [
+ {
+ role: 'user',
+ content: 'Reply with exactly the single word PONG and nothing else.',
+ timestamp: Date.now(),
+ },
+ ],
+ },
+ { temperature: 0, maxTokens: 128 },
+ );
+
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ }
+
+ const message = await stream.result();
+ const text = message.content
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
+ .map((block) => block.text)
+ .join('')
+ .trim()
+ .toLowerCase();
+
+ expect(eventTypes).toEqual(
+ expect.arrayContaining(['text_start', 'text_delta', 'text_end', 'done']),
+ );
+ expect(message.stopReason).toBe('stop');
+ expect(text).toContain('pong');
+ });
+
+ it('reports length stop reasons when generation is deliberately truncated', async () => {
+ const adapter = new OllamaAdapter(baseUrl);
+ const model = adapter.createModel(modelId);
+ const stream = adapter.stream(
+ model,
+ {
+ messages: [
+ {
+ role: 'user',
+ content: 'Write a detailed numbered list from 1 to 100, one item per line.',
+ timestamp: Date.now(),
+ },
+ ],
+ },
+ { temperature: 0, maxTokens: 8 },
+ );
+
+ let doneReason: string | undefined;
+ for await (const event of stream) {
+ if (event.type === 'done') {
+ doneReason = event.reason;
+ }
+ }
+
+ const message = await stream.result();
+ expect(doneReason).toBe('length');
+ expect(message.stopReason).toBe('length');
+ expect(message.usage.output).toBeGreaterThan(0);
+ });
+
+ it('honors abort signals before the response completes', async () => {
+ const adapter = new OllamaAdapter(baseUrl);
+ const model = adapter.createModel(modelId);
+ const controller = new AbortController();
+ const stream = adapter.stream(
+ model,
+ {
+ messages: [
+ {
+ role: 'user',
+ content: 'Count upward forever, one number per line.',
+ timestamp: Date.now(),
+ },
+ ],
+ },
+ { temperature: 0, maxTokens: 512, signal: controller.signal },
+ );
+
+ controller.abort();
+
+ for await (const _event of stream) {
+ // Drain terminal event.
+ }
+
+ const message = await stream.result();
+ expect(message.stopReason).toBe('aborted');
+ });
+});
+
+describeExtended('Ollama live extended', () => {
+ jest.setTimeout(60_000);
+
+ it('surfaces reasoning blocks and usage for reasoning-capable models', async () => {
+ const adapter = new OllamaAdapter(baseUrl);
+ const model = adapter.createModel(modelId);
+ const stream = adapter.stream(
+ model,
+ {
+ messages: [
+ {
+ role: 'user',
+ content: 'Reply with exactly the single word PONG and nothing else.',
+ timestamp: Date.now(),
+ },
+ ],
+ },
+ { temperature: 0 },
+ );
+
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ }
+
+ const message = await stream.result();
+ const text = message.content
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
+ .map((block) => block.text)
+ .join('')
+ .trim()
+ .toLowerCase();
+
+ expect(eventTypes).toContain('done');
+ expect(eventTypes).toContain('text_start');
+ expect(eventTypes).toContain('text_end');
+ expect(message.usage.input).toBeGreaterThan(0);
+ expect(message.usage.output).toBeGreaterThan(0);
+ expect(message.usage.totalTokens).toBeGreaterThan(0);
+ expect(text).toContain('pong');
+
+ const hasThinking = message.content.some((block) => block.type === 'thinking');
+ if (hasThinking) {
+ expect(eventTypes).toContain('thinking_start');
+ expect(eventTypes).toContain('thinking_end');
+ }
+ });
+
+ it('returns visible text through the legacy generate helper', async () => {
+ const client = new OllamaClient(baseUrl);
+ const output = await client.generate({
+ model: modelId,
+ prompt: 'Reply with exactly the single word BLUE and nothing else.',
+ maxTokens: 320,
+ temperature: 0,
+ });
+
+ expect(output.trim().toLowerCase()).toBe('blue');
+ });
+
+ it('maintains short multi-turn context', async () => {
+ const client = new OllamaClient(baseUrl);
+ const output = await client.generate({
+ model: modelId,
+ system: 'Follow the user instruction exactly.',
+ messages: [
+ {
+ role: 'user',
+ content: 'Remember this exact token: MARBLE. Reply with OK only.',
+ },
+ {
+ role: 'assistant',
+ content: 'OK',
+ },
+ {
+ role: 'user',
+ content: 'What token did I ask you to remember? Reply with one word only.',
+ },
+ ],
+ maxTokens: 256,
+ temperature: 0,
+ });
+
+ expect(output.trim().toLowerCase()).toContain('marble');
+ });
+
+ itWithEmbeddings('generates local embeddings when an embed model is installed', async () => {
+ const client = new OllamaClient(baseUrl);
+ const embedding = await client.generateEmbedding('hello world', embedModel);
+
+ expect(Array.isArray(embedding)).toBe(true);
+ expect(embedding.length).toBeGreaterThan(0);
+ expect(embedding.every((value) => Number.isFinite(value))).toBe(true);
+ });
+});
diff --git a/packages/ollama/__tests__/ollama.test.ts b/packages/ollama/__tests__/ollama.test.ts
new file mode 100644
index 0000000..218bbad
--- /dev/null
+++ b/packages/ollama/__tests__/ollama.test.ts
@@ -0,0 +1,223 @@
+import fetch from 'cross-fetch';
+import { PassThrough } from 'stream';
+import { TextEncoder } from 'util';
+
+import OllamaClient, { OllamaAdapter } from '../src';
+
+function createLineResponse(lines: string[]) {
+ const encoded = new TextEncoder().encode(lines.join('\n'));
+ const reader = {
+ read: jest
+ .fn()
+ .mockResolvedValueOnce({ done: false, value: encoded })
+ .mockResolvedValueOnce({ done: true, value: undefined }),
+ };
+
+ return {
+ ok: true,
+ body: { getReader: () => reader },
+ };
+}
+
+function createNodeLineResponse(lines: string[]) {
+ const body = new PassThrough();
+ body.end(lines.join('\n'));
+
+ return {
+ ok: true,
+ body,
+ };
+}
+
+describe('OllamaAdapter', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('streams assistant text through the structured event API', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce(
+ createLineResponse([
+ JSON.stringify({ message: { content: 'Hello' }, done: false }),
+ JSON.stringify({ done: true }),
+ ]),
+ );
+
+ const adapter = new OllamaAdapter('http://localhost:11434');
+ const model = adapter.createModel('llama3');
+ const stream = adapter.stream(model, {
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ });
+
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ }
+
+ const message = await stream.result();
+ expect(eventTypes).toEqual(
+ expect.arrayContaining(['text_start', 'text_delta', 'text_end', 'done']),
+ );
+ expect(message.content).toEqual([{ type: 'text', text: 'Hello' }]);
+ });
+
+ it('supports Node stream response bodies without getReader', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce(
+ createNodeLineResponse([
+ JSON.stringify({ message: { thinking: 'reasoning', content: '' }, done: false }),
+ JSON.stringify({ message: { content: 'Hello from node stream' }, done: false }),
+ JSON.stringify({ done: true, done_reason: 'stop', prompt_eval_count: 12, eval_count: 4 }),
+ ]),
+ );
+
+ const adapter = new OllamaAdapter('http://localhost:11434');
+ const model = adapter.createModel('llama3');
+ const stream = adapter.stream(model, {
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ });
+
+ for await (const _event of stream) {
+ // Drain stream.
+ }
+
+ const message = await stream.result();
+ expect(message.content).toEqual([
+ { type: 'thinking', thinking: 'reasoning' },
+ { type: 'text', text: 'Hello from node stream' },
+ ]);
+ expect(message.usage.input).toBe(12);
+ expect(message.usage.output).toBe(4);
+ expect(message.usage.totalTokens).toBe(16);
+ });
+
+ it('maps Ollama length termination into the structured stop reason', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce(
+ createLineResponse([
+ JSON.stringify({ message: { content: 'partial' }, done: false }),
+ JSON.stringify({ done: true, done_reason: 'length', prompt_eval_count: 9, eval_count: 2 }),
+ ]),
+ );
+
+ const adapter = new OllamaAdapter('http://localhost:11434');
+ const model = adapter.createModel('llama3');
+ const stream = adapter.stream(model, {
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ });
+
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ if (event.type === 'done') {
+ expect(event.reason).toBe('length');
+ }
+ }
+
+ const message = await stream.result();
+ expect(eventTypes).toEqual(
+ expect.arrayContaining(['text_start', 'text_delta', 'text_end', 'done']),
+ );
+ expect(message.stopReason).toBe('length');
+ expect(message.usage.totalTokens).toBe(11);
+ });
+
+ it('serializes system prompts, assistant state, and tool results into Ollama chat messages', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce(
+ createLineResponse([JSON.stringify({ done: true })]),
+ );
+
+ const adapter = new OllamaAdapter('http://127.0.0.1:11434');
+ const model = adapter.createModel('llama3');
+ const stream = adapter.stream(model, {
+ systemPrompt: 'You are helpful.',
+ messages: [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'look at this image' },
+ { type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' },
+ ],
+ timestamp: Date.now(),
+ },
+ {
+ role: 'assistant',
+ api: 'ollama-native',
+ provider: 'ollama',
+ model: 'llama3',
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'toolUse',
+ timestamp: Date.now(),
+ content: [
+ { type: 'thinking', thinking: 'reason quietly' },
+ { type: 'toolCall', id: 'tool_1', name: 'lookup', arguments: { city: 'Paris' } },
+ ],
+ },
+ {
+ role: 'toolResult',
+ toolCallId: 'tool_1',
+ toolName: 'lookup',
+ content: [{ type: 'text', text: 'Paris data' }],
+ isError: false,
+ timestamp: Date.now(),
+ },
+ ],
+ });
+
+ for await (const _event of stream) {
+ // Drain the stream so the request body is captured.
+ }
+
+ const request = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
+ expect(request.messages).toEqual([
+ { role: 'system', content: 'You are helpful.' },
+ { role: 'user', content: 'look at this image', images: ['aGVsbG8='] },
+ {
+ role: 'assistant',
+ content:
+ 'reason quietly \n{"city":"Paris"} ',
+ },
+ { role: 'user', content: 'Paris data' },
+ ]);
+ });
+
+ it('maps aborted fetch failures to aborted assistant messages', async () => {
+ const controller = new AbortController();
+ controller.abort();
+ (fetch as jest.Mock).mockRejectedValueOnce(new Error('The operation was aborted'));
+
+ const adapter = new OllamaAdapter('http://127.0.0.1:11434');
+ const model = adapter.createModel('llama3');
+ const stream = adapter.stream(
+ model,
+ {
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ },
+ { signal: controller.signal },
+ );
+
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ }
+
+ const message = await stream.result();
+ expect(eventTypes).toEqual(['error']);
+ expect(message.stopReason).toBe('aborted');
+ expect(message.errorMessage).toContain('aborted');
+ });
+
+ it('lists models through the client API', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ models: [{ name: 'llama3' }, { name: 'mistral' }] }),
+ });
+
+ const client = new OllamaClient('http://localhost:11434');
+ await expect(client.listModels()).resolves.toEqual(['llama3', 'mistral']);
+ });
+});
diff --git a/packages/ollama/__tests__/tsconfig.json b/packages/ollama/__tests__/tsconfig.json
new file mode 100644
index 0000000..6c4fda5
--- /dev/null
+++ b/packages/ollama/__tests__/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "..",
+ "types": ["jest", "node"]
+ },
+ "include": ["./**/*.ts", "../src/**/*.ts"],
+ "exclude": ["../dist", "../node_modules"]
+}
diff --git a/packages/ollama/jest.config.js b/packages/ollama/jest.config.js
index 787afe1..5b89d20 100644
--- a/packages/ollama/jest.config.js
+++ b/packages/ollama/jest.config.js
@@ -7,7 +7,7 @@ module.exports = {
'ts-jest',
{
babelConfig: false,
- tsconfig: 'tsconfig.json',
+ tsconfig: '__tests__/tsconfig.json',
},
],
},
@@ -15,5 +15,6 @@ module.exports = {
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
+ testPathIgnorePatterns: process.env.OLLAMA_LIVE_READY === '1' ? [] : ['\\.live\\.test\\.ts$'],
setupFilesAfterEnv: ['/jest.setup.js']
};
diff --git a/packages/ollama/jest.setup.js b/packages/ollama/jest.setup.js
index d3320e0..d186834 100644
--- a/packages/ollama/jest.setup.js
+++ b/packages/ollama/jest.setup.js
@@ -1,4 +1,6 @@
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
-jest.mock('cross-fetch', () => jest.fn());
+if (process.env.OLLAMA_LIVE_READY !== '1') {
+ jest.mock('cross-fetch', () => jest.fn());
+}
diff --git a/packages/ollama/package.json b/packages/ollama/package.json
index d25597b..3352afc 100644
--- a/packages/ollama/package.json
+++ b/packages/ollama/package.json
@@ -26,6 +26,9 @@
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest",
+ "test:live": "node ./scripts/run-live-tests.js smoke",
+ "test:live:smoke": "node ./scripts/run-live-tests.js smoke",
+ "test:live:extended": "node ./scripts/run-live-tests.js extended",
"test:watch": "jest --watch"
},
"keywords": [],
diff --git a/packages/ollama/scripts/run-live-tests.js b/packages/ollama/scripts/run-live-tests.js
new file mode 100644
index 0000000..227ba84
--- /dev/null
+++ b/packages/ollama/scripts/run-live-tests.js
@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+
+const { spawnSync } = require('node:child_process');
+
+const baseUrl = process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434';
+const requestedModel = process.env.OLLAMA_LIVE_MODEL || 'qwen3.5:4b';
+const requestedEmbedModel = process.env.OLLAMA_LIVE_EMBED_MODEL || 'nomic-embed-text:latest';
+const requestedSuite = process.argv[2] || process.env.OLLAMA_LIVE_SUITE || 'smoke';
+const validSuites = new Set(['smoke', 'extended']);
+
+async function main() {
+ if (!validSuites.has(requestedSuite)) {
+ console.error(
+ `[ollama-live] invalid suite '${requestedSuite}'. Use one of: ${Array.from(validSuites).join(', ')}`
+ );
+ process.exit(1);
+ }
+
+ let models;
+
+ try {
+ const response = await fetch(`${baseUrl}/api/tags`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const payload = await response.json();
+ models = Array.isArray(payload.models) ? payload.models.map((model) => model.name) : [];
+ } catch (error) {
+ console.log(
+ `[ollama-live] skipping live tests: unable to reach ${baseUrl} (${formatError(error)})`
+ );
+ process.exit(0);
+ }
+
+ if (!models.includes(requestedModel)) {
+ const available = models.length > 0 ? models.join(', ') : '(none)';
+ console.log(
+ `[ollama-live] skipping live tests: model '${requestedModel}' is not installed. Available models: ${available}`
+ );
+ process.exit(0);
+ }
+
+ const hasEmbedModel = models.includes(requestedEmbedModel);
+ console.log(
+ `[ollama-live] running ${requestedSuite} live tests against ${baseUrl} using model '${requestedModel}'`
+ );
+ if (!hasEmbedModel) {
+ console.log(
+ `[ollama-live] embedding scenario skipped: model '${requestedEmbedModel}' is not installed`
+ );
+ }
+
+ const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
+ const result = spawnSync(
+ pnpmCommand,
+ ['exec', 'jest', '--runInBand', '--runTestsByPath', '__tests__/ollama.live.test.ts', '--verbose', '--forceExit'],
+ {
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ OLLAMA_LIVE_READY: '1',
+ OLLAMA_LIVE_SUITE: requestedSuite,
+ OLLAMA_LIVE_EMBED_MODEL: requestedEmbedModel,
+ OLLAMA_LIVE_HAS_EMBED_MODEL: hasEmbedModel ? '1' : '0',
+ },
+ }
+ );
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ process.exit(result.status ?? 1);
+}
+
+function formatError(error) {
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return String(error);
+}
+
+main().catch((error) => {
+ console.error(`[ollama-live] failed to run live tests: ${formatError(error)}`);
+ process.exit(1);
+});
diff --git a/packages/ollama/src/index.ts b/packages/ollama/src/index.ts
index baaabe7..1fe5ba9 100644
--- a/packages/ollama/src/index.ts
+++ b/packages/ollama/src/index.ts
@@ -1,6 +1,215 @@
import fetch from 'cross-fetch';
-// ─── Types ────────────────────────────────────────────────────────────────────
+type JsonPrimitive = string | number | boolean | null;
+type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
+
+interface JsonObject {
+ [key: string]: JsonValue | undefined;
+}
+
+interface ModelDescriptor {
+ id: string;
+ name: string;
+ api: string;
+ provider: string;
+ baseUrl: string;
+ input: Array<'text' | 'image'>;
+ reasoning: boolean;
+ tools?: boolean;
+ contextWindow?: number;
+ maxOutputTokens?: number;
+ headers?: Record;
+}
+
+interface TextContent {
+ type: 'text';
+ text: string;
+}
+
+interface ImageContent {
+ type: 'image';
+ data: string;
+ mimeType: string;
+}
+
+interface ThinkingContent {
+ type: 'thinking';
+ thinking: string;
+}
+
+interface ToolCallContent {
+ type: 'toolCall';
+ id: string;
+ name: string;
+ arguments: Record;
+}
+
+interface Usage {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ totalTokens: number;
+ cost: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
+}
+
+type Message =
+ | {
+ role: 'user';
+ content: string | Array;
+ timestamp: number;
+ }
+ | {
+ role: 'assistant';
+ content: Array;
+ api: string;
+ provider: string;
+ model: string;
+ usage: Usage;
+ stopReason: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted';
+ errorMessage?: string;
+ timestamp: number;
+ }
+ | {
+ role: 'toolResult';
+ toolCallId: string;
+ toolName: string;
+ content: Array;
+ isError: boolean;
+ details?: unknown;
+ timestamp: number;
+ };
+
+interface Context {
+ systemPrompt?: string;
+ messages: Message[];
+}
+
+interface StreamOptions {
+ maxTokens?: number;
+ signal?: AbortSignal;
+ temperature?: number;
+}
+
+type AssistantMessage = Extract;
+
+type AssistantMessageEvent =
+ | { type: 'start'; partial: AssistantMessage }
+ | { type: 'text_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'text_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'text_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'thinking_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'thinking_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'thinking_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'toolcall_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'toolcall_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'toolcall_end'; contentIndex: number; toolCall: ToolCallContent; partial: AssistantMessage }
+ | { type: 'done'; reason: 'stop' | 'length' | 'toolUse'; message: AssistantMessage }
+ | { type: 'error'; reason: 'error' | 'aborted'; error: AssistantMessage };
+
+interface AssistantMessageEventStream extends AsyncIterable {
+ result(): Promise;
+}
+
+class EventStream implements AsyncIterable {
+ private readonly queue: TEvent[] = [];
+ private readonly waiting: Array<(result: IteratorResult) => void> = [];
+ private done = false;
+ private readonly finalResultPromise: Promise;
+ private resolveFinalResult!: (result: TResult) => void;
+
+ constructor(
+ private readonly isTerminal: (event: TEvent) => boolean,
+ private readonly extractResult: (event: TEvent) => TResult
+ ) {
+ this.finalResultPromise = new Promise((resolve) => {
+ this.resolveFinalResult = resolve;
+ });
+ }
+
+ push(event: TEvent): void {
+ if (this.done) {
+ return;
+ }
+
+ if (this.isTerminal(event)) {
+ this.done = true;
+ this.resolveFinalResult(this.extractResult(event));
+ }
+
+ const waiter = this.waiting.shift();
+ if (waiter) {
+ waiter({ value: event, done: false });
+ return;
+ }
+
+ this.queue.push(event);
+ }
+
+ end(result?: TResult): void {
+ this.done = true;
+ if (result !== undefined) {
+ this.resolveFinalResult(result);
+ }
+
+ while (this.waiting.length > 0) {
+ this.waiting.shift()!({ value: undefined as never, done: true });
+ }
+ }
+
+ async *[Symbol.asyncIterator](): AsyncIterator {
+ while (true) {
+ if (this.queue.length > 0) {
+ yield this.queue.shift()!;
+ continue;
+ }
+
+ if (this.done) {
+ return;
+ }
+
+ const next = await new Promise>((resolve) => {
+ this.waiting.push(resolve);
+ });
+
+ if (next.done) {
+ return;
+ }
+
+ yield next.value;
+ }
+ }
+
+ result(): Promise {
+ return this.finalResultPromise;
+ }
+}
+
+class DefaultAssistantMessageEventStream
+ extends EventStream
+ implements AssistantMessageEventStream
+{
+ constructor() {
+ super(
+ (event) => event.type === 'done' || event.type === 'error',
+ (event) => {
+ if (event.type === 'done') {
+ return event.message;
+ }
+ if (event.type === 'error') {
+ return event.error;
+ }
+ throw new Error('Unexpected terminal event');
+ }
+ );
+ }
+}
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
@@ -9,216 +218,486 @@ export interface ChatMessage {
export interface GenerateInput {
model: string;
- /** Single-shot prompt — routes to /api/generate */
prompt?: string;
- /** Multi-turn messages — routes to /api/chat (takes precedence over prompt) */
messages?: ChatMessage[];
- /** System prompt — injected into /api/generate or prepended to /api/chat messages */
system?: string;
stream?: boolean;
temperature?: number;
maxTokens?: number;
}
-interface OllamaGenerateResponse {
- model: string;
- created_at: string;
- response: string;
- done: boolean;
+interface OllamaTagsResponse {
+ models?: Array<{ name: string }>;
}
-interface OllamaChatResponse {
- model: string;
- created_at: string;
- message: { role: string; content: string };
- done: boolean;
+interface OllamaChatLine {
+ done?: boolean;
+ done_reason?: 'stop' | 'length';
+ message?: { role?: string; content?: string; thinking?: string };
+ prompt_eval_count?: number;
+ eval_count?: number;
+ response?: string;
}
interface OllamaEmbeddingResponse {
embedding: number[];
}
-interface OllamaTagsResponse {
- models?: Array<{
- name: string;
- model: string;
- modified_at: string;
- size: number;
- digest: string;
- }>;
- tags?: string[];
-}
+export const OLLAMA_MODELS: ModelDescriptor[] = [];
-// ─── Client ───────────────────────────────────────────────────────────────────
+export class OllamaClient {
+ constructor(private readonly baseUrl = 'http://localhost:11434') {}
-export default class OllamaClient {
- private baseUrl: string;
+ async listModels(): Promise {
+ const response = await fetch(`${this.baseUrl}/api/tags`);
+ if (!response.ok) {
+ throw new Error(`listModels failed: ${response.status} ${response.statusText}`);
+ }
- constructor(baseUrl: string = 'http://localhost:11434') {
- this.baseUrl = baseUrl;
+ const payload = (await response.json()) as OllamaTagsResponse;
+ return payload.models?.map((model) => model.name) ?? [];
}
- // ── Models ──────────────────────────────────────────────────────────────────
-
- async listModels(): Promise {
- const res = await fetch(`${this.baseUrl}/api/tags`);
- if (!res.ok) {
- throw new Error(`listModels failed: ${res.status} ${res.statusText}`);
- }
- const data: OllamaTagsResponse = await res.json();
- if (data.models?.length) return data.models.map((m) => m.name);
- if (data.tags?.length) return data.tags;
- return [];
+ async showModel(model: string): Promise<{ capabilities?: string[] } | null> {
+ const response = await fetch(`${this.baseUrl}/api/show`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model }),
+ });
+ if (!response.ok) return null;
+ return (await response.json()) as { capabilities?: string[] };
}
async pullModel(model: string): Promise {
- const res = await fetch(`${this.baseUrl}/api/pull`, {
+ const response = await fetch(`${this.baseUrl}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: model }),
});
- if (!res.ok) {
- throw new Error(`pullModel failed: ${res.status} ${res.statusText}`);
+ if (!response.ok) {
+ throw new Error(`pullModel failed: ${response.status} ${response.statusText}`);
}
}
async deleteModel(model: string): Promise {
- const res = await fetch(`${this.baseUrl}/api/delete`, {
+ const response = await fetch(`${this.baseUrl}/api/delete`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: model }),
});
- if (!res.ok) {
- throw new Error(`deleteModel failed: ${res.status} ${res.statusText}`);
- }
- }
-
- // ── Generate (single-shot or multi-turn) ────────────────────────────────────
-
- async generate(input: GenerateInput): Promise;
- async generate(input: GenerateInput, onChunk: (chunk: string) => void): Promise;
- async generate(
- input: GenerateInput,
- onChunk?: (chunk: string) => void
- ): Promise {
- if (input.messages) {
- return this._chat(input, onChunk);
+ if (!response.ok) {
+ throw new Error(`deleteModel failed: ${response.status} ${response.statusText}`);
}
- return this._generate(input, onChunk);
}
- // ── Embeddings ──────────────────────────────────────────────────────────────
-
async generateEmbedding(text: string, model = 'nomic-embed-text'): Promise {
- const res = await fetch(`${this.baseUrl}/api/embeddings`, {
+ const response = await fetch(`${this.baseUrl}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: text }),
});
- if (!res.ok) {
- throw new Error(`generateEmbedding failed: ${res.status} ${res.statusText}`);
+ if (!response.ok) {
+ throw new Error(`generateEmbedding failed: ${response.status} ${response.statusText}`);
}
- const data: OllamaEmbeddingResponse = await res.json();
- return data.embedding;
- }
- // ── Private: /api/generate ──────────────────────────────────────────────────
+ const payload = (await response.json()) as OllamaEmbeddingResponse;
+ return payload.embedding;
+ }
- private async _generate(
+ async generate(input: GenerateInput): Promise;
+ async generate(input: GenerateInput, onChunk: (chunk: string) => void): Promise;
+ async generate(
input: GenerateInput,
onChunk?: (chunk: string) => void
): Promise {
- const body: Record = {
- model: input.model,
- prompt: input.prompt ?? '',
- stream: !!onChunk,
+ const context = legacyInputToContext(input);
+ const model: ModelDescriptor = {
+ id: input.model,
+ name: input.model,
+ api: 'ollama-native',
+ provider: 'ollama',
+ baseUrl: this.baseUrl,
+ input: ['text', 'image'],
+ reasoning: false,
+ tools: false,
+ maxOutputTokens: input.maxTokens,
};
- if (input.system) body.system = input.system;
- if (input.temperature !== undefined) body.temperature = input.temperature;
- const res = await fetch(`${this.baseUrl}/api/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
+ const adapter = new OllamaAdapter(this.baseUrl);
+ const response = adapter.stream(model, context, {
+ maxTokens: input.maxTokens,
+ signal: undefined,
+ temperature: input.temperature,
});
- if (!res.ok) {
- const text = await res.text().catch(() => '');
- throw new Error(`generate failed: ${res.status} ${res.statusText} — ${text}`);
- }
- if (onChunk) {
- return this._streamResponse(res, (data: OllamaGenerateResponse) => data.response, onChunk);
+ if (onChunk || input.stream) {
+ for await (const event of response) {
+ if (event.type === 'text_delta') {
+ onChunk?.(event.delta);
+ }
+ }
+ return;
}
- const data: OllamaGenerateResponse = await res.json();
- return data.response;
+ const message = await response.result();
+ return message.content
+ .filter((block): block is TextContent => block.type === 'text')
+ .map((block) => block.text)
+ .join('');
}
+}
- // ── Private: /api/chat ──────────────────────────────────────────────────────
+export class OllamaAdapter {
+ public readonly api = 'ollama-native';
+ public readonly provider = 'ollama';
+ public readonly name = 'ollama';
- private async _chat(
- input: GenerateInput,
- onChunk?: (chunk: string) => void
- ): Promise {
- const messages: ChatMessage[] = [];
- if (input.system) {
- messages.push({ role: 'system', content: input.system });
- }
- messages.push(...(input.messages ?? []));
+ private readonly client: OllamaClient;
+ private readonly baseUrl: string;
- const body: Record = {
- model: input.model,
- messages,
- stream: !!onChunk,
+ constructor(baseUrl?: string) {
+ this.baseUrl = baseUrl ?? 'http://localhost:11434';
+ this.client = new OllamaClient(this.baseUrl);
+ }
+
+ createModel(modelId: string, overrides?: Partial): ModelDescriptor {
+ return {
+ id: modelId,
+ name: modelId,
+ api: this.api,
+ provider: this.provider,
+ baseUrl: this.baseUrl,
+ input: ['text', 'image'],
+ reasoning: false,
+ tools: false,
+ ...overrides,
};
- if (input.temperature !== undefined) body.temperature = input.temperature;
+ }
- const res = await fetch(`${this.baseUrl}/api/chat`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const text = await res.text().catch(() => '');
- throw new Error(`chat failed: ${res.status} ${res.statusText} — ${text}`);
+ async listModels(): Promise> {
+ return this.client.listModels();
+ }
+
+ stream(model: ModelDescriptor, context: Context, options?: StreamOptions): AssistantMessageEventStream {
+ const stream = new DefaultAssistantMessageEventStream();
+ const output = createAssistantMessage(model);
+
+ void (async () => {
+ const body: Record = {
+ model: model.id,
+ stream: true,
+ messages: toOllamaMessages(context),
+ options: {
+ temperature: options?.temperature,
+ num_predict: options?.maxTokens,
+ },
+ };
+ if (model.reasoning) body.think = true;
+
+ try {
+ const response = await fetch(`${model.baseUrl}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: options?.signal,
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(`Ollama error ${response.status}: ${text}`);
+ }
+
+ stream.push({ type: 'start', partial: clone(output) });
+
+ if (!response.body) {
+ throw new Error('No response body');
+ }
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let textIndex: number | undefined;
+ let thinkingIndex: number | undefined;
+ let finished = false;
+
+ const processPayload = (payload: OllamaChatLine): boolean => {
+ const thinking = payload.message?.thinking ?? '';
+ if (thinking) {
+ if (thinkingIndex === undefined) {
+ thinkingIndex = output.content.push({ type: 'thinking', thinking: '' }) - 1;
+ stream.push({
+ type: 'thinking_start',
+ contentIndex: thinkingIndex,
+ partial: clone(output),
+ });
+ }
+
+ const block = output.content[thinkingIndex] as ThinkingContent;
+ block.thinking += thinking;
+ stream.push({
+ type: 'thinking_delta',
+ contentIndex: thinkingIndex,
+ delta: thinking,
+ partial: clone(output),
+ });
+ }
+
+ const text = payload.message?.content ?? payload.response ?? '';
+ if (text) {
+ if (textIndex === undefined) {
+ textIndex = output.content.push({ type: 'text', text: '' }) - 1;
+ stream.push({ type: 'text_start', contentIndex: textIndex, partial: clone(output) });
+ }
+
+ const block = output.content[textIndex] as TextContent;
+ block.text += text;
+ stream.push({
+ type: 'text_delta',
+ contentIndex: textIndex,
+ delta: text,
+ partial: clone(output),
+ });
+ }
+
+ if (!payload.done) {
+ return false;
+ }
+
+ output.usage.input = payload.prompt_eval_count ?? output.usage.input;
+ output.usage.output = payload.eval_count ?? output.usage.output;
+ output.usage.totalTokens = output.usage.input + output.usage.output;
+ output.stopReason = payload.done_reason === 'length' ? 'length' : 'stop';
+
+ if (thinkingIndex !== undefined) {
+ stream.push({
+ type: 'thinking_end',
+ contentIndex: thinkingIndex,
+ content: (output.content[thinkingIndex] as ThinkingContent).thinking,
+ partial: clone(output),
+ });
+ }
+ if (textIndex !== undefined) {
+ stream.push({
+ type: 'text_end',
+ contentIndex: textIndex,
+ content: (output.content[textIndex] as TextContent).text,
+ partial: clone(output),
+ });
+ }
+ stream.push({
+ type: 'done',
+ reason: output.stopReason === 'length' ? 'length' : 'stop',
+ message: clone(output),
+ });
+ stream.end(output);
+ finished = true;
+ return true;
+ };
+
+ for await (const chunk of iterateResponseBody(response.body)) {
+ buffer += decoder.decode(chunk, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ continue;
+ }
+
+ const payload = JSON.parse(trimmed) as OllamaChatLine;
+ if (processPayload(payload)) {
+ return;
+ }
+ }
+ }
+
+ const trailing = buffer.trim();
+ if (!finished && trailing) {
+ const payload = JSON.parse(trailing) as OllamaChatLine;
+ if (processPayload(payload)) {
+ return;
+ }
+ }
+
+ if (thinkingIndex !== undefined) {
+ stream.push({
+ type: 'thinking_end',
+ contentIndex: thinkingIndex,
+ content: (output.content[thinkingIndex] as ThinkingContent).thinking,
+ partial: clone(output),
+ });
+ }
+ if (textIndex !== undefined) {
+ stream.push({
+ type: 'text_end',
+ contentIndex: textIndex,
+ content: (output.content[textIndex] as TextContent).text,
+ partial: clone(output),
+ });
+ }
+ stream.push({
+ type: 'done',
+ reason: output.stopReason === 'length' ? 'length' : 'stop',
+ message: clone(output),
+ });
+ stream.end(output);
+ } catch (error) {
+ output.stopReason = options?.signal?.aborted ? 'aborted' : 'error';
+ output.errorMessage = error instanceof Error ? error.message : String(error);
+ stream.push({
+ type: 'error',
+ reason: output.stopReason === 'aborted' ? 'aborted' : 'error',
+ error: clone(output),
+ });
+ stream.end(output);
+ }
+ })();
+
+ return stream;
+ }
+}
+
+function toOllamaMessages(context: Context): Array<{ role: string; content: string; images?: string[] }> {
+ const messages = context.messages.map((message) => {
+ if (message.role === 'user') {
+ if (typeof message.content === 'string') {
+ return { role: 'user', content: message.content };
+ }
+
+ return {
+ role: 'user',
+ content: message.content
+ .filter((block): block is TextContent => block.type === 'text')
+ .map((block) => block.text)
+ .join('\n'),
+ images: message.content
+ .filter((block): block is ImageContent => block.type === 'image')
+ .map((block) => block.data),
+ };
}
- if (onChunk) {
- return this._streamResponse(
- res,
- (data: OllamaChatResponse) => data.message?.content ?? '',
- onChunk
- );
+ if (message.role === 'toolResult') {
+ return {
+ role: 'user',
+ content: message.content
+ .map((block) =>
+ block.type === 'text' ? block.text : `[image:${block.mimeType};bytes=${block.data.length}]`
+ )
+ .join('\n'),
+ };
}
- const data: OllamaChatResponse = await res.json();
- return data.message?.content ?? '';
+ return {
+ role: 'assistant',
+ content: message.content
+ .map((block) => {
+ if (block.type === 'text') {
+ return block.text;
+ }
+ if (block.type === 'thinking') {
+ return `${block.thinking} `;
+ }
+ return `${JSON.stringify(block.arguments)} `;
+ })
+ .join('\n'),
+ };
+ });
+
+ if (context.systemPrompt) {
+ messages.unshift({
+ role: 'system',
+ content: context.systemPrompt,
+ });
}
- // ── Private: streaming reader ───────────────────────────────────────────────
+ return messages;
+}
- private async _streamResponse(
- res: Response,
- extract: (data: T) => string,
- onChunk: (chunk: string) => void
- ): Promise {
- const reader = res.body?.getReader();
- if (!reader) throw new Error('No response body reader');
+function createAssistantMessage(model: ModelDescriptor): AssistantMessage {
+ return {
+ role: 'assistant',
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ content: [],
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop',
+ timestamp: Date.now(),
+ };
+}
+
+function legacyInputToContext(input: GenerateInput): Context {
+ const messages: Message[] = input.messages
+ ? input.messages
+ .filter((message) => message.role !== 'system')
+ .map((message) =>
+ message.role === 'assistant'
+ ? {
+ role: 'assistant' as const,
+ api: 'ollama-native',
+ provider: 'ollama',
+ model: input.model,
+ content: [{ type: 'text', text: message.content }],
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: 'stop' as const,
+ timestamp: Date.now(),
+ }
+ : {
+ role: 'user' as const,
+ content: message.content,
+ timestamp: Date.now(),
+ }
+ )
+ : [{ role: 'user' as const, content: input.prompt ?? '', timestamp: Date.now() }];
+
+ return {
+ systemPrompt: input.system ?? input.messages?.find((message) => message.role === 'system')?.content,
+ messages,
+ };
+}
+
+function clone(value: TValue): TValue {
+ return JSON.parse(JSON.stringify(value)) as TValue;
+}
- const decoder = new TextDecoder();
+async function* iterateResponseBody(
+ body: NonNullable
+): AsyncGenerator {
+ if ('getReader' in body && typeof body.getReader === 'function') {
+ const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
- if (done) break;
- const lines = decoder.decode(value).split('\n').filter(Boolean);
- for (const line of lines) {
- try {
- const data: T = JSON.parse(line);
- const chunk = extract(data);
- if (chunk) onChunk(chunk);
- } catch {
- // partial line — skip
- }
+ if (done) {
+ return;
+ }
+
+ if (value) {
+ yield value;
}
}
}
+
+ for await (const chunk of body as unknown as AsyncIterable) {
+ if (typeof chunk === 'string') {
+ yield new TextEncoder().encode(chunk);
+ continue;
+ }
+
+ yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
+ }
}
+
+export default OllamaClient;
diff --git a/packages/openai/__tests__/openai.test.ts b/packages/openai/__tests__/openai.test.ts
index 795c3c2..7613973 100644
--- a/packages/openai/__tests__/openai.test.ts
+++ b/packages/openai/__tests__/openai.test.ts
@@ -1,148 +1,88 @@
import fetch from 'cross-fetch';
+import { TextEncoder } from 'util';
+
import { OpenAIAdapter } from '../src';
-const apiKey = 'sk-test';
+function createStreamingResponse(lines: string[]) {
+ const payload = lines.join('\n');
+ const encoded = new TextEncoder().encode(payload);
+ const reader = {
+ read: jest
+ .fn()
+ .mockResolvedValueOnce({ done: false, value: encoded })
+ .mockResolvedValueOnce({ done: true, value: undefined }),
+ };
-describe('OpenAIAdapter', () => {
- let adapter: OpenAIAdapter;
+ return {
+ ok: true,
+ body: { getReader: () => reader },
+ };
+}
+describe('OpenAIAdapter', () => {
beforeEach(() => {
- adapter = new OpenAIAdapter(apiKey);
jest.clearAllMocks();
});
- afterEach(() => { jest.resetAllMocks(); });
-
- // ── Constructor ─────────────────────────────────────────────────────────────
-
- it('accepts a plain string as apiKey', () => {
- expect(new OpenAIAdapter('key').name).toBe('openai');
- });
-
- it('accepts an options object', () => {
- expect(new OpenAIAdapter({ apiKey: 'key', defaultModel: 'gpt-4o-mini' }).name).toBe('openai');
- });
-
- // ── listModels ──────────────────────────────────────────────────────────────
-
- it('listModels returns OpenAI model names', async () => {
- const models = await adapter.listModels();
- expect(models).toContain('gpt-4o');
- expect(models).toContain('o1');
- });
-
- // ── generate ────────────────────────────────────────────────────────────────
-
- it('generate returns choice content', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('Hello GPT') });
- expect(await adapter.generate({ model: 'gpt-4o', prompt: 'Hi' })).toBe('Hello GPT');
- });
-
- it('generate sends Authorization Bearer header', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('ok') });
- await adapter.generate({ model: 'gpt-4o', prompt: 'hi' });
- const [url, opts] = (fetch as jest.Mock).mock.calls[0];
- expect(url).toContain('/v1/chat/completions');
- expect(opts.headers['Authorization']).toBe(`Bearer ${apiKey}`);
- });
-
- it('generate uses defaultModel when model is empty', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('ok') });
- const a = new OpenAIAdapter({ apiKey, defaultModel: 'gpt-4o-mini' });
- await a.generate({ model: '', prompt: 'hi' });
- expect(JSON.parse((fetch as jest.Mock).mock.calls[0][1].body).model).toBe('gpt-4o-mini');
- });
-
- it('generate prepends system as first message', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('ok') });
- await adapter.generate({ model: 'gpt-4o', prompt: 'Hi', system: 'Be concise.' });
- const { messages } = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(messages[0]).toEqual({ role: 'system', content: 'Be concise.' });
- expect(messages[1]).toEqual({ role: 'user', content: 'Hi' });
- });
-
- it('generate deduplicates system when already in messages[]', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('ok') });
- await adapter.generate({
- model: 'gpt-4o',
- messages: [{ role: 'system', content: 'Be helpful.' }, { role: 'user', content: 'Hello' }],
- system: 'Be helpful.',
+ it('streams text and tool calls with parsed partial JSON', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce(
+ createStreamingResponse([
+ 'data: {"choices":[{"delta":{"content":"Hello "},"finish_reason":null}]}',
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\\"city\\":\\"Pa"}}]},"finish_reason":null}]}',
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ris\\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',
+ 'data: [DONE]',
+ ])
+ );
+
+ const adapter = new OpenAIAdapter({ apiKey: 'test-key' });
+ const model = adapter.createModel('gpt-5.4-mini');
+ const stream = adapter.stream(model, {
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ tools: [
+ {
+ name: 'lookup',
+ description: 'Lookup a city',
+ parameters: {
+ type: 'object',
+ properties: {
+ city: { type: 'string' },
+ },
+ required: ['city'],
+ },
+ },
+ ],
});
- const { messages } = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(messages.filter((m: { role: string }) => m.role === 'system')).toHaveLength(1);
- });
-
- it('generate passes temperature and maxTokens', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('ok') });
- await adapter.generate({ model: 'gpt-4o', prompt: 'hi', temperature: 0.5, maxTokens: 128 });
- const body = JSON.parse((fetch as jest.Mock).mock.calls[0][1].body);
- expect(body.temperature).toBe(0.5);
- expect(body.max_tokens).toBe(128);
- });
-
- it('generate throws on non-ok response', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 429, text: async () => 'Rate limit' });
- await expect(adapter.generate({ model: 'gpt-4o', prompt: 'hi' })).rejects.toThrow('OpenAI error 429');
- });
-
- // ── generateStreaming ───────────────────────────────────────────────────────
-
- it('generateStreaming yields delta content', async () => {
- const sse = sseReader([
- { choices: [{ delta: { content: 'Hello' }, finish_reason: null }] },
- { choices: [{ delta: { content: ' world' }, finish_reason: null }] },
- { choices: [{ delta: {}, finish_reason: 'stop' }] },
- ]);
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, body: { getReader: () => sse } });
- const chunks: string[] = [];
- await adapter.generateStreaming({ model: 'gpt-4o', prompt: 'hi' }, (c) => chunks.push(c));
- expect(chunks).toEqual(['Hello', ' world']);
- });
- it('generateStreaming stops at [DONE]', async () => {
- const lines = [
- 'data: ' + JSON.stringify({ choices: [{ delta: { content: 'Hi' }, finish_reason: null }] }),
- 'data: [DONE]',
- 'data: ' + JSON.stringify({ choices: [{ delta: { content: 'NEVER' }, finish_reason: null }] }),
- ].join('\n') + '\n';
- const encoded = new TextEncoder().encode(lines);
- let done = false;
- (fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- body: { getReader: () => ({ read: jest.fn().mockImplementation(async () => { if (done) return { done: true, value: undefined }; done = true; return { done: false, value: encoded }; }) }) },
+ const eventTypes: string[] = [];
+ for await (const event of stream) {
+ eventTypes.push(event.type);
+ }
+
+ const message = await stream.result();
+ const toolCall = message.content.find((block) => block.type === 'toolCall');
+
+ expect(eventTypes).toEqual(
+ expect.arrayContaining(['text_start', 'text_delta', 'toolcall_start', 'toolcall_delta', 'toolcall_end', 'done'])
+ );
+ expect(message.stopReason).toBe('toolUse');
+ expect(message.usage.totalTokens).toBe(15);
+ expect(toolCall).toMatchObject({
+ type: 'toolCall',
+ name: 'lookup',
+ arguments: { city: 'Paris' },
});
- const chunks: string[] = [];
- await adapter.generateStreaming({ model: 'gpt-4o', prompt: 'hi' }, (c) => chunks.push(c));
- expect(chunks).toEqual(['Hi']);
});
- // ── OpenAI-compatible baseUrl ───────────────────────────────────────────────
+ it('falls back to built-in models when no API key is configured', async () => {
+ const adapter = new OpenAIAdapter();
+ const models = await adapter.listModels();
- it('uses custom baseUrl for OpenAI-compatible APIs', async () => {
- (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => completionResponse('ok') });
- const a = new OpenAIAdapter({ apiKey, baseUrl: 'http://localhost:1234' });
- await a.generate({ model: 'llama3', prompt: 'hi' });
- expect((fetch as jest.Mock).mock.calls[0][0]).toBe('http://localhost:1234/v1/chat/completions');
+ expect(models).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: 'gpt-5.4' }),
+ expect.objectContaining({ id: 'gpt-5.4-mini' }),
+ expect.objectContaining({ id: 'gpt-5.4-nano' }),
+ ])
+ );
});
});
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-function completionResponse(content: string) {
- return {
- choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }],
- };
-}
-
-function sseReader(events: object[]) {
- const lines = [...events.map((e) => `data: ${JSON.stringify(e)}`), 'data: [DONE]'].join('\n') + '\n';
- const encoded = new TextEncoder().encode(lines);
- let done = false;
- return {
- read: jest.fn().mockImplementation(async () => {
- if (done) return { done: true, value: undefined };
- done = true;
- return { done: false, value: encoded };
- }),
- };
-}
diff --git a/packages/openai/__tests__/tsconfig.json b/packages/openai/__tests__/tsconfig.json
new file mode 100644
index 0000000..6c4fda5
--- /dev/null
+++ b/packages/openai/__tests__/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "..",
+ "types": ["jest", "node"]
+ },
+ "include": ["./**/*.ts", "../src/**/*.ts"],
+ "exclude": ["../dist", "../node_modules"]
+}
diff --git a/packages/openai/jest.config.js b/packages/openai/jest.config.js
index 787afe1..e11f478 100644
--- a/packages/openai/jest.config.js
+++ b/packages/openai/jest.config.js
@@ -7,7 +7,7 @@ module.exports = {
'ts-jest',
{
babelConfig: false,
- tsconfig: 'tsconfig.json',
+ tsconfig: '__tests__/tsconfig.json',
},
],
},
diff --git a/packages/openai/src/index.ts b/packages/openai/src/index.ts
index 3824f58..365a76d 100644
--- a/packages/openai/src/index.ts
+++ b/packages/openai/src/index.ts
@@ -1,156 +1,960 @@
import fetch from 'cross-fetch';
-// ─── Types ────────────────────────────────────────────────────────────────────
+type JsonPrimitive = string | number | boolean | null;
+type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
-export interface ChatMessage {
- role: 'system' | 'user' | 'assistant';
- content: string;
+interface JsonObject {
+ [key: string]: JsonValue | undefined;
}
-export interface GenerateInput {
- model: string;
- prompt?: string;
- messages?: ChatMessage[];
- system?: string;
- stream?: boolean;
- temperature?: number;
+interface JsonSchema {
+ additionalProperties?: boolean | JsonSchema;
+ description?: string;
+ enum?: JsonValue[];
+ items?: JsonSchema | JsonSchema[];
+ properties?: Record;
+ required?: string[];
+ type?: string | string[];
+}
+
+interface OpenAICompatibleCompat {
+ maxTokensField?: 'max_completion_tokens' | 'max_tokens';
+ reasoningFormat?: 'none' | 'openai';
+ supportsReasoningEffort?: boolean;
+ supportsStrictToolSchema?: boolean;
+ supportsUsageInStreaming?: boolean;
+ toolCallIdFormat?: 'passthrough' | 'safe64' | 'mistral9';
+ requiresToolResultName?: boolean;
+}
+
+interface ModelDescriptor {
+ id: string;
+ name: string;
+ api: string;
+ provider: string;
+ baseUrl: string;
+ input: Array<'text' | 'image'>;
+ reasoning: boolean;
+ tools?: boolean;
+ contextWindow?: number;
+ maxOutputTokens?: number;
+ cost?: {
+ input?: number;
+ output?: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+ };
+ headers?: Record;
+ compat?: OpenAICompatibleCompat;
+}
+
+interface TextContent {
+ type: 'text';
+ text: string;
+}
+
+interface ImageContent {
+ type: 'image';
+ data: string;
+ mimeType: string;
+}
+
+interface ThinkingContent {
+ type: 'thinking';
+ thinking: string;
+ signature?: string;
+}
+
+interface ToolCallContent {
+ type: 'toolCall';
+ id: string;
+ name: string;
+ arguments: Record;
+ rawArguments?: string;
+}
+
+type Message =
+ | {
+ role: 'user';
+ content: string | Array;
+ timestamp: number;
+ }
+ | {
+ role: 'assistant';
+ content: Array;
+ api: string;
+ provider: string;
+ model: string;
+ usage: Usage;
+ stopReason: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted';
+ errorMessage?: string;
+ timestamp: number;
+ }
+ | {
+ role: 'toolResult';
+ toolCallId: string;
+ toolName: string;
+ content: Array;
+ isError: boolean;
+ details?: unknown;
+ timestamp: number;
+ };
+
+interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: JsonSchema;
+}
+
+interface Context {
+ systemPrompt?: string;
+ messages: Message[];
+ tools?: ToolDefinition[];
+}
+
+interface StreamOptions {
+ apiKey?: string;
+ headers?: Record;
maxTokens?: number;
+ onPayload?: (payload: unknown) => void;
+ reasoning?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+ signal?: AbortSignal;
+ temperature?: number;
+}
+
+interface Usage {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ totalTokens: number;
+ cost: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
}
+type AssistantMessage =
+ Extract;
+
+type AssistantMessageEvent =
+ | { type: 'start'; partial: AssistantMessage }
+ | { type: 'text_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'text_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'text_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'thinking_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'thinking_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'thinking_end'; contentIndex: number; content: string; partial: AssistantMessage }
+ | { type: 'toolcall_start'; contentIndex: number; partial: AssistantMessage }
+ | { type: 'toolcall_delta'; contentIndex: number; delta: string; partial: AssistantMessage }
+ | { type: 'toolcall_end'; contentIndex: number; toolCall: ToolCallContent; partial: AssistantMessage }
+ | { type: 'done'; reason: 'stop' | 'length' | 'toolUse'; message: AssistantMessage }
+ | { type: 'error'; reason: 'error' | 'aborted'; error: AssistantMessage };
+
+interface AssistantMessageEventStream extends AsyncIterable {
+ result(): Promise;
+}
+
+class EventStream implements AsyncIterable {
+ private readonly queue: TEvent[] = [];
+ private readonly waiting: Array<(result: IteratorResult) => void> = [];
+ private done = false;
+ private readonly finalResultPromise: Promise;
+ private resolveFinalResult!: (result: TResult) => void;
+
+ constructor(
+ private readonly isTerminal: (event: TEvent) => boolean,
+ private readonly extractResult: (event: TEvent) => TResult
+ ) {
+ this.finalResultPromise = new Promise((resolve) => {
+ this.resolveFinalResult = resolve;
+ });
+ }
+
+ push(event: TEvent): void {
+ if (this.done) {
+ return;
+ }
+
+ if (this.isTerminal(event)) {
+ this.done = true;
+ this.resolveFinalResult(this.extractResult(event));
+ }
+
+ const waiter = this.waiting.shift();
+ if (waiter) {
+ waiter({ value: event, done: false });
+ return;
+ }
+
+ this.queue.push(event);
+ }
+
+ end(result?: TResult): void {
+ this.done = true;
+ if (result !== undefined) {
+ this.resolveFinalResult(result);
+ }
+
+ while (this.waiting.length > 0) {
+ this.waiting.shift()!({ value: undefined as never, done: true });
+ }
+ }
+
+ async *[Symbol.asyncIterator](): AsyncIterator