diff --git a/.env.example b/.env.example index cc1314c..73bd6ea 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -OPENAI_API_KEY= +KTAVI_TEXT_API_KEY= +KTAVI_IMAGE_API_KEY= CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 454bb0d..271e77e 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -2,11 +2,25 @@ Ktavi reads environment variables from a `.env` file in your project root (loaded via `dotenv`). -## Required +## AI keys -| Variable | Description | Required for | -| ---------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- | -| `OPENAI_API_KEY` | OpenAI API key | `review`, `cover` (always required). `analyze`, `seo`, `fix`, `prepare` (optional -- enables AI-powered features) | +| Variable | Description | +| --------------------- | ------------------------------------------------------- | +| `KTAVI_TEXT_API_KEY` | API key for text generation (review, SEO, cover prompt) | +| `KTAVI_IMAGE_API_KEY` | API key for image generation (cover images) | + +Set the key that matches your configured provider. For example, if `ai.provider` is `anthropic`, set `KTAVI_TEXT_API_KEY` to your Anthropic key. If you also use cover image generation (OpenAI), set `KTAVI_IMAGE_API_KEY` to your OpenAI key. + +### Fallback keys + +If the Ktavi-specific variables are not set, provider-specific keys are used as fallbacks: + +| Ktavi variable | Fallback | +| --------------------- | ---------------------------------------------------------------- | +| `KTAVI_TEXT_API_KEY` | `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` (based on `ai.provider`) | +| `KTAVI_IMAGE_API_KEY` | `OPENAI_API_KEY` | + +This means existing setups with `OPENAI_API_KEY` continue to work without changes. ## Cloudinary (optional) @@ -29,7 +43,8 @@ cp .env.example .env `.env.example` contents: ``` -OPENAI_API_KEY= +KTAVI_TEXT_API_KEY= +KTAVI_IMAGE_API_KEY= CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= @@ -37,7 +52,7 @@ CLOUDINARY_API_SECRET= ## Commands and their AI requirements -| Command | Without `OPENAI_API_KEY` | With `OPENAI_API_KEY` | +| Command | Without AI key | With AI key | | --------- | --------------------------- | ------------------------------------------------------------ | | `analyze` | Metadata and structure only | Adds content summary | | `seo` | Deterministic checks only | Adds AI-powered suggestions | diff --git a/docs/providers.md b/docs/providers.md index 5b55408..72bee65 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -2,36 +2,56 @@ Ktavi uses a provider abstraction for AI text generation, image generation, and asset storage. This guide covers how to set up each provider. -## OpenAI (text and image) +## AI providers -OpenAI is currently the only supported AI provider for both text and image generation. +Ktavi supports multiple AI providers for text generation. Set the provider in your `ktavi.config.ts`: -### Setup +```typescript +export default { + ai: { + provider: 'openai', // or 'anthropic' + textModel: 'gpt-4o', + }, +}; +``` + +**Text model** is used by: `analyze` (summary), `seo` (AI suggestions), `review`, `fix` (AI suggestions), `cover` (prompt generation), and `prepare`. -1. Get an API key from [platform.openai.com](https://platform.openai.com) -2. Add it to your `.env` file: +**Image model** is used by: `cover --generate` and `prepare --generate-cover`. Image generation currently uses OpenAI regardless of the text provider setting. + +Set your API keys in `.env`: ``` -OPENAI_API_KEY=sk-... +KTAVI_TEXT_API_KEY=sk-... # for text generation (review, SEO, cover prompt) +KTAVI_IMAGE_API_KEY=sk-... # for image generation (cover --generate) ``` -### Models +Provider-specific keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) are also supported as fallbacks. `KTAVI_IMAGE_API_KEY` falls back to `OPENAI_API_KEY`. -Configure which models to use in your `ktavi.config.ts`: +### OpenAI ```typescript export default { ai: { provider: 'openai', - textModel: 'gpt-4o', // Used for review, SEO, summary, cover prompt - imageModel: 'gpt-image-2', // Used for cover image generation + textModel: 'gpt-4o', + imageModel: 'gpt-image-2', }, }; ``` -**Text model** is used by: `analyze` (summary), `seo` (AI suggestions), `review`, `fix` (AI suggestions), `cover` (prompt generation), and `prepare`. +### Anthropic (Claude) + +```typescript +export default { + ai: { + provider: 'anthropic', + textModel: 'claude-sonnet-4-20250514', + }, +}; +``` -**Image model** is used by: `cover --generate` and `prepare --generate-cover`. +Note: Anthropic does not offer image generation, so cover image generation requires `KTAVI_IMAGE_API_KEY` set to an OpenAI key. ### Supported image sizes diff --git a/package-lock.json b/package-lock.json index ddcca40..83771c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.95.2", "@inquirer/prompts": "^7.10.1", "cloudinary": "^2.5.1", "commander": "^13.1.0", @@ -59,6 +60,27 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.95.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.95.2.tgz", + "integrity": "sha512-Egddwo3sheo1PzUrMkZnH6VkQYwS0h/b/i8vSK8Ta9M45UQipAMeDFH57dYuDAfXMEUUGeKw6CMlremgMZgrSQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -99,7 +121,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2198,6 +2219,12 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3789,6 +3816,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4536,6 +4569,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6277,6 +6323,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -6614,6 +6670,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index 4e2e3a0..473e08b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "node": ">=18" }, "dependencies": { + "@anthropic-ai/sdk": "^0.95.2", "@inquirer/prompts": "^7.10.1", "cloudinary": "^2.5.1", "commander": "^13.1.0", diff --git a/src/cli/commands/analyze.ts b/src/cli/commands/analyze.ts index ad6990a..f1cea8d 100644 --- a/src/cli/commands/analyze.ts +++ b/src/cli/commands/analyze.ts @@ -2,8 +2,8 @@ import { Command } from 'commander'; import { analyzeDraftWorkflow } from '../../workflows/analyzeDraftWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; -import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; import { loadConfig } from '../../core/config.js'; +import { createTextAIProvider } from '../shared/providers.js'; export function registerAnalyzeCommand(program: Command) { program @@ -13,13 +13,8 @@ export function registerAnalyzeCommand(program: Command) { .option('--json', 'Output results as JSON') .action(async (file: string, opts: { json?: boolean }) => { try { - const apiKey = process.env.OPENAI_API_KEY; - let aiProvider; - - if (apiKey) { - const config = await loadConfig(); - aiProvider = createOpenAITextProvider(apiKey, config.ai.textModel); - } + const config = await loadConfig(); + const aiProvider = createTextAIProvider(config, process.env); const { draft, contentSummary } = await analyzeDraftWorkflow(file, { aiProvider }); const { metadata, frontmatter } = draft; diff --git a/src/cli/commands/configInit.ts b/src/cli/commands/configInit.ts index 8d0c3b9..f62aeb4 100644 --- a/src/cli/commands/configInit.ts +++ b/src/cli/commands/configInit.ts @@ -6,6 +6,7 @@ import { loadSingleConfigFile, } from '../../core/config.js'; import type { KtaviConfig } from '../../core/config.js'; +import { DEFAULT_TEXT_MODEL } from '../../core/config.js'; import { logger } from '../../core/logger.js'; import { fileExists } from '../../utils/fileSystem.js'; import { writeFile } from '../../utils/fileSystem.js'; @@ -55,13 +56,16 @@ async function promptForConfig( ): Promise> { const provider = await select({ message: 'AI provider', - choices: [{ value: 'openai' as const, name: 'OpenAI' }], + choices: [ + { value: 'openai' as const, name: 'OpenAI' }, + { value: 'anthropic' as const, name: 'Anthropic (Claude)' }, + ], default: getDefault(existing, 'ai.provider', SCHEMA_DEFAULTS.ai.provider), }); const textModel = await input({ message: 'Text model', - default: getDefault(existing, 'ai.textModel', SCHEMA_DEFAULTS.ai.textModel), + default: getDefault(existing, 'ai.textModel', DEFAULT_TEXT_MODEL[provider]), }); const imageModel = await input({ @@ -191,10 +195,9 @@ function printNextSteps(filePath: string, config: Partial): void { logger.heading('Next steps'); const steps: string[] = []; - const provider = config.ai?.provider ?? SCHEMA_DEFAULTS.ai.provider; - if (provider === 'openai') { - steps.push('Set OPENAI_API_KEY in your .env'); - } + const fallbackKey = config.ai?.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'; + steps.push(`Set KTAVI_TEXT_API_KEY in your .env (or ${fallbackKey})`); + steps.push('Set KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) for cover image generation'); if (config.storage?.provider === 'cloudinary') { steps.push('Set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET in .env'); } diff --git a/src/cli/commands/cover.ts b/src/cli/commands/cover.ts index 9ae7e1e..1c0fe39 100644 --- a/src/cli/commands/cover.ts +++ b/src/cli/commands/cover.ts @@ -2,10 +2,12 @@ import { Command } from 'commander'; import { generateAndAttachCoverWorkflow } from '../../workflows/generateAndAttachCoverWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; -import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; -import { createOpenAIImageProvider } from '../../providers/image/openaiImageProvider.js'; import { loadConfig } from '../../core/config.js'; -import { createStorageProvider } from '../shared/providers.js'; +import { + createTextAIProvider, + createImageProvider, + createStorageProvider, +} from '../shared/providers.js'; import type { ImageSize, StorageTarget } from '../../core/types.js'; export function registerCoverCommand(program: Command) { @@ -39,24 +41,28 @@ export function registerCoverCommand(program: Command) { ) => { try { const config = await loadConfig(); - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { + const aiProvider = createTextAIProvider(config, process.env); + if (!aiProvider) { logger.error( - 'OPENAI_API_KEY is required for cover generation. Add it to your .env file.', + `KTAVI_TEXT_API_KEY (or ${config.ai.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'}) is required for cover generation. Add it to your .env file.`, ); process.exit(1); } - const aiProvider = createOpenAITextProvider(apiKey, config.ai.textModel); - const storageTarget = (opts.upload ?? opts.save ?? config.storage.provider) as StorageTarget; const storageProvider = createStorageProvider(storageTarget, config, process.env); const imageProvider = opts.generate - ? createOpenAIImageProvider(apiKey, config.ai.imageModel) + ? createImageProvider(config, process.env) : undefined; + if (opts.generate && !imageProvider) { + logger.error( + 'KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) is required for image generation. Add it to your .env file.', + ); + process.exit(1); + } const result = await generateAndAttachCoverWorkflow(file, { generate: opts.generate ?? false, diff --git a/src/cli/commands/fix.ts b/src/cli/commands/fix.ts index 60a12fe..0625d4e 100644 --- a/src/cli/commands/fix.ts +++ b/src/cli/commands/fix.ts @@ -1,8 +1,8 @@ import { Command } from 'commander'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; -import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; import { loadConfig } from '../../core/config.js'; +import { createTextAIProvider } from '../shared/providers.js'; import { parseMarkdownTool } from '../../tools/parse-markdown/index.js'; import { reviewSeoTool } from '../../tools/review-seo/index.js'; import { updateFrontmatterTool } from '../../tools/update-frontmatter/index.js'; @@ -24,10 +24,7 @@ export function registerFixCommand(program: Command) { ) => { try { const config = await loadConfig(); - const apiKey = process.env.OPENAI_API_KEY; - const aiProvider = apiKey - ? createOpenAITextProvider(apiKey, config.ai.textModel) - : undefined; + const aiProvider = createTextAIProvider(config, process.env); const draft = await parseMarkdownTool({ filePath: file }); const { suggestions } = await reviewSeoTool({ draft }, aiProvider); diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 6ea61e9..c62d447 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -2,10 +2,12 @@ import { Command } from 'commander'; import { prepareDraftWorkflow } from '../../workflows/prepareDraftWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; -import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; -import { createOpenAIImageProvider } from '../../providers/image/openaiImageProvider.js'; import { loadConfig } from '../../core/config.js'; -import { createStorageProvider } from '../shared/providers.js'; +import { + createTextAIProvider, + createImageProvider, + createStorageProvider, +} from '../shared/providers.js'; import { renderInlineSuggestion, renderFrontmatterDiff } from '../../utils/diffRenderer.js'; import type { ImageSize, StorageTarget, WritingMode } from '../../core/types.js'; @@ -36,10 +38,7 @@ export function registerPrepareCommand(program: Command) { ) => { try { const config = await loadConfig(); - const apiKey = process.env.OPENAI_API_KEY; - const aiProvider = apiKey - ? createOpenAITextProvider(apiKey, config.ai.textModel) - : undefined; + const aiProvider = createTextAIProvider(config, process.env); const storageTarget = ( opts.upload !== 'none' @@ -50,10 +49,15 @@ export function registerPrepareCommand(program: Command) { ) as StorageTarget; const storageProvider = createStorageProvider(storageTarget, config, process.env); - const imageProvider = - opts.generateCover && apiKey - ? createOpenAIImageProvider(apiKey, config.ai.imageModel) - : undefined; + const imageProvider = opts.generateCover + ? createImageProvider(config, process.env) + : undefined; + if (opts.generateCover && !imageProvider) { + logger.error( + 'KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) is required for image generation. Add it to your .env file.', + ); + process.exit(1); + } const result = await prepareDraftWorkflow(file, { mode: (opts.mode as WritingMode) ?? config.writing.defaultMode, diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 15c4eaf..d5fe072 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -2,8 +2,8 @@ import { Command } from 'commander'; import { reviewDraftWorkflow } from '../../workflows/reviewDraftWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; -import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; import { loadConfig } from '../../core/config.js'; +import { createTextAIProvider } from '../shared/providers.js'; import { renderInlineSuggestion } from '../../utils/diffRenderer.js'; import type { WritingMode } from '../../core/types.js'; @@ -17,13 +17,13 @@ export function registerReviewCommand(program: Command) { .action(async (file: string, opts: { mode: string; json?: boolean }) => { try { const config = await loadConfig(); - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - logger.error('OPENAI_API_KEY is required for writing review. Add it to your .env file.'); + const aiProvider = createTextAIProvider(config, process.env); + if (!aiProvider) { + logger.error( + `KTAVI_TEXT_API_KEY (or ${config.ai.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'}) is required for writing review. Add it to your .env file.`, + ); process.exit(1); } - - const aiProvider = createOpenAITextProvider(apiKey, config.ai.textModel); const mode = (opts.mode as WritingMode) ?? config.writing.defaultMode; const result = await reviewDraftWorkflow(file, mode, aiProvider); diff --git a/src/cli/commands/seo.ts b/src/cli/commands/seo.ts index 677e956..25cbcae 100644 --- a/src/cli/commands/seo.ts +++ b/src/cli/commands/seo.ts @@ -2,8 +2,8 @@ import { Command } from 'commander'; import { optimizeSeoWorkflow } from '../../workflows/optimizeSeoWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; -import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; import { loadConfig } from '../../core/config.js'; +import { createTextAIProvider } from '../shared/providers.js'; import { renderFrontmatterDiff } from '../../utils/diffRenderer.js'; export function registerSeoCommand(program: Command) { @@ -18,10 +18,7 @@ export function registerSeoCommand(program: Command) { async (file: string, opts: { json?: boolean; apply?: boolean; sideBySide?: boolean }) => { try { const config = await loadConfig(); - const apiKey = process.env.OPENAI_API_KEY; - const aiProvider = apiKey - ? createOpenAITextProvider(apiKey, config.ai.textModel) - : undefined; + const aiProvider = createTextAIProvider(config, process.env); const result = await optimizeSeoWorkflow(file, { apply: opts.apply, diff --git a/src/cli/shared/providers.ts b/src/cli/shared/providers.ts index 5db0979..bd6b46e 100644 --- a/src/cli/shared/providers.ts +++ b/src/cli/shared/providers.ts @@ -1,13 +1,66 @@ import { createCloudinaryStorageProvider } from '../../providers/storage/cloudinaryStorageProvider.js'; import { createLocalStorageProvider } from '../../providers/storage/localStorageProvider.js'; -import type { AssetStorageProvider } from '../../core/providers.js'; +import { createOpenAITextProvider } from '../../providers/ai/openaiTextProvider.js'; +import { createOpenAIImageProvider } from '../../providers/image/openaiImageProvider.js'; +import { createAnthropicTextProvider } from '../../providers/ai/anthropicTextProvider.js'; +import type { + AssetStorageProvider, + ImageGenerationProvider, + TextAIProvider, +} from '../../core/providers.js'; import type { KtaviConfig } from '../../core/config.js'; -import type { StorageTarget } from '../../core/types.js'; +import type { AIProvider, StorageTarget } from '../../core/types.js'; + +const FALLBACK_TEXT_KEY: Record = { + openai: 'OPENAI_API_KEY', + anthropic: 'ANTHROPIC_API_KEY', +}; + +function firstNonEmptyEnvValue( + ...values: Array +): string | undefined { + return values.find((value) => value !== undefined && value !== ''); +} + +export function resolveTextApiKey( + provider: AIProvider, + env: NodeJS.ProcessEnv, +): string | undefined { + return firstNonEmptyEnvValue( + env.KTAVI_TEXT_API_KEY, + env[FALLBACK_TEXT_KEY[provider]], + ); +} + +export function resolveImageApiKey(env: NodeJS.ProcessEnv): string | undefined { + return firstNonEmptyEnvValue(env.KTAVI_IMAGE_API_KEY, env.OPENAI_API_KEY); +} + +export function createTextAIProvider( + config: KtaviConfig, + env: NodeJS.ProcessEnv, +): TextAIProvider | undefined { + const apiKey = resolveTextApiKey(config.ai.provider, env); + if (!apiKey) return undefined; + + switch (config.ai.provider) { + case 'anthropic': + return createAnthropicTextProvider(apiKey, config.ai.textModel); + case 'openai': + default: + return createOpenAITextProvider(apiKey, config.ai.textModel); + } +} + +export function createImageProvider( + config: KtaviConfig, + env: NodeJS.ProcessEnv, +): ImageGenerationProvider | undefined { + const apiKey = resolveImageApiKey(env); + if (!apiKey) return undefined; + return createOpenAIImageProvider(apiKey, config.ai.imageModel); +} -/** - * Factory function that resolves the correct storage provider based on the - * given target, config, and environment variables. - */ export function createStorageProvider( target: StorageTarget, config: KtaviConfig, diff --git a/src/core/config.ts b/src/core/config.ts index cc2093d..c10d3be 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,11 +2,16 @@ import path from 'node:path'; import os from 'node:os'; import { createJiti } from 'jiti'; import { z } from 'zod'; -import type { CoverFieldName, ImageSize, StorageTarget, WritingMode } from './types.js'; +import type { AIProvider, CoverFieldName, ImageSize, StorageTarget, WritingMode } from './types.js'; + +export const DEFAULT_TEXT_MODEL: Record = { + openai: 'gpt-4o', + anthropic: 'claude-sonnet-4-20250514', +}; export type KtaviConfig = { ai: { - provider: 'openai'; + provider: AIProvider; textModel: string; imageModel?: string; }; @@ -36,7 +41,7 @@ export type KtaviConfig = { const ktaviConfigSchema = z.object({ ai: z .object({ - provider: z.literal('openai').default('openai'), + provider: z.enum(['openai', 'anthropic']).default('openai'), textModel: z.string().default('gpt-4o'), imageModel: z.string().optional(), }) @@ -145,6 +150,9 @@ function deepMerge(base: KtaviConfig, override: Partial): KtaviConf if (override.ai) { result.ai = { ...result.ai, ...override.ai }; + if (override.ai.provider && !override.ai.textModel) { + result.ai.textModel = DEFAULT_TEXT_MODEL[result.ai.provider]; + } } if (override.markdown) { result.markdown = { ...result.markdown, ...override.markdown }; diff --git a/src/core/types.ts b/src/core/types.ts index 7656302..d507d4f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -122,6 +122,8 @@ export type ImageSize = '1024x1024' | '1536x1024' | '1792x1024'; export type StorageTarget = 'local' | 'cloudinary'; +export type AIProvider = 'openai' | 'anthropic'; + export type ContentSummary = { shortSummary: string; keyTopics: string[]; diff --git a/src/providers/ai/anthropicTextProvider.ts b/src/providers/ai/anthropicTextProvider.ts new file mode 100644 index 0000000..f2eeebc --- /dev/null +++ b/src/providers/ai/anthropicTextProvider.ts @@ -0,0 +1,100 @@ +import type { TextAIProvider } from '../../core/providers.js'; +import { KtaviError } from '../../core/errors.js'; + +function extractJsonObject(text: string): string | null { + let searchFrom = 0; + + while (searchFrom < text.length) { + const start = text.indexOf('{', searchFrom); + if (start === -1) return null; + + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (escape) { + escape = false; + continue; + } + if (ch === '\\' && inString) { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '{') depth++; + else if (ch === '}') depth--; + if (depth === 0) { + const candidate = text.slice(start, i + 1); + try { + JSON.parse(candidate); + return candidate; + } catch { + break; + } + } + } + + searchFrom = start + 1; + } + + return null; +} + +export function createAnthropicTextProvider(apiKey: string, model: string): TextAIProvider { + if (!apiKey) { + throw new KtaviError( + 'API key is not set. Please set KTAVI_TEXT_API_KEY (or ANTHROPIC_API_KEY) in your .env file.', + 'AI_PROVIDER_ERROR', + ); + } + + return { + async generateStructuredOutput(input: { + systemPrompt: string; + userPrompt: string; + schemaName: string; + }): Promise { + const { default: Anthropic } = await import('@anthropic-ai/sdk'); + const client = new Anthropic({ apiKey }); + + const response = await client.messages.create({ + model, + max_tokens: 4096, + system: input.systemPrompt, + messages: [{ role: 'user', content: input.userPrompt }], + }); + + const textBlock = response.content.find((block) => block.type === 'text'); + if (!textBlock || textBlock.type !== 'text') { + throw new KtaviError('Empty response from AI provider.', 'AI_PROVIDER_ERROR'); + } + + const content = textBlock.text; + + const codeBlockMatch = content.match(/```(?:json)?\s*\n([\s\S]*?)\n```/); + const jsonStr = codeBlockMatch?.[1]?.trim() ?? extractJsonObject(content); + + if (!jsonStr) { + throw new KtaviError( + 'AI provider did not return valid JSON. You may need to retry the request.', + 'AI_PROVIDER_ERROR', + ); + } + + try { + return JSON.parse(jsonStr) as TOutput; + } catch (cause) { + throw new KtaviError( + 'AI provider returned malformed JSON. You may need to retry the request.', + 'AI_PROVIDER_ERROR', + cause, + ); + } + }, + }; +} diff --git a/src/providers/ai/openaiTextProvider.ts b/src/providers/ai/openaiTextProvider.ts index 03a7ed0..4a6c068 100644 --- a/src/providers/ai/openaiTextProvider.ts +++ b/src/providers/ai/openaiTextProvider.ts @@ -4,7 +4,7 @@ import { KtaviError } from '../../core/errors.js'; export function createOpenAITextProvider(apiKey: string, model: string): TextAIProvider { if (!apiKey) { throw new KtaviError( - 'OPENAI_API_KEY is not set. Please add it to your .env file.', + 'API key is not set. Please set KTAVI_TEXT_API_KEY (or OPENAI_API_KEY) in your .env file.', 'AI_PROVIDER_ERROR', ); } diff --git a/src/providers/image/openaiImageProvider.ts b/src/providers/image/openaiImageProvider.ts index 8e909a4..d8e45ca 100644 --- a/src/providers/image/openaiImageProvider.ts +++ b/src/providers/image/openaiImageProvider.ts @@ -5,7 +5,7 @@ import { KtaviError } from '../../core/errors.js'; export function createOpenAIImageProvider(apiKey: string, model?: string): ImageGenerationProvider { if (!apiKey) { throw new KtaviError( - 'OPENAI_API_KEY is not set. Please add it to your .env file.', + 'API key is not set. Please set KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) in your .env file.', 'AI_PROVIDER_ERROR', ); } diff --git a/tests/cli/shared/providers.test.ts b/tests/cli/shared/providers.test.ts index 86b58f8..9555e65 100644 --- a/tests/cli/shared/providers.test.ts +++ b/tests/cli/shared/providers.test.ts @@ -8,9 +8,30 @@ vi.mock('../../../src/providers/storage/localStorageProvider.js', () => ({ createLocalStorageProvider: vi.fn(() => ({ upload: vi.fn() })), })); -import { createStorageProvider } from '../../../src/cli/shared/providers.js'; +vi.mock('../../../src/providers/ai/openaiTextProvider.js', () => ({ + createOpenAITextProvider: vi.fn(() => ({ generateStructuredOutput: vi.fn() })), +})); + +vi.mock('../../../src/providers/ai/anthropicTextProvider.js', () => ({ + createAnthropicTextProvider: vi.fn(() => ({ generateStructuredOutput: vi.fn() })), +})); + +vi.mock('../../../src/providers/image/openaiImageProvider.js', () => ({ + createOpenAIImageProvider: vi.fn(() => ({ generateImage: vi.fn() })), +})); + +import { + createStorageProvider, + createTextAIProvider, + createImageProvider, + resolveTextApiKey, + resolveImageApiKey, +} from '../../../src/cli/shared/providers.js'; import { createCloudinaryStorageProvider } from '../../../src/providers/storage/cloudinaryStorageProvider.js'; import { createLocalStorageProvider } from '../../../src/providers/storage/localStorageProvider.js'; +import { createOpenAITextProvider } from '../../../src/providers/ai/openaiTextProvider.js'; +import { createAnthropicTextProvider } from '../../../src/providers/ai/anthropicTextProvider.js'; +import { createOpenAIImageProvider } from '../../../src/providers/image/openaiImageProvider.js'; import type { KtaviConfig } from '../../../src/core/config.js'; const baseConfig: KtaviConfig = { @@ -110,3 +131,117 @@ describe('createStorageProvider', () => { }); }); }); + +describe('resolveTextApiKey', () => { + it('prefers KTAVI_TEXT_API_KEY over provider-specific key', () => { + expect( + resolveTextApiKey('openai', { + KTAVI_TEXT_API_KEY: 'ktavi-key', + OPENAI_API_KEY: 'openai-key', + }), + ).toBe('ktavi-key'); + }); + + it('falls back to OPENAI_API_KEY for openai provider', () => { + expect(resolveTextApiKey('openai', { OPENAI_API_KEY: 'sk-123' })).toBe('sk-123'); + }); + + it('falls back to ANTHROPIC_API_KEY for anthropic provider', () => { + expect(resolveTextApiKey('anthropic', { ANTHROPIC_API_KEY: 'sk-ant-123' })).toBe('sk-ant-123'); + }); + + it('returns undefined when no key is set', () => { + expect(resolveTextApiKey('openai', {})).toBeUndefined(); + }); +}); + +describe('resolveImageApiKey', () => { + it('prefers KTAVI_IMAGE_API_KEY over OPENAI_API_KEY', () => { + expect( + resolveImageApiKey({ KTAVI_IMAGE_API_KEY: 'ktavi-img', OPENAI_API_KEY: 'openai-key' }), + ).toBe('ktavi-img'); + }); + + it('falls back to OPENAI_API_KEY', () => { + expect(resolveImageApiKey({ OPENAI_API_KEY: 'sk-123' })).toBe('sk-123'); + }); + + it('returns undefined when no key is set', () => { + expect(resolveImageApiKey({})).toBeUndefined(); + }); +}); + +describe('createTextAIProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates OpenAI provider when config.ai.provider is openai', () => { + const config = { ...baseConfig, ai: { provider: 'openai' as const, textModel: 'gpt-4o' } }; + createTextAIProvider(config, { KTAVI_TEXT_API_KEY: 'sk-123' }); + + expect(createOpenAITextProvider).toHaveBeenCalledWith('sk-123', 'gpt-4o'); + expect(createAnthropicTextProvider).not.toHaveBeenCalled(); + }); + + it('creates Anthropic provider when config.ai.provider is anthropic', () => { + const config = { + ...baseConfig, + ai: { provider: 'anthropic' as const, textModel: 'claude-sonnet-4-20250514' }, + }; + createTextAIProvider(config, { KTAVI_TEXT_API_KEY: 'sk-ant-123' }); + + expect(createAnthropicTextProvider).toHaveBeenCalledWith( + 'sk-ant-123', + 'claude-sonnet-4-20250514', + ); + expect(createOpenAITextProvider).not.toHaveBeenCalled(); + }); + + it('uses fallback key when KTAVI_TEXT_API_KEY is not set', () => { + const config = { ...baseConfig, ai: { provider: 'openai' as const, textModel: 'gpt-4o' } }; + createTextAIProvider(config, { OPENAI_API_KEY: 'fallback-key' }); + + expect(createOpenAITextProvider).toHaveBeenCalledWith('fallback-key', 'gpt-4o'); + }); + + it('returns undefined when no API key is available', () => { + const result = createTextAIProvider(baseConfig, {}); + + expect(result).toBeUndefined(); + expect(createOpenAITextProvider).not.toHaveBeenCalled(); + }); +}); + +describe('createImageProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates OpenAI image provider with KTAVI_IMAGE_API_KEY', () => { + const config = { + ...baseConfig, + ai: { provider: 'openai' as const, textModel: 'gpt-4o', imageModel: 'gpt-image-2' }, + }; + createImageProvider(config, { KTAVI_IMAGE_API_KEY: 'img-key' }); + + expect(createOpenAIImageProvider).toHaveBeenCalledWith('img-key', 'gpt-image-2'); + }); + + it('falls back to OPENAI_API_KEY for image provider', () => { + const config = { + ...baseConfig, + ai: { provider: 'openai' as const, textModel: 'gpt-4o', imageModel: 'gpt-image-2' }, + }; + createImageProvider(config, { OPENAI_API_KEY: 'sk-123' }); + + expect(createOpenAIImageProvider).toHaveBeenCalledWith('sk-123', 'gpt-image-2'); + }); + + it('returns undefined when no image key is available', () => { + const result = createImageProvider(baseConfig, {}); + + expect(result).toBeUndefined(); + expect(createOpenAIImageProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index a2fa52e..1d5d643 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -89,6 +89,14 @@ describe('loadConfig', () => { expect(config.writing.defaultMode).toBe('light'); }); + it('defaults textModel to Claude model when provider is anthropic', async () => { + const { loadConfig, DEFAULT_TEXT_MODEL } = await import('../../src/core/config.js'); + const fixturePath = path.resolve('tests/fixtures/test-config-anthropic.ts'); + const config = await loadConfig(fixturePath, NONEXISTENT_PATH); + expect(config.ai.provider).toBe('anthropic'); + expect(config.ai.textModel).toBe(DEFAULT_TEXT_MODEL.anthropic); + }); + it('deep-merges storage.local without touching cloudinary', async () => { const { loadConfig } = await import('../../src/core/config.js'); const fixturePath = path.resolve('tests/fixtures/test-config-local-storage.ts'); diff --git a/tests/fixtures/test-config-anthropic.ts b/tests/fixtures/test-config-anthropic.ts new file mode 100644 index 0000000..0515342 --- /dev/null +++ b/tests/fixtures/test-config-anthropic.ts @@ -0,0 +1,5 @@ +export default { + ai: { + provider: 'anthropic', + }, +}; diff --git a/tests/providers/ai/anthropicTextProvider.test.ts b/tests/providers/ai/anthropicTextProvider.test.ts new file mode 100644 index 0000000..7060dab --- /dev/null +++ b/tests/providers/ai/anthropicTextProvider.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest'; +import { KtaviError } from '../../../src/core/errors.js'; + +vi.mock('@anthropic-ai/sdk', () => { + const create = vi.fn(); + return { + default: vi.fn(() => ({ messages: { create } })), + __mockCreate: create, + }; +}); + +import { createAnthropicTextProvider } from '../../../src/providers/ai/anthropicTextProvider.js'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { __mockCreate: mockCreate } = (await import('@anthropic-ai/sdk')) as any; + +describe('createAnthropicTextProvider', () => { + it('throws KtaviError when API key is empty', () => { + expect(() => createAnthropicTextProvider('', 'claude-sonnet-4-20250514')).toThrow(KtaviError); + expect(() => createAnthropicTextProvider('', 'claude-sonnet-4-20250514')).toThrow( + 'KTAVI_TEXT_API_KEY', + ); + }); + + it('returns parsed JSON from text response', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: '{"suggestions": []}' }], + }); + + const result = await provider.generateStructuredOutput<{ suggestions: unknown[] }>({ + systemPrompt: 'You are helpful.', + userPrompt: 'Review this.', + schemaName: 'test', + }); + + expect(result).toEqual({ suggestions: [] }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-sonnet-4-20250514', + system: 'You are helpful.', + messages: [{ role: 'user', content: 'Review this.' }], + }), + ); + }); + + it('extracts JSON from response with surrounding text', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: 'Here is the result:\n{"data": "value"}\nDone.' }], + }); + + const result = await provider.generateStructuredOutput<{ data: string }>({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }); + + expect(result).toEqual({ data: 'value' }); + }); + + it('throws KtaviError when response has no text block', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ content: [] }); + + await expect( + provider.generateStructuredOutput({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }), + ).rejects.toThrow('Empty response'); + }); + + it('throws KtaviError when response contains no JSON', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: 'No JSON here at all.' }], + }); + + await expect( + provider.generateStructuredOutput({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }), + ).rejects.toThrow('did not return valid JSON'); + }); + + it('extracts JSON from fenced code block', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [ + { + type: 'text', + text: 'Here is the result:\n```json\n{"key": "value"}\n```\nDone.', + }, + ], + }); + + const result = await provider.generateStructuredOutput<{ key: string }>({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }); + + expect(result).toEqual({ key: 'value' }); + }); + + it('extracts only first balanced JSON object, not greedy match', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [ + { + type: 'text', + text: 'Result: {"first": true} and also {"second": true}', + }, + ], + }); + + const result = await provider.generateStructuredOutput<{ first: boolean }>({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }); + + expect(result).toEqual({ first: true }); + }); + + it('skips non-JSON brace pairs in explanatory text', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [ + { + type: 'text', + text: 'Use {field} to configure. Here is the result: {"data": "value"}', + }, + ], + }); + + const result = await provider.generateStructuredOutput<{ data: string }>({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }); + + expect(result).toEqual({ data: 'value' }); + }); + + it('handles braces inside JSON string values correctly', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [ + { + type: 'text', + text: 'Result: {"text": "use {braces} and \\"quotes\\"", "valid": true}', + }, + ], + }); + + const result = await provider.generateStructuredOutput<{ text: string; valid: boolean }>({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }); + + expect(result).toEqual({ text: 'use {braces} and "quotes"', valid: true }); + }); + + it('throws KtaviError when no parseable JSON object exists', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: '{invalid json}' }], + }); + + await expect( + provider.generateStructuredOutput({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }), + ).rejects.toThrow('did not return valid JSON'); + }); + + it('throws KtaviError with cause when code block JSON is malformed', async () => { + const provider = createAnthropicTextProvider('test-key', 'claude-sonnet-4-20250514'); + mockCreate.mockResolvedValueOnce({ + content: [ + { + type: 'text', + text: '```json\n{invalid json}\n```', + }, + ], + }); + + await expect( + provider.generateStructuredOutput({ + systemPrompt: 'system', + userPrompt: 'user', + schemaName: 'test', + }), + ).rejects.toThrow('malformed JSON'); + }); +}); diff --git a/tests/providers/image/openaiImageProvider.integration.test.ts b/tests/providers/image/openaiImageProvider.integration.test.ts index b2064f1..6564a68 100644 --- a/tests/providers/image/openaiImageProvider.integration.test.ts +++ b/tests/providers/image/openaiImageProvider.integration.test.ts @@ -3,12 +3,28 @@ import { createOpenAIImageProvider } from '../../../src/providers/image/openaiIm import { KtaviError } from '../../../src/core/errors.js'; const RUN_INTEGRATION = process.env.RUN_OPENAI_IMAGE_INTEGRATION_TESTS === 'true'; -const apiKey = process.env.OPENAI_API_KEY ?? ''; + +function resolveApiKey(): string { + const candidates = [ + process.env.KTAVI_IMAGE_API_KEY, + process.env.OPENAI_API_KEY, + ]; + + for (const candidate of candidates) { + if (candidate && candidate.trim() !== '') { + return candidate; + } + } + + return ''; +} + +const apiKey = resolveApiKey(); describe('openaiImageProvider', () => { it('throws KtaviError when API key is empty', () => { expect(() => createOpenAIImageProvider('')).toThrow(KtaviError); - expect(() => createOpenAIImageProvider('')).toThrow('OPENAI_API_KEY is not set'); + expect(() => createOpenAIImageProvider('')).toThrow('KTAVI_IMAGE_API_KEY'); try { createOpenAIImageProvider('');