0Z4C0
// navigation
// @ozaco/ai

OpenAI

The concrete OpenAI-compatible implementation of the `AI` protocol, built over `std:fetch`.

import from@ozaco/ai/impl/openai

OpenAI is the shipped implementation of the AI protocol from @ozaco/ai/core. Install it with provider config and every AI.actions.* call dispatches to it, hitting the OpenAI REST surface: /chat/completions, /embeddings, /audio/speech and /audio/transcriptions. It works against api.openai.com and any OpenAI-compatible server (including local ones) via baseURL.

The implementation is thin and effect-native. Each action reads the resolved AiDef.Context, builds a request body (camelCase options mapped to the provider's snake_case wire shape, with undefined fields dropped), POSTs it through std:fetch, and — on a non-2xx — reads the body and fails with the matching ai.* tag. Request bodies, SSE chunks and error bodies all (de)serialize through the std:codec registry, so setup installs a JsonCodec when the consumer has not already registered one, keeping install(OpenAI) self-sufficient.

Streaming actions parse the raw response as Server-Sent Events, buffering partial chunks across event boundaries and stopping on the [DONE] sentinel. The SSE pump runs in a task spawned in the consumer's scope, so halting the returned Stream also aborts the in-flight fetch — cancelling a stream cancels the request, which is what makes barge-in possible.

// on this page
01

Implementation

The OpenAI-compatible plugin you install to back the `AI` protocol.

const

OpenAI

The shipped `AI` implementation over `std:fetch`; install it with `{ apiKey, baseURL?, model?, headers? }`.

signature
// from '@ozaco/ai/impl/openai'
const OpenAI: AiDef
// = AI.implement({
//     name: 'ozaco/openai',
//     version: '0.0.1',
//     description: 'OpenAI-compatible AI client over std:fetch',
//     setup,
//   }).build({ chat, chatStream, embed, tts, ttsStream, stt, sttStream })

// installed with the provider config:
install(OpenAI, config: AiDef.Config): Future<void>

On setup it validates that config.apiKey is present (failing with AiErrors.Auth otherwise), auto-installs JsonCodec when no codec is registered in the current scope, then stores the resolved AiDef.ContextbaseURL defaulted to DEFAULT_BASE_URL, headers copied, model left as given.

Every request sends Authorization: Bearer <apiKey> plus the install-time headers; JSON calls add content-type: application/json, while the multipart transcription call lets the platform set the boundary. A missing per-call model falls back to config.model, then to the endpoint's DEFAULT_* constant.

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!,
    // point at any OpenAI-compatible server:
    // baseURL: 'http://localhost:11434/v1',
    model: 'gpt-4o-mini',
  })

  const out = yield* AI.actions.chat([{ role: 'user', content: 'hi' }])
  console.log(out.text)
}
02

Actions

The seven action handlers `OpenAI` builds. Call them through `AI.actions.*` after installing.

fn

chat

POST `/chat/completions` (non-streaming); returns the normalized `ChatResult`.

signature
AI.actions.chat(
  messages: AiDef.Message[],
  options?: AiDef.ChatOptions,
): Future<AiDef.ChatResult>

Builds the completion body from messages + options, POSTs it, and parses the first choice into a ChatResult (text, message, model, finishReason, usage, toolCalls). The model is options.model ?? ctx.model ?? DEFAULT_CHAT_MODEL.

example
const out = yield* AI.actions.chat(
  [{ role: 'user', content: 'Summarize in one word: ...' }],
  { temperature: 0, maxTokens: 4 },
)
console.log(out.text, out.usage?.totalTokens)
fn

chatStream

POST `/chat/completions` with `stream: true`; yields one `ChatStreamChunk` per SSE event.

signature
AI.actions.chatStream(
  messages: AiDef.Message[],
  options?: AiDef.ChatOptions,
): Future<Stream<AiDef.ChatStreamChunk, AiDef.StreamClose>>

Sends stream: true plus stream_options: { include_usage: true }, then re-channels the raw SSE bytes into a Stream of chunks — every event is forwarded, including the final usage-only chunk. Tool-call arguments arrive as partial JSON fragments the consumer stitches by index. Halting the stream aborts the underlying fetch.

example
const stream = yield* AI.actions.chatStream([{ role: 'user', content: 'Write a haiku' }])
for (const chunk of yield* each(stream)) {
  process.stdout.write(chunk.delta)
  yield* each.next()
}
fn

embed

POST `/embeddings`; returns one vector per input.

signature
AI.actions.embed(
  input: string | string[],
  options?: AiDef.EmbedOptions,
): Future<number[][]>

Always returns an array of vectors (one per input), even for a single string. The model is options.model ?? ctx.model ?? DEFAULT_EMBED_MODEL; pass dimensions to request a reduced embedding size where the provider supports it.

example
const [vec] = yield* AI.actions.embed('hello world')
const many = yield* AI.actions.embed(['a', 'b'], { dimensions: 256 })
fn

tts

POST `/audio/speech`; returns the synthesized audio bytes.

signature
AI.actions.tts(text: string, options?: AiDef.TtsOptions): Future<Uint8Array>

Buffers the whole clip into a Uint8Array. The model defaults to DEFAULT_TTS_MODEL and the voice to DEFAULT_TTS_VOICE; options.format maps to the provider response_format.

example
const audio = yield* AI.actions.tts('Hello there', { voice: 'nova', format: 'mp3' })
yield* IO.actions.writeFile('hello.mp3', audio)
fn

ttsStream

POST `/audio/speech`; yields the synthesized audio as a byte stream.

signature
AI.actions.ttsStream(
  text: string,
  options?: AiDef.TtsOptions,
): Future<Stream<Uint8Array, AiDef.StreamClose>>

Same request body as tts, but forwards the response bytes chunk by chunk for low-latency playback rather than buffering the whole clip. Closes with true on a clean end or a Result.Failure if one is raised mid-stream.

example
const stream = yield* AI.actions.ttsStream('streaming speech')
for (const bytes of yield* each(stream)) {
  yield* speaker.write(bytes)
  yield* each.next()
}
fn

stt

POST `/audio/transcriptions` (multipart); returns the transcription text.

signature
AI.actions.stt(
  audio: Uint8Array | Blob,
  options?: AiDef.SttOptions,
): Future<string>

Uploads audio as a multipart file part (raw Uint8Array wrapped in a Blob with options.contentType, filename options.filename ?? 'audio') and returns the transcript text. The model defaults to DEFAULT_STT_MODEL; pass language/prompt as hints.

example
const text = yield* AI.actions.stt(clip, {
  filename: 'note.wav',
  contentType: 'audio/wav',
  language: 'en',
})
fn

sttStream

POST `/audio/transcriptions` (multipart, `stream: true`); yields transcript text deltas.

signature
AI.actions.sttStream(
  audio: Uint8Array | Blob,
  options?: AiDef.SttOptions,
): Future<Stream<string, AiDef.StreamClose>>

The audio is still uploaded whole, but the response is SSE and yields transcript text deltas as they arrive (only transcript.text.delta events are forwarded; empty deltas are dropped). Stops on the [DONE] sentinel.

example
const stream = yield* AI.actions.sttStream(clip, { language: 'en' })
for (const delta of yield* each(stream)) {
  process.stdout.write(delta)
  yield* each.next()
}