0Z4C0
// navigation
// @ozaco/ai

Core

A provider-agnostic, OpenAI-shaped AI client protocol: chat, streaming, embeddings, TTS and STT.

import from@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.

// additional entry points
  • @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`.
01

Client

The provider-agnostic AI protocol and its routed actions.

const

AI

The AI protocol — declares the OpenAI-shaped client surface implemented by `OpenAI`.

signature
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.

example
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()
  }
}
02

Types

The request/response shapes, all under the `AiDef` namespace.

ns

AiDef

The AI plugin shape and every associated config, message, option and result type.

signature
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.
  • StreamClosetrue | Result.Failure<unknown>, the value a token stream settles with.
  • Actions — the seven-method action surface (chatsttStream).
  • Error — the ai.* tag union widened with whatever std:fetch surfaces raw.
iface

AiDef.Config

Install-time configuration for an OpenAI-compatible provider.

signature
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.

example
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',
})
iface

AiDef.Context

Resolved provider context, stored after install.

signature
interface Context {
  apiKey: string
  baseURL: string
  model: string | undefined
  headers: Record<string, string>
}

The normalized form of Config an implementation produces during setupbaseURL and headers are always populated (defaulted from Config), while model stays undefined when no install default was given.

type

AiDef.Role

The role a chat message plays in the conversation.

signature
type Role = 'system' | 'user' | 'assistant' | 'tool'
iface

AiDef.Message

One chat message; assistant messages may carry tool calls, tool messages a call id.

signature
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
}
example
const messages: AiDef.Message[] = [
  { role: 'system', content: 'You are a helpful assistant.' },
  { role: 'user', content: 'What is 2 + 2?' },
]
iface

AiDef.ChatOptions

Per-call chat options forwarded to `/chat/completions`.

signature
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.

example
const out = yield* AI.actions.chat(messages, {
  model: 'gpt-4o',
  temperature: 0.2,
  maxTokens: 512,
  seed: 7,
})
iface

AiDef.ChatResult

The normalized result of a non-streaming chat completion.

signature
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.

iface

AiDef.Usage

Token accounting reported by the provider, when available.

signature
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.

iface

AiDef.Tool

A function the model may call; `parameters` is a JSON Schema for the arguments.

signature
interface Tool {
  type: 'function'
  function: {
    name: string
    description?: string
    parameters?: Record<string, unknown>
  }
}
example
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] })
iface

AiDef.ToolCall

A tool invocation the model requested; `arguments` is the raw JSON string it emitted.

signature
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.

type

AiDef.ToolChoice

How the model should pick tools: a strategy, or a forced `{ name }`.

signature
type ToolChoice = 'auto' | 'none' | 'required' | { name: string }
type

AiDef.ResponseFormat

Constrain output to free text, a JSON object, or a named JSON Schema.

signature
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.

example
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'],
      },
    },
  },
})
iface

AiDef.ChatStreamChunk

One streamed `/chat/completions` chunk: text delta, tool-call fragments, finish reason and usage.

signature
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).

iface

AiDef.ToolCallDelta

One tool-call fragment within a streaming chunk; `arguments` is a PARTIAL JSON fragment.

signature
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.

iface

AiDef.EmbedOptions

Per-call options for `embed`.

signature
interface EmbedOptions {
  model?: string
  dimensions?: number
  extra?: Record<string, unknown>
}
iface

AiDef.TtsOptions

Per-call options for `tts` / `ttsStream`.

signature
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.

iface

AiDef.SttOptions

Per-call options for `stt` / `sttStream`.

signature
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).

type

AiDef.StreamClose

The value a token stream settles with: `true` on a clean end, or a mid-stream failure.

signature
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.

type

AiDef.Error

The failure type actions can surface: an `ai.*` tag, or whatever `std:fetch` surfaces raw.

signature
type Error = (typeof AiErrors)[keyof typeof AiErrors] | FetchDef.Error

Mapped 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.

03

Errors

const

AiErrors

The `ai.*` error tags AI actions raise failures with.

signature
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/403Auth, 429RateLimit, 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.

example
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
}
04

Constants

The subtype tag, default models, and the SSE sentinel.

const

AI_SUBTYPE

Subtype tag identifying the AI protocol.

signature
const AI_SUBTYPE: unique symbol // Symbol.for('ozaco:ai')
const

DEFAULT_BASE_URL

Default OpenAI-compatible base URL.

signature
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.

const

DEFAULT_CHAT_MODEL

Default chat model when neither install config nor a per-call option provides one.

signature
const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
const

DEFAULT_EMBED_MODEL

Default embeddings model.

signature
const DEFAULT_EMBED_MODEL = 'text-embedding-3-small'
const

DEFAULT_TTS_MODEL

Default text-to-speech model.

signature
const DEFAULT_TTS_MODEL = 'tts-1'
const

DEFAULT_TTS_VOICE

Default text-to-speech voice.

signature
const DEFAULT_TTS_VOICE = 'alloy'
const

DEFAULT_STT_MODEL

Default speech-to-text (transcription) model.

signature
const DEFAULT_STT_MODEL = 'whisper-1'
const

SSE_DONE

The SSE sentinel a chat stream emits to signal the end of the token stream.

signature
const SSE_DONE = '[DONE]'