@nixxie-cms/ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nixxie International DMCC
4
+ Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
5
+ (this software is derived from the KeystoneJS project, https://keystonejs.com)
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @nixxie-cms/ai
2
+
3
+ LLM text generation and embeddings for Nixxie CMS, with a provider-agnostic interface. Defaults to
4
+ Anthropic's Claude (model `claude-opus-4-8`) via the official `@anthropic-ai/sdk`; OpenAI is also
5
+ supported. SDKs are loaded lazily — install only the provider you use.
6
+
7
+ ```ts
8
+ import { config } from '@nixxie-cms/core'
9
+ import { createAi } from '@nixxie-cms/ai'
10
+
11
+ export default config({
12
+ ai: createAi({ provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY! }),
13
+ })
14
+ ```
15
+
16
+ Available everywhere as `context.services.ai`:
17
+
18
+ ```ts
19
+ // One-shot completion — great for generating SEO descriptions, summaries, alt text, etc.
20
+ const { text } = await context.services.ai.complete(
21
+ `Write a 155-character meta description for: ${post.title}`
22
+ )
23
+
24
+ // Multi-turn chat
25
+ const reply = await context.services.ai.chat([
26
+ { role: 'system', content: 'You are a helpful editorial assistant.' },
27
+ { role: 'user', content: 'Suggest five headline variations for this article.' },
28
+ ])
29
+
30
+ // Embeddings — pair with @nixxie-cms/search for semantic search
31
+ const vector = await context.services.ai.embed(post.body)
32
+ ```
33
+
34
+ ## Providers
35
+
36
+ | provider | SDK | embeddings |
37
+ | ----------- | ------------------ | ---------- |
38
+ | `anthropic` | `@anthropic-ai/sdk` | via Voyage AI (`voyageApiKey`) — Anthropic has no native embeddings endpoint |
39
+ | `openai` | `openai` | native (`text-embedding-3-small` by default) |
40
+
41
+ Install the SDK for your chosen provider, e.g. `npm install @anthropic-ai/sdk`.
@@ -0,0 +1,2 @@
1
+ export * from "../src/index.js";
2
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1haS5janMuZC50cyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NyYy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ // this file might look strange and you might be wondering what it's for
3
+ // it's lets you import your source files by importing this entrypoint
4
+ // as you would import it if it was built with preconstruct build
5
+ // this file is slightly different to some others though
6
+ // it has a require hook which compiles your code with Babel
7
+ // this means that you don't have to set up @babel/register or anything like that
8
+ // but you can still require this module and it'll be compiled
9
+
10
+ // this bit of code imports the require hook and registers it
11
+ let unregister = require("../../../node_modules/.pnpm/@preconstruct+hook@0.4.0/node_modules/@preconstruct/hook").___internalHook(typeof __dirname === 'undefined' ? undefined : __dirname, "../../..", "..");
12
+
13
+ // this re-exports the source file
14
+ module.exports = require("../src/index.ts");
15
+
16
+ unregister();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@nixxie-cms/ai",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "main": "dist/nixxie-cms-ai.cjs.js",
6
+ "module": "dist/nixxie-cms-ai.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/nixxie-cms-ai.cjs.js",
10
+ "module": "./dist/nixxie-cms-ai.esm.js",
11
+ "default": "./dist/nixxie-cms-ai.cjs.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/runtime": "^7.24.7"
17
+ },
18
+ "devDependencies": {
19
+ "@nixxie-cms/core": "^1.0.0"
20
+ },
21
+ "peerDependencies": {
22
+ "@nixxie-cms/core": "^1.0.0"
23
+ },
24
+ "optionalDependencies": {
25
+ "@anthropic-ai/sdk": "^0.69.0",
26
+ "openai": "^6.0.0"
27
+ },
28
+ "preconstruct": {
29
+ "entrypoints": [
30
+ "index.ts"
31
+ ]
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/nixxiecms/nixxie/tree/main/packages/ai"
36
+ }
37
+ }
@@ -0,0 +1,115 @@
1
+ import type {
2
+ NixxieAiCompleteOptions,
3
+ NixxieAiCompletion,
4
+ NixxieAiMessage,
5
+ NixxieAiService,
6
+ } from '@nixxie-cms/core'
7
+ import type { AiConfig } from './types'
8
+
9
+ const DEFAULT_MODEL = 'claude-opus-4-8'
10
+
11
+ function loadAnthropic(): any {
12
+ try {
13
+ return require('@anthropic-ai/sdk').default ?? require('@anthropic-ai/sdk')
14
+ } catch {
15
+ throw new Error(
16
+ 'The Anthropic provider requires @anthropic-ai/sdk. Run: npm install @anthropic-ai/sdk'
17
+ )
18
+ }
19
+ }
20
+
21
+ /** Anthropic (Claude) provider, backed by the official @anthropic-ai/sdk. */
22
+ export class AnthropicProvider implements NixxieAiService {
23
+ private config: AiConfig
24
+ private model: string
25
+ private maxTokens: number
26
+ private client: any
27
+
28
+ constructor(config: AiConfig) {
29
+ this.config = config
30
+ this.model = config.model ?? DEFAULT_MODEL
31
+ this.maxTokens = config.maxTokens ?? 4096
32
+ const Anthropic = loadAnthropic()
33
+ this.client = new Anthropic({ apiKey: config.apiKey, baseURL: config.baseUrl })
34
+ }
35
+
36
+ async complete(prompt: string, options?: NixxieAiCompleteOptions): Promise<NixxieAiCompletion> {
37
+ return this.chat([{ role: 'user', content: prompt }], options)
38
+ }
39
+
40
+ async chat(
41
+ messages: NixxieAiMessage[],
42
+ options?: NixxieAiCompleteOptions
43
+ ): Promise<NixxieAiCompletion> {
44
+ // Anthropic takes the system prompt as a top-level field, not a message.
45
+ const system = [
46
+ options?.system,
47
+ ...messages.filter(m => m.role === 'system').map(m => m.content),
48
+ ]
49
+ .filter(Boolean)
50
+ .join('\n\n')
51
+
52
+ const turns = messages
53
+ .filter(m => m.role !== 'system')
54
+ .map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }))
55
+
56
+ const model = options?.model ?? this.model
57
+ const response = await this.client.messages.create({
58
+ model,
59
+ max_tokens: options?.maxTokens ?? this.maxTokens,
60
+ system: system || undefined,
61
+ messages: turns,
62
+ ...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
63
+ })
64
+
65
+ const text = (response.content ?? [])
66
+ .filter((block: any) => block.type === 'text')
67
+ .map((block: any) => block.text)
68
+ .join('')
69
+
70
+ return {
71
+ text,
72
+ model: response.model ?? model,
73
+ usage: {
74
+ inputTokens: response.usage?.input_tokens,
75
+ outputTokens: response.usage?.output_tokens,
76
+ },
77
+ }
78
+ }
79
+
80
+ async embed(text: string): Promise<number[]> {
81
+ const [vector] = await this.embedMany([text])
82
+ if (!vector) throw new Error('Embedding provider returned no vector')
83
+ return vector
84
+ }
85
+
86
+ async embedMany(texts: string[]): Promise<number[][]> {
87
+ if (!this.config.voyageApiKey) {
88
+ throw new Error(
89
+ 'Anthropic has no native embeddings endpoint. Set `voyageApiKey` to generate embeddings via Voyage AI, or use the OpenAI provider.'
90
+ )
91
+ }
92
+ const model = this.config.embeddingModel ?? 'voyage-3'
93
+ const res = await fetch('https://api.voyageai.com/v1/embeddings', {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/json',
97
+ Authorization: `Bearer ${this.config.voyageApiKey}`,
98
+ },
99
+ body: JSON.stringify({ input: texts, model }),
100
+ })
101
+ if (!res.ok) throw new Error(`Voyage embeddings failed (${res.status}): ${await res.text()}`)
102
+ const data: any = await res.json()
103
+ const items = (data.data ?? []) as Array<{ index?: number; embedding: number[] }>
104
+ if (items.length !== texts.length) {
105
+ throw new Error(
106
+ `Voyage returned ${items.length} embeddings for ${texts.length} inputs`
107
+ )
108
+ }
109
+ // The API does not guarantee response order matches input order — sort by `index`.
110
+ return items
111
+ .slice()
112
+ .sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
113
+ .map(d => d.embedding)
114
+ }
115
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ NixxieAiCompleteOptions,
3
+ NixxieAiCompletion,
4
+ NixxieAiMessage,
5
+ NixxieAiService,
6
+ } from '@nixxie-cms/core'
7
+ import type { AiConfig } from './types'
8
+
9
+ const DEFAULT_MODEL = 'gpt-4o'
10
+ const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
11
+
12
+ function loadOpenAi(): any {
13
+ try {
14
+ return require('openai').default ?? require('openai')
15
+ } catch {
16
+ throw new Error('The OpenAI provider requires the openai package. Run: npm install openai')
17
+ }
18
+ }
19
+
20
+ /** OpenAI provider, backed by the official openai SDK. */
21
+ export class OpenAiProvider implements NixxieAiService {
22
+ private config: AiConfig
23
+ private model: string
24
+ private maxTokens: number
25
+ private client: any
26
+
27
+ constructor(config: AiConfig) {
28
+ this.config = config
29
+ this.model = config.model ?? DEFAULT_MODEL
30
+ this.maxTokens = config.maxTokens ?? 4096
31
+ const OpenAI = loadOpenAi()
32
+ this.client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl })
33
+ }
34
+
35
+ async complete(prompt: string, options?: NixxieAiCompleteOptions): Promise<NixxieAiCompletion> {
36
+ return this.chat([{ role: 'user', content: prompt }], options)
37
+ }
38
+
39
+ async chat(
40
+ messages: NixxieAiMessage[],
41
+ options?: NixxieAiCompleteOptions
42
+ ): Promise<NixxieAiCompletion> {
43
+ const allMessages = options?.system
44
+ ? [{ role: 'system' as const, content: options.system }, ...messages]
45
+ : messages
46
+
47
+ const model = options?.model ?? this.model
48
+ const response = await this.client.chat.completions.create({
49
+ model,
50
+ max_tokens: options?.maxTokens ?? this.maxTokens,
51
+ messages: allMessages,
52
+ ...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
53
+ })
54
+
55
+ return {
56
+ text: response.choices?.[0]?.message?.content ?? '',
57
+ model: response.model ?? model,
58
+ usage: {
59
+ inputTokens: response.usage?.prompt_tokens,
60
+ outputTokens: response.usage?.completion_tokens,
61
+ },
62
+ }
63
+ }
64
+
65
+ async embed(text: string): Promise<number[]> {
66
+ const [vector] = await this.embedMany([text])
67
+ if (!vector) throw new Error('Embedding provider returned no vector')
68
+ return vector
69
+ }
70
+
71
+ async embedMany(texts: string[]): Promise<number[][]> {
72
+ const response = await this.client.embeddings.create({
73
+ model: this.config.embeddingModel ?? DEFAULT_EMBEDDING_MODEL,
74
+ input: texts,
75
+ })
76
+ const items = (response.data ?? []) as Array<{ index?: number; embedding: number[] }>
77
+ if (items.length !== texts.length) {
78
+ throw new Error(`OpenAI returned ${items.length} embeddings for ${texts.length} inputs`)
79
+ }
80
+ // The API does not guarantee response order matches input order — sort by `index`.
81
+ return items
82
+ .slice()
83
+ .sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
84
+ .map(d => d.embedding)
85
+ }
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { NixxieAiService } from '@nixxie-cms/core'
2
+ import { AnthropicProvider } from './AnthropicProvider'
3
+ import { OpenAiProvider } from './OpenAiProvider'
4
+ import type { AiConfig } from './types'
5
+
6
+ export function createAi(config: AiConfig): NixxieAiService {
7
+ switch (config.provider ?? 'anthropic') {
8
+ case 'anthropic':
9
+ return new AnthropicProvider(config)
10
+ case 'openai':
11
+ return new OpenAiProvider(config)
12
+ default: {
13
+ const exhaustive: never = config.provider as never
14
+ throw new Error(`Unknown AI provider: ${exhaustive}`)
15
+ }
16
+ }
17
+ }
18
+
19
+ export { AnthropicProvider, OpenAiProvider }
20
+ export type { AiConfig, AiProvider } from './types'
21
+ export type {
22
+ NixxieAiService,
23
+ NixxieAiMessage,
24
+ NixxieAiCompleteOptions,
25
+ NixxieAiCompletion,
26
+ } from '@nixxie-cms/core'
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type {
2
+ NixxieAiCompleteOptions,
3
+ NixxieAiCompletion,
4
+ NixxieAiMessage,
5
+ NixxieAiService,
6
+ } from '@nixxie-cms/core'
7
+
8
+ export type { NixxieAiCompleteOptions, NixxieAiCompletion, NixxieAiMessage, NixxieAiService }
9
+
10
+ export type AiProvider = 'anthropic' | 'openai'
11
+
12
+ export type AiConfig = {
13
+ /** LLM provider. Default: 'anthropic'. */
14
+ provider?: AiProvider
15
+ /** API key for the provider. */
16
+ apiKey: string
17
+ /**
18
+ * Default model for completions. Defaults to 'claude-opus-4-8' (Anthropic) or 'gpt-4o' (OpenAI).
19
+ */
20
+ model?: string
21
+ /** Override the API base URL (proxies, gateways, Azure, etc.). */
22
+ baseUrl?: string
23
+ /** Default max output tokens. Default: 4096. */
24
+ maxTokens?: number
25
+ /**
26
+ * Model used for `embed`/`embedMany`. Anthropic has no native embeddings endpoint, so for the
27
+ * Anthropic provider embeddings are produced via Voyage AI (set `voyageApiKey`). Defaults to
28
+ * 'voyage-3' (Voyage) or 'text-embedding-3-small' (OpenAI).
29
+ */
30
+ embeddingModel?: string
31
+ /** Voyage AI key, used for embeddings when `provider` is 'anthropic'. */
32
+ voyageApiKey?: string
33
+ }