Kynetra FX

Documentation

AI port

AiPort is a minimal, provider-agnostic interface for two AI primitives: text completion and text embedding. Because your business logic depends on the port, not an SDK, you can swap OpenAI for Anthropic for Cloudflare Workers AI — or run tests entirely offline with mockAi.

The AiPort interface#

AiPort exposes exactly two methods. This intentional minimalism means any model, any provider, and any future API shape can implement the port without forcing a breaking change on your application code.

NameTypeDescription
complete(options)({ messages: Message[], model?: string }) → Promise<{ text: string }>Sends a chat-style message array to the model and returns the assistant's text reply.
embed(options)({ input: string | string[], model?: string }) → Promise<{ vectors: number[][] }>Returns one dense vector per input string. Each vector's dimension depends on the model.

Message shape

Messages follow the universal { role, content } shape used by every major LLM API. role is a string — typically "user", "assistant", or "system".

examples/message-shape.ts
import type { AiPort } from '@kynetra/fx'
 
async function summarise(ai: AiPort, text: string): Promise<string> {
const { text: summary } = await ai.complete({
messages: [
{ role: 'system', content: 'You are a concise summariser.' },
{ role: 'user', content: `Summarise the following in two sentences:\n\n${text}` },
],
})
return summary
}
 
async function embed(ai: AiPort, sentences: string[]): Promise<number[][]> {
const { vectors } = await ai.embed({ input: sentences })
return vectors // vectors[i] corresponds to sentences[i]
}

Tip

The model field is optional on both methods. If omitted, the adapter uses its configured default. Passing it explicitly lets you override per-call — useful for routing cheap queries to a small model and expensive ones to a large model.

mockAi — deterministic test double#

mockAi(options?) returns an AiPort that never makes network calls. Every call to complete returns the same configurable reply; every call to embed returns random-but-stable unit vectors of a configurable dimension. Tests that use mockAi are deterministic, fast, and free.

NameTypeDescription
replystringThe fixed text returned by complete(). Defaults to an empty string.
dimnumberDimension of each embedding vector returned by embed(). Defaults to 3.
examples/mock-ai.ts
import { mockAi } from '@kynetra/fx'
 
const ai = mockAi({ reply: 'This is a two-sentence summary.', dim: 4 })
 
// complete() always returns the configured reply
const { text } = await ai.complete({
messages: [{ role: 'user', content: 'Summarise something.' }],
})
console.log(text) // 'This is a two-sentence summary.'
 
// embed() returns vectors of the configured dimension
const { vectors } = await ai.embed({ input: ['hello', 'world'] })
console.log(vectors.length) // 2
console.log(vectors[0].length) // 4

Provider-agnostic design#

Because AiPort is just a TypeScript interface, swapping providers is a one-line change at your composition root. Your handler code, your services, and your RAG pipeline never import a provider SDK — they import AiPort.

src/services/content.ts
import type { AiPort } from '@kynetra/fx'
 
export class ContentService {
constructor(private ai: AiPort) {}
 
async generateTitle(body: string): Promise<string> {
const { text } = await this.ai.complete({
messages: [
{ role: 'system', content: 'Return only the title. No explanation.' },
{ role: 'user', content: `Generate a blog title for:\n${body}` },
],
})
return text.trim()
}
 
async vectorise(texts: string[]): Promise<number[][]> {
const { vectors } = await this.ai.embed({ input: texts })
return vectors
}
}

In your app entrypoint, inject whichever adapter fits the deployment target:

src/app.ts
import { createFX } from '@kynetra/fx'
import { openAiAdapter } from '@kynetra/fx-ai'
import { ContentService } from './services/content'
 
const app = createFX()
 
// Inject once at startup — handlers never know which provider this is
const ai = openAiAdapter({ apiKey: process.env.OPENAI_API_KEY! })
app.decorateContext('content', new ContentService(ai))
 
app.post('/generate-title', async (ctx) => {
const { body } = await ctx.jsonBody<{ body: string }>()
const svc = ctx.get('content')
const title = await svc.generateTitle(body)
return ctx.json({ title })
})

Injecting AiPort into context#

For simpler use-cases, inject AiPort directly rather than wrapping it in a service class:

src/routes/ask.ts
import { createFX } from '@kynetra/fx'
import { mockAi } from '@kynetra/fx-ai'
 
const app = createFX()
app.decorateContext('ai', mockAi({ reply: 'I am a placeholder.' }))
 
app.post('/ask', async (ctx) => {
const ai = ctx.get('ai')
const { question } = await ctx.jsonBody<{ question: string }>()
const { text } = await ai.complete({
messages: [{ role: 'user', content: question }],
})
return ctx.json({ answer: text })
})

Testing with mockAi#

Tests that exercise AI-powered routes use mockAi — no API key, no network, no flakiness.

src/routes/ask.test.ts
import { describe, it, expect } from 'vitest'
import { mockAi } from '@kynetra/fx-ai'
import app from './ask'
 
describe('POST /ask', () => {
it('returns the AI reply', async () => {
app.decorateContext('ai', mockAi({ reply: 'The answer is 42.' }))
 
const res = await app.fetch(new Request('http://x/ask', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ question: 'What is the meaning of life?' }),
}))
 
expect(res.status).toBe(200)
const { answer } = await res.json() as { answer: string }
expect(answer).toBe('The answer is 42.')
})
 
it('passes the question in the message body', async () => {
let captured: { role: string; content: string }[] = []
const ai = {
async complete(opts: { messages: { role: string; content: string }[] }) {
captured = opts.messages
return { text: 'ok' }
},
async embed() { return { vectors: [] } },
}
app.decorateContext('ai', ai)
 
await app.fetch(new Request('http://x/ask', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ question: 'What is TypeScript?' }),
}))
 
expect(captured[0].content).toBe('What is TypeScript?')
})
})

Note

Because AiPort is a plain interface you can also write a hand-rolled test double (as shown in the second test above) when you need to capture call arguments. No mocking library needed.

Next steps#

  • AI providers — wire up OpenAI, Anthropic, or Workers AI
  • RAG — compose AiPort with VectorPort for retrieval-augmented generation
  • Ports & adapters — the overall philosophy