/** * LLM Provider interface — implement once, providers slot in. * See ../../docs/03-llm-providers.md for the full design. */ interface LLMProvider { readonly name: string; complete(opts: CompleteOptions): Promise; } interface CompleteOptions { messages: LLMMessage[]; tools?: ToolDefinition[]; temperature?: number; maxTokens?: number; model?: string; signal?: AbortSignal; /** * Reasoning intensity. Provider-specific mapping: * - OpenAI reasoning / gpt-5+ models → `reasoning_effort` * 'off' → 'minimal' * - Gemini 2.5+ models → `generationConfig.thinkingConfig.thinkingBudget` * 'off' → 0 * 'minimal' / 'low' → 64 / 512 * 'medium' / 'high' → 1024 / 4096 * - Other / non-reasoning models → field is ignored * * Use `'off'` for short, deterministic tasks (inline text edits, * translation, classification) — saves cost and prevents the model * from leaking chain-of-thought into the response. */ thinking?: 'off' | 'minimal' | 'low' | 'medium' | 'high'; /** * Force the model to respond with a JSON object that parses cleanly. * - OpenAI → `response_format: { type: 'json_object' }` * - Gemini → `generationConfig.responseMimeType = 'application/json'` * Providers that don't support structured output ignore this; the prompt * itself should still say "Reply with JSON only" as a fallback. */ jsonMode?: boolean; /** * Force which tool the model must call. * - `'auto'` (default) — model picks zero or one tool * - `'required'` — model must call exactly one tool (any tool in `tools`) * - `{ name: 'foo' }` — model must call the named tool * * Used by the WebAgent's CoT mode to force the wrapping `agent_turn` * tool every turn. Providers that don't support targeted tool_choice * fall back to `'required'` or `'auto'`. */ toolChoice?: 'auto' | 'required' | { name: string; }; } interface CompleteResult { content: string; toolCalls?: ToolCall[]; usage?: { promptTokens: number; completionTokens: number; }; finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter'; /** * Streaming-only timing. Present when the call went through * `streamComplete()` and the stream produced at least one delta. All * values are epoch ms (`Date.now()`). * - `startedAt` — first call to `produce()` * - `firstDeltaAt` — first non-empty text delta (TTFT = this - startedAt) * - `endedAt` — terminal chunk (last delta or finish) * Non-streaming `complete()` calls omit this field. */ streamMetrics?: { startedAt: number; firstDeltaAt?: number; endedAt: number; }; } type LLMRole$1 = 'system' | 'user' | 'assistant' | 'tool'; interface LLMMessage { role: LLMRole$1; content: string | ContentPart[]; /** Set when role = 'tool' — the call this message answers. */ toolCallId?: string; /** Set when role = 'assistant' — tool calls emitted in this turn. */ toolCalls?: ToolCall[]; /** Display name (optional, for multi-agent traces). */ name?: string; } type ContentPart = { type: 'text'; text: string; } | { type: 'image'; image: string; }; interface ToolDefinition { name: string; description: string; parameters: Record; } interface ToolCall { id: string; name: string; arguments: Record; } interface StreamChunk { /** Incremental text delta since previous chunk. */ delta: string; /** Snapshot of accumulated text so far. */ text: string; /** Tool calls assembled so far. */ toolCalls?: ToolCall[]; /** True only on the very last chunk. */ done: boolean; /** Reason for stop — present on the final chunk only. */ finishReason?: CompleteResult['finishReason']; /** * Incremental tool-call argument fragment. v0.2.0 — used by the * streaming-envelope path so the cot loop can feed each piece into * the envelope parser AS the LLM types it, instead of waiting for * the full JSON to settle. * * `deltaText` is just the new chars added in this chunk. * `accumulatedText` is the full args buffer so far (so consumers * with no parser state can still inspect the snapshot). * * Only OpenAIProvider (and adapters that wrap it — Agnes / DeepSeek * via OpenAI-compatible) currently emit this. Other providers may * not, which is fine — the cot loop falls back to the JSON-parsed * path when no streaming-envelope events arrive. */ toolArgsDelta?: { id: string; name: string; deltaText: string; accumulatedText: string; }; /** * Set on the terminal chunk when streaming aborted with an error. The * `await handle` form will reject with the same error; iterators receive * the chunk so callers iterating without awaiting can still observe it. */ error?: unknown; } /** * Combined awaitable + async-iterable handle. * const r = streamComplete(...); * for await (const c of r) {} * const result = await r; */ type StreamHandle = AsyncIterable & Promise; interface BuildStreamOptions { /** * Produces a stream of deltas. Implementation is provider-specific (parses * SSE, decodes Gemini chunks, whatever). Each yield is a string delta; * pass `null` to signal completion with optional final result. */ produce(): AsyncIterable<{ delta?: string; toolCall?: ToolCall; /** v0.2.0 streaming-envelope: per-fragment tool-args delta. */ toolArgsDelta?: { id: string; name: string; deltaText: string; accumulatedText: string; }; finishReason?: CompleteResult['finishReason']; usage?: CompleteResult['usage']; }>; } /** * Build a StreamHandle from a provider's chunk producer. * Buffers a single iteration so the awaited Promise and the iterable share state. */ declare function buildStream(opts: BuildStreamOptions): StreamHandle; /** * Provider interface extension — providers MAY implement streamComplete in * addition to the base complete(). Callers should feature-check. */ interface StreamingProvider { streamComplete?(opts: CompleteOptions): StreamHandle; } /** * OpenAI provider. Works with Azure OpenAI, OpenRouter, Cloudflare AI Gateway, * and any other OpenAI-compatible endpoint via `baseURL`. */ interface OpenAIProviderConfig { apiKey: string; model?: string; baseURL?: string; organization?: string; /** Default request headers (e.g. for self-hosted reverse proxies). */ headers?: Record; /** * Vendor-specific request body fields merged into every request. The * OpenAI Chat Completions shape is a lingua franca — DeepSeek, Qwen, * Together, OpenRouter, etc. all accept it but each adds proprietary * knobs. Put them here: * * new OpenAIProvider({ * apiKey, baseURL: 'https://api.deepseek.com/v1', * extraBody: { thinking: { type: 'disabled' } }, // DeepSeek-specific * }); * * Values here are spread shallow-merged into the body AFTER the * built-in fields, so they can override `temperature` / `max_tokens` / * etc. when needed. */ extraBody?: Record; } declare class OpenAIProvider implements LLMProvider, StreamingProvider { readonly name = "openai"; private apiKey; private model; private baseURL; private headers; private extraBody; constructor(config: OpenAIProviderConfig); private buildBody; complete(opts: CompleteOptions): Promise; /** * Streaming via OpenAI's SSE `chat/completions?stream=true`. Each event is * `data: { ... }` carrying either a text delta or a tool_call fragment. * Tool calls stream piecewise — `index` slots; `id` / `function.name` arrive * on the first fragment, `function.arguments` accumulates JSON characters * across subsequent fragments. */ streamComplete(opts: CompleteOptions): StreamHandle; } /** * Google provider — talks to Google AI Studio (`generativelanguage.googleapis.com`). * Supports both Gemini (gemini-3.1-pro-preview, gemini-2.5-pro, ...) and * Gemma (gemma-4-31b-it, ...) — the endpoint shape is the same; only the * model id differs. Translates messages / tools to / from OpenAI-shape internally. */ interface GoogleProviderConfig { apiKey: string; model?: string; baseURL?: string; } declare class GoogleProvider implements LLMProvider, StreamingProvider { readonly name = "google"; private apiKey; private model; private baseURL; constructor(config: GoogleProviderConfig); complete(opts: CompleteOptions): Promise; /** * Streaming via Gemini's :streamGenerateContent endpoint (alt=sse). */ streamComplete(opts: CompleteOptions): StreamHandle; } /** * ProxyProvider — calls the host's own backend instead of OpenAI/Gemini directly. * * **THE production-safe pattern for BYOK + browser apps.** The user's API key * never touches client code: client → your /api/llm endpoint → OpenAI. * * Your backend implements `/api/llm/complete` (or whatever path), reads the * key from `process.env`, and forwards the request. This file is the client * half — it sends OpenAI-shaped `CompleteOptions`, expects the standardized * `CompleteResult` shape back. * * See ../../docs/09-security.md for full discussion + a Node.js reference * implementation. */ interface ProxyProviderConfig { /** Endpoint that returns CompleteResult-shaped JSON. */ endpoint: string; /** Method override (default 'POST'). */ method?: 'POST' | 'PUT'; /** Extra headers — pass auth tokens etc. */ headers?: Record; /** * Optional credentials override. Set 'include' if your endpoint is * cross-origin and relies on cookies. */ credentials?: RequestCredentials; /** * Optional request body transformer — if your backend expects a different * shape than the default `{ messages, tools, ... }`. */ buildBody?: (opts: CompleteOptions) => unknown; /** * Optional response transformer — if your backend returns a different * shape than `CompleteResult`. Map it here. */ parseResult?: (raw: unknown) => CompleteResult; /** * Logical model name to advertise — purely informational, controls the * `name` field. Default 'proxy'. */ name?: string; /** * Per-request timeout in milliseconds. The proxy aborts the fetch and * throws if no response arrives in this window. Default 30_000 (30s). * Pass `0` (or any non-positive value) to disable. */ timeoutMs?: number; } declare class ProxyProvider implements LLMProvider, StreamingProvider { readonly name: string; private config; constructor(config: ProxyProviderConfig); complete(opts: CompleteOptions): Promise; /** * Streaming via the same endpoint. Default body shape (OpenAI-compatible) * is `{ messages, tools, ..., stream: true }`; the proxy is expected to * forward the SSE body verbatim. Hosts using a non-OpenAI wire shape * should override `buildBody` to inject their own streaming flag and * implement an OpenAI-style SSE on the backend (or use a separate * provider class). */ streamComplete(opts: CompleteOptions): StreamHandle; } /** * LLMRouter — pick a provider per role. * * 4 roles: * - `webagent` — main agent loop (text-only) * - `vision` — webagent with images / screenshot context. Falls back to webagent. * - `utility` — short single-shot calls (inline AI / voice cleanup / etc). Falls back to webagent. * - `plan` — pre-loop planner. Falls back to webagent. * * Legacy field names (`webagentWithSelection`, `inline`, `voiceCleanup`) * are still accepted as fallback sources so existing host configs keep * working; prefer the new names for new code. */ type LLMRole = 'webagent' | 'vision' | 'utility' | 'plan' /** v0.2.0. Conversational + tool-calling agent * (TaskAgent). Defaults to `webagent` when not explicitly set on * the router. */ | 'task' /** @deprecated — alias for `vision`. */ | 'webagentWithSelection' /** @deprecated — alias for `utility`. */ | 'inline' /** @deprecated — alias for `utility`. */ | 'voiceCleanup'; interface LLMRouter { /** Required. Default LLM for the webagent loop. All other roles fall back here. */ webagent: LLMProvider; /** Used when the agent has images / screenshots to reason about. */ vision?: LLMProvider; /** Short single-shot calls — inline AI, voice cleanup, immersive translate. */ utility?: LLMProvider; /** Pre-loop planner. */ plan?: LLMProvider; /** v0.2.0. TaskAgent — conversational + tool calling. */ task?: LLMProvider; /** @deprecated — use `vision`. */ webagentWithSelection?: LLMProvider; /** @deprecated — use `utility`. */ inline?: LLMProvider; /** @deprecated — use `utility`. */ voiceCleanup?: LLMProvider; } type LLMSource = LLMProvider | LLMRouter; declare function isLLMRouter(v: LLMSource): v is LLMRouter; declare function resolveLLM(source: LLMSource, role: LLMRole): LLMProvider; /** * LLM Adapter — uniform façade over a single vendor (OpenAI, Google, * self-hosted vLLM, custom backend, …). Hosts and dddk register adapters * once at boot, then construct `LLMProvider` instances by adapter id. * * The adapter interface is intentionally small: an `id` for the registry * key, an optional `matchesModel` for auto-detection callers (which model * id belongs to which vendor), and a `create()` factory that turns a * config bag into an `LLMProvider`. * * Config is a free-form bag because different vendors take different * fields (ProxyAdapter wants `endpoint` and `buildBody`; OpenAI wants * `apiKey` and `baseURL`; an Azure-OpenAI adapter would want a * `deployment` + `apiVersion`). Each adapter narrows the shape at * `create()` time. * * See `notes/llm-adapter-guide.md` § 7 for the design rationale and the * 12-step adapter-author checklist. */ interface AdapterConfig { /** Vendor API key. Most adapters require it; proxy adapter doesn't. */ apiKey?: string; /** Base URL — for swapping to a same-origin proxy, AI Gateway, or * self-hosted endpoint. */ baseURL?: string; /** Default model id when CompleteOptions.model is not set. */ model?: string; /** Adapter-specific extras (e.g. proxy endpoint, custom headers). */ [key: string]: unknown; } interface LLMAdapter { /** Stable id used as the registry key and as the `provider` half of a * `":"` spec string (e.g. `"openai:gpt-5.4-mini"`). */ readonly id: string; /** Optional auto-detection: given a raw model id, does this adapter * handle it? Used by routers that want to route a model id without * a paired provider name. */ matchesModel?(modelId: string): boolean; /** Build an `LLMProvider` instance from a config bag. */ create(config: AdapterConfig): LLMProvider; } /** * OpenAI adapter — wraps `OpenAIProvider`. * * `matchesModel` returns true for OpenAI's first-party model families * (`gpt-*`, `o1`/`o3`/..., `text-*`). Routers that detect by model id * use this as the dispatch test. * * Vendor quirks (see `notes/llm-adapter-guide.md` § 3 for full table): * - gpt-5+ (incl. `-mini`/`-nano`) require `max_completion_tokens`, * NOT `max_tokens`. * - True reasoning models (o-series, gpt-5+ full non-mini) reject * custom `temperature`. * - `-mini` / `-nano` accept both temperature AND `max_completion_tokens`. * These are all handled inside `OpenAIProvider.complete()`. */ declare const openaiAdapter: LLMAdapter; /** * Google adapter — wraps `GoogleProvider`, covering both Gemini and Gemma * model families (both go through `generativelanguage.googleapis.com`). * `matchesModel` recognises `gemini-*` and `gemma-*` model ids. * * Model-family quirks are normalised inside `GoogleProvider.complete()` * via `buildThinkingConfig` — Gemini 3.x / Gemma 4 use `thinkingLevel`, * Gemini 2.5 uses `thinkingBudget`, and `part.thought === true` parts * are stripped from responses so reasoning never leaks. */ declare const googleAdapter: LLMAdapter; /** * Proxy adapter — wraps `ProxyProvider`. The production-safe pattern for * BYOK in browser apps: client → your /api/llm endpoint → vendor. The * adapter is registered under id `'proxy'`; `matchesModel` is omitted * because proxy routes don't carry a model-id naming convention. * * Config keys: * - `endpoint` (string, required) — backend URL * - `method` ('POST' | 'PUT') * - `headers` (Record) * - `credentials` (RequestCredentials) * - `buildBody` ((opts) => unknown) * - `parseResult` ((raw) => CompleteResult) * - `timeoutMs` (number) * - `name` (string) * * See `ProxyProviderConfig` for the full surface. */ declare const proxyAdapter: LLMAdapter; /** * Agnes AI adapter — wraps `OpenAIProvider` against Agnes AI's * OpenAI-compatible gateway. * * Agnes AI (https://agnes-ai.com) exposes a vLLM-backed, OpenAI-compatible * API at `https://apihub.agnes-ai.com/v1`: standard `/chat/completions` * (blocking + SSE streaming), `/models`, `/images/generations`, Bearer * auth. Responses carry extra `provider_specific_fields` keys which the * OpenAI parser ignores, and may omit the `data: [DONE]` SSE sentinel * (the empty-`choices` usage chunk marks the end) — both handled by * `OpenAIProvider`'s tolerant stream reader. * * The free tier is capped at ~20 RPM on a shared key, so the canonical * deployment proxies through a backend (dddk-frontend `/api/llm/agnes/v1`) * that injects the real key and throttles — same `proxied-via-worker` * dummy-key pattern as the other vendors. Direct use (passing a real * `apiKey`) works too for local testing. * * Verified live 2026-06-23 via curl: base URL, bearer auth, model list * (`agnes-2.0-flash` for chat), blocking + streaming all confirmed. */ declare const agnesAdapter: LLMAdapter; /** * Adapter registry — module-level map from id → adapter. * * IMPORTANT: This module does NOT auto-register the built-in adapters. * Per the tsup tree-shake constraint (see project memory * `feedback_sdk_seed_tree_shake.md`), side-effect imports are dropped * from the bundle, so the host MUST call `seedDefaultAdapters()` * explicitly at boot to get the OpenAI/Google/Proxy adapters available. * * The registry is per-process (module singleton). All `createProvider` * / `getAdapter` calls see the same map. Re-registering an id silently * replaces the previous adapter — useful when an app wants to swap the * default OpenAI adapter for one that hits an internal proxy. */ /** Register or replace an adapter by id. */ declare function registerAdapter(adapter: LLMAdapter): void; /** Get an adapter by id, or undefined if not registered. */ declare function getAdapter(id: string): LLMAdapter | undefined; /** All currently-registered adapters (insertion order). */ declare function listAdapters(): LLMAdapter[]; /** Remove an adapter from the registry. Returns whether it existed. */ declare function unregisterAdapter(id: string): boolean; /** * Build a provider from either: * 1. A `":"` spec string (`"openai:gpt-5.4-mini"`) * — looks up the adapter, calls `.create({ model })` against it. * Caller can pass extra `{ apiKey, baseURL, ... }` via the second arg. * 2. A config object with an explicit `adapter` field. * * Throws if no adapter is registered for the given id. We throw instead * of returning null because a missing adapter is almost always a boot- * order bug (forgot `seedDefaultAdapters()`) and a loud failure * surfaces it faster than a silent `null`. */ declare function createProvider(spec: string | (AdapterConfig & { adapter: string; }), extra?: AdapterConfig): LLMProvider; /** * LLM Adapter system — public entry point. * * Typical boot: * * import { seedDefaultAdapters, createProvider } from '@perhapxin/dddk'; * seedDefaultAdapters(); * const provider = createProvider('google:gemma-4-26b-a4b-it', { * apiKey: env.GOOGLE_API_KEY, * }); * * Custom adapter (e.g. self-hosted vLLM exposing an OpenAI-compatible * endpoint at a private URL): * * import { registerAdapter, type LLMAdapter } from '@perhapxin/dddk'; * registerAdapter({ * id: 'company-vllm', * matchesModel: (m) => m.startsWith('vllm/'), * create: (c) => new OpenAIProvider({ * apiKey: c.apiKey ?? '', * baseURL: 'https://llm.internal.acme.com/v1', * model: c.model, * }), * }); * * `seedDefaultAdapters()` must be called explicitly — bundlers tree-shake * pure side-effect imports. */ /** * Register the built-in adapters (`openai`, `google`, `proxy`, `agnes`). * Idempotent: safe to call multiple times. Returns the list of registered * adapter ids for diagnostic logging. */ declare function seedDefaultAdapters(): string[]; export { type AdapterConfig, type CompleteOptions, type CompleteResult, type ContentPart, GoogleProvider, type GoogleProviderConfig, type LLMAdapter, type LLMMessage, type LLMProvider, type LLMRole$1 as LLMRole, type LLMRouter, type LLMRole as LLMRouterRole, type LLMSource, OpenAIProvider, type OpenAIProviderConfig, ProxyProvider, type ProxyProviderConfig, type StreamChunk, type StreamHandle, type StreamingProvider, type ToolCall, type ToolDefinition, agnesAdapter, buildStream, createProvider, getAdapter, googleAdapter, isLLMRouter, listAdapters, openaiAdapter, proxyAdapter, registerAdapter, resolveLLM, seedDefaultAdapters, unregisterAdapter };