OpenAI
The concrete OpenAI-compatible implementation of the `AI` protocol, built over `std:fetch`.
@ozaco/ai/impl/openaiOpenAI 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.
Implementation
The OpenAI-compatible plugin you install to back the `AI` protocol.
OpenAI
The shipped `AI` implementation over `std:fetch`; install it with `{ apiKey, baseURL?, model?, headers? }`.
// 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.Context — baseURL 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.
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)
}Actions
The seven action handlers `OpenAI` builds. Call them through `AI.actions.*` after installing.
chat
POST `/chat/completions` (non-streaming); returns the normalized `ChatResult`.
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.
const out = yield* AI.actions.chat(
[{ role: 'user', content: 'Summarize in one word: ...' }],
{ temperature: 0, maxTokens: 4 },
)
console.log(out.text, out.usage?.totalTokens)chatStream
POST `/chat/completions` with `stream: true`; yields one `ChatStreamChunk` per SSE event.
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.
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()
}embed
POST `/embeddings`; returns one vector per input.
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.
const [vec] = yield* AI.actions.embed('hello world')
const many = yield* AI.actions.embed(['a', 'b'], { dimensions: 256 })tts
POST `/audio/speech`; returns the synthesized audio bytes.
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.
const audio = yield* AI.actions.tts('Hello there', { voice: 'nova', format: 'mp3' })
yield* IO.actions.writeFile('hello.mp3', audio)ttsStream
POST `/audio/speech`; yields the synthesized audio as a byte stream.
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.
const stream = yield* AI.actions.ttsStream('streaming speech')
for (const bytes of yield* each(stream)) {
yield* speaker.write(bytes)
yield* each.next()
}stt
POST `/audio/transcriptions` (multipart); returns the transcription text.
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.
const text = yield* AI.actions.stt(clip, {
filename: 'note.wav',
contentType: 'audio/wav',
language: 'en',
})sttStream
POST `/audio/transcriptions` (multipart, `stream: true`); yields transcript text deltas.
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.
const stream = yield* AI.actions.sttStream(clip, { language: 'en' })
for (const delta of yield* each(stream)) {
process.stdout.write(delta)
yield* each.next()
}