Core
A provider-agnostic, OpenAI-shaped AI client protocol: chat, streaming, embeddings, TTS and STT.
@ozaco/ai/core@ozaco/ai/core declares AI — a plugin protocol describing an OpenAI-compatible client surface — without binding to any concrete provider. You install an implementation (OpenAI, from @ozaco/ai/impl/openai) with provider config, then drive it through AI.actions.*: chat/chatStream for completions, embed for vectors, tts/ttsStream for speech synthesis, and stt/sttStream for transcription.
The protocol mirrors the Logger/Codec shape from @ozaco/std: every action is effect-native — an operation returning a Future — and composes with the rest of the effect system. Failures surface as a Result tagged with one of the ai.* AiErrors values rather than throwing, so callers pattern-match on the tag. Streaming actions return an effect Stream that settles with a StreamClose (true on a clean end, or a mid-stream Result.Failure).
Everything here is a declaration: AI carries no transport of its own. The concrete work — request building, SSE parsing, HTTP via std:fetch, JSON via the std:codec registry — lives in the implementation. All request/response types live under the AiDef namespace and are re-exported from this entry alongside the AiErrors tags and the default-model constants.
@ozaco/ai/impl/openai`OpenAI` — the concrete OpenAI-compatible implementation of `AI`, built over `std:fetch`. Install it with `{ apiKey, baseURL?, model?, headers? }`; works against api.openai.com and any OpenAI-compatible server (including local ones) via `baseURL`.
Client
The provider-agnostic AI protocol and its routed actions.
AI
The AI protocol — declares the OpenAI-shaped client surface implemented by `OpenAI`.
const AI: Protocol<
AiDef.Context,
[config: AiDef.Config],
AiDef.Actions
>
// AI.actions run against the installed AI implementation (e.g. OpenAI):
AI.actions.chat(
messages: AiDef.Message[],
options?: AiDef.ChatOptions,
): Future<AiDef.ChatResult>
AI.actions.chatStream(
messages: AiDef.Message[],
options?: AiDef.ChatOptions,
): Future<Stream<AiDef.ChatStreamChunk, AiDef.StreamClose>>
AI.actions.embed(
input: string | string[],
options?: AiDef.EmbedOptions,
): Future<number[][]>
AI.actions.tts(text: string, options?: AiDef.TtsOptions): Future<Uint8Array>
AI.actions.ttsStream(
text: string,
options?: AiDef.TtsOptions,
): Future<Stream<Uint8Array, AiDef.StreamClose>>
AI.actions.stt(audio: Uint8Array | Blob, options?: AiDef.SttOptions): Future<string>
AI.actions.sttStream(
audio: Uint8Array | Blob,
options?: AiDef.SttOptions,
): Future<Stream<string, AiDef.StreamClose>>Defined with defineProtocol (name ozaco/ai, version 0.0.1, subtype AI_SUBTYPE). AI declares the contract only — install a concrete implementation to bind a provider, then all AI.actions.* calls dispatch to it.
Per-call options override the install-time defaults: a missing model falls back to the install config.model, then to the appropriate DEFAULT_* constant. Any non-2xx response is mapped to an ai.* AiErrors tag; other transport errors surface raw from std:fetch.
import { AI } from '@ozaco/ai/core'
import { OpenAI } from '@ozaco/ai/impl/openai'
import { install } from '@ozaco/std/plugin'
function* program() {
yield* install(OpenAI, { apiKey: process.env.OPENAI_API_KEY! })
const out = yield* AI.actions.chat([
{ role: 'system', content: 'You are terse.' },
{ role: 'user', content: 'Say hi.' },
])
console.log(out.text) //=> the assistant reply
// Stream tokens as they arrive.
const stream = yield* AI.actions.chatStream([{ role: 'user', content: 'Count to 5' }])
for (const chunk of yield* each(stream)) {
process.stdout.write(chunk.delta)
yield* each.next()
}
}Types
The request/response shapes, all under the `AiDef` namespace.
AiDef
The AI plugin shape and every associated config, message, option and result type.
type AiDef = Plugin<AiDef.Context, [config: AiDef.Config], AiDef.Actions>The AiDef namespace also exposes:
- •
Config— install-time provider config:{ apiKey; baseURL?; model?; headers? }. - •
Context— resolved provider context stored after install. - •
Role—'system' | 'user' | 'assistant' | 'tool'. - •
Message— one chat message, with optional tool-call fields. - •
ChatOptions/ChatResult/Usage— per-call chat inputs and the normalized result. - •
Tool/ToolCall/ToolChoice— function-calling shapes. - •
ResponseFormat— free text, a JSON object, or a named JSON Schema. - •
ChatStreamChunk/ToolCallDelta— the streaming-completion fragments. - •
EmbedOptions/TtsOptions/SttOptions— per-call options for the other actions. - •
StreamClose—true | Result.Failure<unknown>, the value a token stream settles with. - •
Actions— the seven-method action surface (chat…sttStream). - •
Error— theai.*tag union widened with whateverstd:fetchsurfaces raw.
AiDef.Config
Install-time configuration for an OpenAI-compatible provider.
interface Config {
/** API key sent as `Authorization: Bearer <apiKey>`. */
apiKey: string
/** Base URL of the OpenAI-compatible API. Defaults to `https://api.openai.com/v1`. */
baseURL?: string
/** Default model used for chat completions when a call omits `model`. */
model?: string
/** Extra headers merged into every request. */
headers?: Record<string, string>
}Passed as the install argument: install(OpenAI, config). Only apiKey is required; baseURL defaults to DEFAULT_BASE_URL, and model (when set) is the fallback for calls that omit their own.
import { OpenAI } from '@ozaco/ai/impl/openai'
import { install } from '@ozaco/std/plugin'
yield* install(OpenAI, {
apiKey: process.env.OPENAI_API_KEY!,
baseURL: 'http://localhost:11434/v1', // any OpenAI-compatible server
model: 'llama3.1',
})AiDef.Context
Resolved provider context, stored after install.
interface Context {
apiKey: string
baseURL: string
model: string | undefined
headers: Record<string, string>
}The normalized form of Config an implementation produces during setup — baseURL and headers are always populated (defaulted from Config), while model stays undefined when no install default was given.
AiDef.Role
The role a chat message plays in the conversation.
type Role = 'system' | 'user' | 'assistant' | 'tool'AiDef.Message
One chat message; assistant messages may carry tool calls, tool messages a call id.
interface Message {
role: Role
content: string
/** Optional name (e.g. the tool name for `role: 'tool'`). */
name?: string
/** Tool calls the assistant requested (set on `role: 'assistant'` messages). */
toolCalls?: ToolCall[]
/** The `ToolCall.id` this message answers (set on `role: 'tool'` result messages). */
toolCallId?: string
}const messages: AiDef.Message[] = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'What is 2 + 2?' },
]AiDef.ChatOptions
Per-call chat options forwarded to `/chat/completions`.
interface ChatOptions {
model?: string
temperature?: number
topP?: number
maxTokens?: number
stop?: string | string[]
frequencyPenalty?: number
presencePenalty?: number
seed?: number
/** Tools (functions) the model may call. */
tools?: Tool[]
/** How the model should pick tools. */
toolChoice?: ToolChoice
/** Constrain the model's output (free text, a JSON object, or a named JSON Schema). */
responseFormat?: ResponseFormat
/** Arbitrary extra body fields merged last (escape hatch for provider-specific params). */
extra?: Record<string, unknown>
}camelCase fields are mapped to the provider's snake_case wire names; undefined fields are dropped so nothing spurious is sent. extra is merged last, so it can override or add any body field the typed options do not cover.
const out = yield* AI.actions.chat(messages, {
model: 'gpt-4o',
temperature: 0.2,
maxTokens: 512,
seed: 7,
})AiDef.ChatResult
The normalized result of a non-streaming chat completion.
interface ChatResult {
/** The full assistant message returned by the provider. */
message: Message
/** Convenience alias for `message.content`. */
text: string
model: string
finishReason: string | undefined
usage: Usage | undefined
/** Tool calls the model requested, or `undefined` when none. */
toolCalls: ToolCall[] | undefined
}Always populated from the first choice: text mirrors message.content (empty string when the model returned none), model falls back to the requested model when the provider omits it, and toolCalls is undefined unless the model requested at least one.
AiDef.Usage
Token accounting reported by the provider, when available.
interface Usage {
promptTokens: number | undefined
completionTokens: number | undefined
totalTokens: number | undefined
}Present on ChatResult.usage and — for streaming — on the final ChatStreamChunk (requested via stream_options.include_usage). Individual fields are undefined when the provider omits them.
AiDef.Tool
A function the model may call; `parameters` is a JSON Schema for the arguments.
interface Tool {
type: 'function'
function: {
name: string
description?: string
parameters?: Record<string, unknown>
}
}const getWeather: AiDef.Tool = {
type: 'function',
function: {
name: 'get_weather',
description: 'Look up the weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
}
const out = yield* AI.actions.chat(messages, { tools: [getWeather] })AiDef.ToolCall
A tool invocation the model requested; `arguments` is the raw JSON string it emitted.
interface ToolCall {
id: string
name: string
arguments: string
}Returned in ChatResult.toolCalls (and echoed on the assistant Message). arguments is a JSON string you parse yourself; answer a call by replying with a Message of role: 'tool' whose toolCallId equals this id.
AiDef.ToolChoice
How the model should pick tools: a strategy, or a forced `{ name }`.
type ToolChoice = 'auto' | 'none' | 'required' | { name: string }AiDef.ResponseFormat
Constrain output to free text, a JSON object, or a named JSON Schema.
type ResponseFormat =
| { type: 'text' }
| { type: 'json_object' }
| {
type: 'json_schema'
jsonSchema: { name: string; schema: Record<string, unknown>; strict?: boolean }
}text is the default free-form mode; json_object asks the model to emit syntactically valid JSON; json_schema constrains it to a named schema — set strict: true for guaranteed-conformant output where the provider supports it.
const out = yield* AI.actions.chat(messages, {
responseFormat: {
type: 'json_schema',
jsonSchema: {
name: 'person',
strict: true,
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } },
required: ['name', 'age'],
},
},
},
})AiDef.ChatStreamChunk
One streamed `/chat/completions` chunk: text delta, tool-call fragments, finish reason and usage.
interface ChatStreamChunk {
delta: string
toolCalls?: ToolCallDelta[]
finishReason?: string
usage?: Usage
}delta is the incremental content (empty string when the chunk carries no text). A single chunk usually carries only one signal — for example the final chunk often has just usage. Tool-call arguments arrive as partial JSON fragments; the consumer accumulates them (see ToolCallDelta).
AiDef.ToolCallDelta
One tool-call fragment within a streaming chunk; `arguments` is a PARTIAL JSON fragment.
interface ToolCallDelta {
index: number
id?: string
name?: string
/** Partial JSON fragment of the tool call's arguments — concatenate across chunks by `index`. */
arguments?: string
}The provider streams a tool call across many chunks. Accumulate fragments sharing the same index (adopting the first non-empty id/name) to reconstruct the full ToolCall; that stitching is the consumer's responsibility.
AiDef.EmbedOptions
Per-call options for `embed`.
interface EmbedOptions {
model?: string
dimensions?: number
extra?: Record<string, unknown>
}AiDef.TtsOptions
Per-call options for `tts` / `ttsStream`.
interface TtsOptions {
model?: string
voice?: string
/** Output format, e.g. `mp3`, `wav`, `opus`. */
format?: string
speed?: number
extra?: Record<string, unknown>
}model defaults to DEFAULT_TTS_MODEL and voice to DEFAULT_TTS_VOICE when omitted; format maps to the provider response_format field.
AiDef.SttOptions
Per-call options for `stt` / `sttStream`.
interface SttOptions {
model?: string
/** ISO-639-1 language hint. */
language?: string
prompt?: string
/** File name reported to the provider for the multipart upload. */
filename?: string
/** MIME type of the audio blob. */
contentType?: string
extra?: Record<string, string>
}The audio is uploaded as a multipart file part; filename defaults to audio and contentType to application/octet-stream when a raw Uint8Array is passed (ignored for a Blob, which carries its own type).
AiDef.StreamClose
The value a token stream settles with: `true` on a clean end, or a mid-stream failure.
type StreamClose = true | Result.Failure<unknown>Every streaming action (chatStream, ttsStream, sttStream) returns a Stream<_, StreamClose>. Inspect the close value to distinguish a clean end (true) from an error raised while the stream was in flight.
AiDef.Error
The failure type actions can surface: an `ai.*` tag, or whatever `std:fetch` surfaces raw.
type Error = (typeof AiErrors)[keyof typeof AiErrors] | FetchDef.ErrorMapped non-2xx responses fail with one of the AiErrors tags. Because std:fetch no longer re-tags thrown transport errors (FetchDef.Error is unknown), this union widens to unknown — match the ai.* tags by value.
Errors
AiErrors
The `ai.*` error tags AI actions raise failures with.
const AiErrors: {
Request: 'ai.request'
Auth: 'ai.auth'
RateLimit: 'ai.rate-limit'
BadResponse: 'ai.bad-response'
Unsupported: 'ai.unsupported'
}Built with createTags('ai', …). HTTP statuses map to tags: 401/403 → Auth, 429 → RateLimit, any other non-2xx → Request. The provider's structured error.code/type can refine that — e.g. an insufficient_quota 429 stays RateLimit, while a key/permission hint on a 4xx maps to Auth.
Actions yield* fail(tag, message) rather than throwing, so callers pattern-match on the tag value carried by the Result.Failure.
import { AiErrors } from '@ozaco/ai/core'
import { isFailure } from '@ozaco/std/result'
const out = yield* attempt(AI.actions.chat(messages))
if (isFailure(out) && out.error === AiErrors.RateLimit) {
// back off and retry
}Constants
The subtype tag, default models, and the SSE sentinel.
AI_SUBTYPE
Subtype tag identifying the AI protocol.
const AI_SUBTYPE: unique symbol // Symbol.for('ozaco:ai')DEFAULT_BASE_URL
Default OpenAI-compatible base URL.
const DEFAULT_BASE_URL = 'https://api.openai.com/v1'Used when install config.baseURL is omitted. Override it with any OpenAI-compatible server, including a local one.
DEFAULT_CHAT_MODEL
Default chat model when neither install config nor a per-call option provides one.
const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'DEFAULT_EMBED_MODEL
Default embeddings model.
const DEFAULT_EMBED_MODEL = 'text-embedding-3-small'DEFAULT_TTS_MODEL
Default text-to-speech model.
const DEFAULT_TTS_MODEL = 'tts-1'DEFAULT_TTS_VOICE
Default text-to-speech voice.
const DEFAULT_TTS_VOICE = 'alloy'DEFAULT_STT_MODEL
Default speech-to-text (transcription) model.
const DEFAULT_STT_MODEL = 'whisper-1'SSE_DONE
The SSE sentinel a chat stream emits to signal the end of the token stream.
const SSE_DONE = '[DONE]'