Skip to content

Write a custom model ​

Every xsAI operation takes a model as its first argument, and a model is a plain function. The HTTP adapters, such as responses() and embeddings(), are functions of this kind. Write your own to test code without a service, to wrap a protocol that has no adapter, or to serve a local backend.

PackageModel typeReceivesReturnsUsed by
@xsai/textLanguageModelLanguageModelOptionsReadableStream<TextEvent>generateText, streamText, loop
@xsai/audioSpeechModelSpeechModelOptionsResponse with audiogenerateSpeech, streamSpeech
@xsai/audioTranscriptionModelTranscriptionModelOptionsReadableStream<TranscriptionEvent>generateTranscription, streamTranscription
@xsai/decideDecisionModelDecisionModelOptions{ answers, usage }decide
@xsai/embedEmbeddingModelEmbeddingModelOptions{ embeddings, usage? }embed, embedMany
@xsai/imageImageModelImageModelOptions{ images, providerMetadata? }generateImage
@xsai/modelModelCatalogAn object with list and retrieveModelCatalogEntry valueslistModels, retrieveModel

Each function can return its value directly or in a promise. Each takes an optional signal, so a model that does asynchronous work must stop when the signal aborts. When the input or the output is invalid, throw an XSAIError such as invalid-input or invalid-response.

Language model ​

A language model returns a stream of TextEvent values. This one answers with a fixed text.

ts
import type { 
LanguageModel
,
TextEvent
} from '@xsai/text'
import {
generateText
} from '@xsai/text'
const
model
:
LanguageModel
= () => new
ReadableStream
<
TextEvent
>({
start
: (
controller
) => {
controller
.
enqueue
({
type
: 'step.start' })
controller
.
enqueue
({
contentType
: 'text',
index
: 0,
type
: 'content.start' })
controller
.
enqueue
({
delta
: 'Hello.',
index
: 0,
type
: 'text.delta' })
controller
.
enqueue
({
content
: {
text
: 'Hello.',
type
: 'text' },
index
: 0,
type
: 'content.end' })
controller
.
enqueue
({
message
: {
content
: [{
text
: 'Hello.',
type
: 'text' }],
role
: 'assistant' },
reason
: 'stop',
status
: 'completed',
type
: 'step.end',
})
controller
.
close
()
}, }) const {
text
} = await
generateText
(
model
, {
input
: 'Hi.' })
console
.
log
(
text
)

Follow three rules:

  1. Emit exactly one step.end for each request, including a request that produces no text.
  2. Give each Part a content.start, its deltas, and a content.end, all with the same index.
  3. Report a failure with a step.end that has status: 'failed' and an error.

A stream that ends without step.end makes the operation reject with truncated-stream. More than one step.end in one request is a protocol-error.

Other models ​

The other models are shorter, because their results have no events to order. Each example below runs without a service.

ts
import type { 
SpeechModel
} from '@xsai/audio'
import {
generateSpeech
} from '@xsai/audio'
const
model
:
SpeechModel
= ({
input
}) =>
new
Response
(new
TextEncoder
().
encode
(
input
), {
headers
: { 'content-type': 'audio/mpeg' } })
const
audio
= await
generateSpeech
(
model
, {
input
: 'Hello.',
voice
: 'test' })
console
.
log
(
audio
.
type
,
audio
.
size
)
ts
import type { 
TranscriptionEvent
,
TranscriptionModel
} from '@xsai/audio'
import {
generateTranscription
} from '@xsai/audio'
const
model
:
TranscriptionModel
= () => new
ReadableStream
<
TranscriptionEvent
>({
start
: (
controller
) => {
controller
.
enqueue
({
type
: 'transcription.start' })
controller
.
enqueue
({
delta
: 'Hello.',
type
: 'transcription.text.delta' })
controller
.
enqueue
({
text
: 'Hello.',
type
: 'transcription.end' })
controller
.
close
()
}, }) const {
text
} = await
generateTranscription
(
model
, {
audio
: new
Blob
([]) })
console
.
log
(
text
)
ts
import type { 
DecisionModel
} from '@xsai/decide'
import {
decide
} from '@xsai/decide'
const
model
:
DecisionModel
= ({
questions
}) => ({
answers
:
Object
.
fromEntries
(
Object
.
keys
(
questions
).
map
(
id
=> [
id
, {
probability
: 0.5,
type
: 'boolean' as
const
}])),
usage
: {},
}) const {
answers
} = await
decide
(
model
, {
input
: 'A quiet forest.',
questions
: {
calm
: {
instructions
: 'Is the text calm?',
type
: 'boolean' } },
})
console
.
log
(
answers
.
calm
.
probability
)
ts
import type { 
EmbeddingModel
} from '@xsai/embed'
import {
embed
} from '@xsai/embed'
const
model
:
EmbeddingModel
= ({
input
}) => ({
embeddings
: (
Array
.
isArray
(
input
) ?
input
: [
input
]).
map
(
text
=> [
text
.
length
, 0]),
}) const {
embedding
} = await
embed
(
model
, {
input
: 'A quiet forest.' })
console
.
log
(
embedding
)
ts
import type { 
ImageModel
} from '@xsai/image'
import {
generateImage
} from '@xsai/image'
const
model
:
ImageModel
= () => ({
images
: [new
Blob
([new
Uint8Array
(8)], {
type
: 'image/png' })],
}) const {
image
} = await
generateImage
(
model
, {
input
: 'A cabin.' })
console
.
log
(
image
.
type
,
image
.
size
)
ts
import type { ModelCatalog } from '@xsai/model'

import { 
listModels
,
retrieveModel
} from '@xsai/model'
const
catalog
: ModelCatalog = {
list
: () => [{
id
: 'local-small' }, {
id
: 'local-large' }],
retrieve
: ({
id
}) => ({
id
}),
}
console
.
log
(await
listModels
(
catalog
))
console
.
log
(await
retrieveModel
(
catalog
, {
id
: 'local-small' }))

generateTranscription keeps the last transcription.end event as its result. It rejects with truncated-stream if the stream has none. embed rejects with invalid-response when embeddings is empty, and generateImage does the same when images is empty.

To give a custom model typed options of its own, read Module augmentation.

Contributors

Changelog