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.
| Name | Type | Description |
|---|---|---|
| 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".
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
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.
| Name | Type | Description |
|---|---|---|
| reply | string | The fixed text returned by complete(). Defaults to an empty string. |
| dim | number | Dimension of each embedding vector returned by embed(). Defaults to 3. |
import { mockAi } from '@kynetra/fx' const ai = mockAi({ reply: 'This is a two-sentence summary.', dim: 4 }) // complete() always returns the configured replyconst { 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 dimensionconst { vectors } = await ai.embed({ input: ['hello', 'world'] })console.log(vectors.length) // 2console.log(vectors[0].length) // 4Provider-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.
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:
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 isconst 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:
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.
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
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
AiPortwithVectorPortfor retrieval-augmented generation - Ports & adapters — the overall philosophy