From 2197a540d1eec9fff4cbbc4d626be894740bfacf Mon Sep 17 00:00:00 2001 From: Maya Date: Tue, 12 May 2026 14:27:26 +0300 Subject: [PATCH 1/8] Add Anthropic as an alternative AI text provider Introduce provider factory pattern for AI text and image providers, replacing hardcoded OpenAI imports in CLI commands. Users can now set `ai.provider: 'anthropic'` in config to use Claude models. Closes #107 Co-Authored-By: Claude Opus 4.6 --- .env.example | 1 + docs/environment-variables.md | 16 +++- docs/providers.md | 47 ++++++--- package-lock.json | 64 ++++++++++++- package.json | 1 + src/cli/commands/analyze.ts | 11 +-- src/cli/commands/configInit.ts | 7 +- src/cli/commands/cover.ts | 18 ++-- src/cli/commands/fix.ts | 7 +- src/cli/commands/prepare.ts | 20 ++-- src/cli/commands/review.ts | 12 +-- src/cli/commands/seo.ts | 7 +- src/cli/shared/providers.ts | 52 ++++++++-- src/core/config.ts | 6 +- src/core/types.ts | 2 + src/providers/ai/anthropicTextProvider.ts | 45 +++++++++ tests/cli/shared/providers.test.ts | 96 ++++++++++++++++++- .../ai/anthropicTextProvider.test.ts | 88 +++++++++++++++++ 18 files changed, 428 insertions(+), 72 deletions(-) create mode 100644 src/providers/ai/anthropicTextProvider.ts create mode 100644 tests/providers/ai/anthropicTextProvider.test.ts diff --git a/.env.example b/.env.example index cc1314c..4e299e1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ OPENAI_API_KEY= +ANTHROPIC_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..0943e7c 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -2,11 +2,16 @@ Ktavi reads environment variables from a `.env` file in your project root (loaded via `dotenv`). -## Required +## AI provider keys -| Variable | Description | Required for | -| ---------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- | -| `OPENAI_API_KEY` | OpenAI API key | `review`, `cover` (always required). `analyze`, `seo`, `fix`, `prepare` (optional -- enables AI-powered features) | +Set the API key for your configured AI provider (`ai.provider` in config): + +| Variable | Description | Provider | +| ------------------- | ----------------- | ----------- | +| `OPENAI_API_KEY` | OpenAI API key | `openai` | +| `ANTHROPIC_API_KEY` | Anthropic API key | `anthropic` | + +Only the key for your configured provider is required. If using Anthropic for text but need image generation, you'll also need `OPENAI_API_KEY` since image generation currently only supports OpenAI. ## Cloudinary (optional) @@ -30,6 +35,7 @@ cp .env.example .env ``` OPENAI_API_KEY= +ANTHROPIC_API_KEY= CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= @@ -37,7 +43,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..20d10bf 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -2,11 +2,24 @@ 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`. + +**Image model** is used by: `cover --generate` and `prepare --generate-cover`. Image generation currently uses OpenAI regardless of the text provider setting. + +### OpenAI 1. Get an API key from [platform.openai.com](https://platform.openai.com) 2. Add it to your `.env` file: @@ -15,23 +28,35 @@ OpenAI is currently the only supported AI provider for both text and image gener OPENAI_API_KEY=sk-... ``` -### Models - -Configure which models to use in your `ktavi.config.ts`: - ```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) + +1. Get an API key from [console.anthropic.com](https://console.anthropic.com) +2. Add it to your `.env` file: + +``` +ANTHROPIC_API_KEY=sk-ant-... +``` + +```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 still requires an `OPENAI_API_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..146c2b5 100644 --- a/src/cli/commands/configInit.ts +++ b/src/cli/commands/configInit.ts @@ -55,7 +55,10 @@ 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), }); @@ -194,6 +197,8 @@ function printNextSteps(filePath: string, config: Partial): void { const provider = config.ai?.provider ?? SCHEMA_DEFAULTS.ai.provider; if (provider === 'openai') { steps.push('Set OPENAI_API_KEY in your .env'); + } else if (provider === 'anthropic') { + steps.push('Set ANTHROPIC_API_KEY in your .env'); } 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..3cf5cb0 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,23 +41,21 @@ 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.', + 'An AI 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; const result = await generateAndAttachCoverWorkflow(file, { 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..1aa7f57 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,9 @@ 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; 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..d7eb511 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( + `${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..ebe0cce 100644 --- a/src/cli/shared/providers.ts +++ b/src/cli/shared/providers.ts @@ -1,13 +1,53 @@ 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 API_KEY_ENV: Record = { + openai: 'OPENAI_API_KEY', + anthropic: 'ANTHROPIC_API_KEY', +}; + +export function getApiKeyForProvider( + provider: AIProvider, + env: NodeJS.ProcessEnv, +): string | undefined { + return env[API_KEY_ENV[provider]]; +} + +export function createTextAIProvider( + config: KtaviConfig, + env: NodeJS.ProcessEnv, +): TextAIProvider | undefined { + const apiKey = getApiKeyForProvider(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 = env.OPENAI_API_KEY; + 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..d31aaef 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,11 +2,11 @@ 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 type KtaviConfig = { ai: { - provider: 'openai'; + provider: AIProvider; textModel: string; imageModel?: string; }; @@ -36,7 +36,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(), }) 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..08d0f5f --- /dev/null +++ b/src/providers/ai/anthropicTextProvider.ts @@ -0,0 +1,45 @@ +import type { TextAIProvider } from '../../core/providers.js'; +import { KtaviError } from '../../core/errors.js'; + +export function createAnthropicTextProvider(apiKey: string, model: string): TextAIProvider { + if (!apiKey) { + throw new KtaviError( + 'ANTHROPIC_API_KEY is not set. Please add it to 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 jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new KtaviError( + 'AI provider did not return valid JSON. Response may need retry.', + 'AI_PROVIDER_ERROR', + ); + } + + return JSON.parse(jsonMatch[0]) as TOutput; + }, + }; +} diff --git a/tests/cli/shared/providers.test.ts b/tests/cli/shared/providers.test.ts index 86b58f8..ec38e65 100644 --- a/tests/cli/shared/providers.test.ts +++ b/tests/cli/shared/providers.test.ts @@ -8,9 +8,29 @@ 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, + getApiKeyForProvider, +} 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 +130,77 @@ describe('createStorageProvider', () => { }); }); }); + +describe('getApiKeyForProvider', () => { + it('returns OPENAI_API_KEY for openai provider', () => { + expect(getApiKeyForProvider('openai', { OPENAI_API_KEY: 'sk-123' })).toBe('sk-123'); + }); + + it('returns ANTHROPIC_API_KEY for anthropic provider', () => { + expect(getApiKeyForProvider('anthropic', { ANTHROPIC_API_KEY: 'sk-ant-123' })).toBe( + 'sk-ant-123', + ); + }); + + it('returns undefined when key is not set', () => { + expect(getApiKeyForProvider('openai', {})).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, { OPENAI_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, { ANTHROPIC_API_KEY: 'sk-ant-123' }); + + expect(createAnthropicTextProvider).toHaveBeenCalledWith( + 'sk-ant-123', + 'claude-sonnet-4-20250514', + ); + expect(createOpenAITextProvider).not.toHaveBeenCalled(); + }); + + it('returns undefined when API key is not set', () => { + const result = createTextAIProvider(baseConfig, {}); + + expect(result).toBeUndefined(); + expect(createOpenAITextProvider).not.toHaveBeenCalled(); + }); +}); + +describe('createImageProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates OpenAI image provider when OPENAI_API_KEY is set', () => { + 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 OPENAI_API_KEY is not set', () => { + const result = createImageProvider(baseConfig, {}); + + expect(result).toBeUndefined(); + expect(createOpenAIImageProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/providers/ai/anthropicTextProvider.test.ts b/tests/providers/ai/anthropicTextProvider.test.ts new file mode 100644 index 0000000..533b21f --- /dev/null +++ b/tests/providers/ai/anthropicTextProvider.test.ts @@ -0,0 +1,88 @@ +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( + 'ANTHROPIC_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'); + }); +}); From 89d47fee8d42abb2ffd4284b74c23d13155c73fd Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 13 May 2026 10:30:41 +0300 Subject: [PATCH 2/8] Address PR review: fail-fast image provider, robust JSON extraction, centralized key names - cover/prepare: error immediately when --generate requested but OPENAI_API_KEY missing, instead of silently skipping - anthropicTextProvider: prefer fenced code blocks for JSON extraction, use balanced-brace parser instead of greedy regex, wrap JSON.parse in try/catch to throw KtaviError with cause - review/cover: use getApiKeyEnvName() instead of inline provider check - Add 5 new tests (code block extraction, balanced parse, malformed JSON, getApiKeyEnvName) Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/cover.ts | 16 ++++-- src/cli/commands/prepare.ts | 13 ++++- src/cli/commands/review.ts | 4 +- src/cli/shared/providers.ts | 4 ++ src/providers/ai/anthropicTextProvider.ts | 30 +++++++++- tests/cli/shared/providers.test.ts | 11 ++++ .../ai/anthropicTextProvider.test.ts | 55 +++++++++++++++++++ 7 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/cover.ts b/src/cli/commands/cover.ts index 3cf5cb0..872e6e8 100644 --- a/src/cli/commands/cover.ts +++ b/src/cli/commands/cover.ts @@ -7,6 +7,7 @@ import { createTextAIProvider, createImageProvider, createStorageProvider, + getApiKeyEnvName, } from '../shared/providers.js'; import type { ImageSize, StorageTarget } from '../../core/types.js'; @@ -44,7 +45,7 @@ export function registerCoverCommand(program: Command) { const aiProvider = createTextAIProvider(config, process.env); if (!aiProvider) { logger.error( - 'An AI API key is required for cover generation. Add it to your .env file.', + `${getApiKeyEnvName(config.ai.provider)} is required for cover generation. Add it to your .env file.`, ); process.exit(1); } @@ -54,9 +55,16 @@ export function registerCoverCommand(program: Command) { config.storage.provider) as StorageTarget; const storageProvider = createStorageProvider(storageTarget, config, process.env); - const imageProvider = opts.generate - ? createImageProvider(config, process.env) - : undefined; + let imageProvider; + if (opts.generate) { + imageProvider = createImageProvider(config, process.env); + if (!imageProvider) { + logger.error( + '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/prepare.ts b/src/cli/commands/prepare.ts index 1aa7f57..ab75b8e 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -49,9 +49,16 @@ export function registerPrepareCommand(program: Command) { ) as StorageTarget; const storageProvider = createStorageProvider(storageTarget, config, process.env); - const imageProvider = opts.generateCover - ? createImageProvider(config, process.env) - : undefined; + let imageProvider; + if (opts.generateCover) { + imageProvider = createImageProvider(config, process.env); + if (!imageProvider) { + logger.error( + '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 d7eb511..8eb04ff 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -3,7 +3,7 @@ import { reviewDraftWorkflow } from '../../workflows/reviewDraftWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; import { loadConfig } from '../../core/config.js'; -import { createTextAIProvider } from '../shared/providers.js'; +import { createTextAIProvider, getApiKeyEnvName } from '../shared/providers.js'; import { renderInlineSuggestion } from '../../utils/diffRenderer.js'; import type { WritingMode } from '../../core/types.js'; @@ -20,7 +20,7 @@ export function registerReviewCommand(program: Command) { const aiProvider = createTextAIProvider(config, process.env); if (!aiProvider) { logger.error( - `${config.ai.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'} is required for writing review. Add it to your .env file.`, + `${getApiKeyEnvName(config.ai.provider)} is required for writing review. Add it to your .env file.`, ); process.exit(1); } diff --git a/src/cli/shared/providers.ts b/src/cli/shared/providers.ts index ebe0cce..0e965f0 100644 --- a/src/cli/shared/providers.ts +++ b/src/cli/shared/providers.ts @@ -16,6 +16,10 @@ const API_KEY_ENV: Record = { anthropic: 'ANTHROPIC_API_KEY', }; +export function getApiKeyEnvName(provider: AIProvider): string { + return API_KEY_ENV[provider]; +} + export function getApiKeyForProvider( provider: AIProvider, env: NodeJS.ProcessEnv, diff --git a/src/providers/ai/anthropicTextProvider.ts b/src/providers/ai/anthropicTextProvider.ts index 08d0f5f..1d3c495 100644 --- a/src/providers/ai/anthropicTextProvider.ts +++ b/src/providers/ai/anthropicTextProvider.ts @@ -1,6 +1,19 @@ import type { TextAIProvider } from '../../core/providers.js'; import { KtaviError } from '../../core/errors.js'; +function extractJsonObject(text: string): string | null { + const start = text.indexOf('{'); + if (start === -1) return null; + + let depth = 0; + for (let i = start; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') depth--; + if (depth === 0) return text.slice(start, i + 1); + } + return null; +} + export function createAnthropicTextProvider(apiKey: string, model: string): TextAIProvider { if (!apiKey) { throw new KtaviError( @@ -31,15 +44,26 @@ export function createAnthropicTextProvider(apiKey: string, model: string): Text } const content = textBlock.text; - const jsonMatch = content.match(/\{[\s\S]*\}/); - if (!jsonMatch) { + + 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. Response may need retry.', 'AI_PROVIDER_ERROR', ); } - return JSON.parse(jsonMatch[0]) as TOutput; + try { + return JSON.parse(jsonStr) as TOutput; + } catch (cause) { + throw new KtaviError( + 'AI provider returned malformed JSON. Response may need retry.', + 'AI_PROVIDER_ERROR', + cause, + ); + } }, }; } diff --git a/tests/cli/shared/providers.test.ts b/tests/cli/shared/providers.test.ts index ec38e65..82e4cc7 100644 --- a/tests/cli/shared/providers.test.ts +++ b/tests/cli/shared/providers.test.ts @@ -25,6 +25,7 @@ import { createTextAIProvider, createImageProvider, getApiKeyForProvider, + getApiKeyEnvName, } from '../../../src/cli/shared/providers.js'; import { createCloudinaryStorageProvider } from '../../../src/providers/storage/cloudinaryStorageProvider.js'; import { createLocalStorageProvider } from '../../../src/providers/storage/localStorageProvider.js'; @@ -131,6 +132,16 @@ describe('createStorageProvider', () => { }); }); +describe('getApiKeyEnvName', () => { + it('returns OPENAI_API_KEY for openai', () => { + expect(getApiKeyEnvName('openai')).toBe('OPENAI_API_KEY'); + }); + + it('returns ANTHROPIC_API_KEY for anthropic', () => { + expect(getApiKeyEnvName('anthropic')).toBe('ANTHROPIC_API_KEY'); + }); +}); + describe('getApiKeyForProvider', () => { it('returns OPENAI_API_KEY for openai provider', () => { expect(getApiKeyForProvider('openai', { OPENAI_API_KEY: 'sk-123' })).toBe('sk-123'); diff --git a/tests/providers/ai/anthropicTextProvider.test.ts b/tests/providers/ai/anthropicTextProvider.test.ts index 533b21f..a7b9e97 100644 --- a/tests/providers/ai/anthropicTextProvider.test.ts +++ b/tests/providers/ai/anthropicTextProvider.test.ts @@ -85,4 +85,59 @@ describe('createAnthropicTextProvider', () => { }), ).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('throws KtaviError with cause when JSON is malformed', 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('malformed JSON'); + }); }); From 7c1b318b77f4be11f58b59de389085c23dc1fd67 Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 13 May 2026 13:43:11 +0300 Subject: [PATCH 3/8] Use dedicated KTAVI_TEXT_API_KEY and KTAVI_IMAGE_API_KEY env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce KTAVI_TEXT_API_KEY for text generation and KTAVI_IMAGE_API_KEY for image generation, with provider-specific keys (OPENAI_API_KEY, ANTHROPIC_API_KEY) as fallbacks. Switching providers is now purely a config change — .env stays the same. Co-Authored-By: Claude Opus 4.6 --- .env.example | 4 +- docs/environment-variables.md | 27 ++++--- docs/providers.md | 20 ++---- src/cli/commands/configInit.ts | 9 +-- src/cli/commands/cover.ts | 5 +- src/cli/commands/prepare.ts | 2 +- src/cli/commands/review.ts | 4 +- src/cli/shared/providers.ts | 18 ++--- src/providers/ai/anthropicTextProvider.ts | 2 +- src/providers/ai/openaiTextProvider.ts | 2 +- src/providers/image/openaiImageProvider.ts | 2 +- tests/cli/shared/providers.test.ts | 72 +++++++++++++------ .../ai/anthropicTextProvider.test.ts | 2 +- 13 files changed, 99 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 4e299e1..73bd6ea 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -OPENAI_API_KEY= -ANTHROPIC_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 0943e7c..271e77e 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -2,16 +2,25 @@ Ktavi reads environment variables from a `.env` file in your project root (loaded via `dotenv`). -## AI provider keys +## AI keys -Set the API key for your configured AI provider (`ai.provider` in config): +| 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) | -| Variable | Description | Provider | -| ------------------- | ----------------- | ----------- | -| `OPENAI_API_KEY` | OpenAI API key | `openai` | -| `ANTHROPIC_API_KEY` | Anthropic API key | `anthropic` | +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. -Only the key for your configured provider is required. If using Anthropic for text but need image generation, you'll also need `OPENAI_API_KEY` since image generation currently only supports OpenAI. +### 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) @@ -34,8 +43,8 @@ cp .env.example .env `.env.example` contents: ``` -OPENAI_API_KEY= -ANTHROPIC_API_KEY= +KTAVI_TEXT_API_KEY= +KTAVI_IMAGE_API_KEY= CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= diff --git a/docs/providers.md b/docs/providers.md index 20d10bf..7f2a01c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -19,15 +19,16 @@ export default { **Image model** is used by: `cover --generate` and `prepare --generate-cover`. Image generation currently uses OpenAI regardless of the text provider setting. -### OpenAI - -1. Get an API key from [platform.openai.com](https://platform.openai.com) -2. Add it to your `.env` file: +Set your API key in `.env`: ``` -OPENAI_API_KEY=sk-... +KTAVI_TEXT_API_KEY=sk-... ``` +Provider-specific keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) are also supported as fallbacks. + +### OpenAI + ```typescript export default { ai: { @@ -40,13 +41,6 @@ export default { ### Anthropic (Claude) -1. Get an API key from [console.anthropic.com](https://console.anthropic.com) -2. Add it to your `.env` file: - -``` -ANTHROPIC_API_KEY=sk-ant-... -``` - ```typescript export default { ai: { @@ -56,7 +50,7 @@ export default { }; ``` -Note: Anthropic does not offer image generation, so cover image generation still requires an `OPENAI_API_KEY`. +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/src/cli/commands/configInit.ts b/src/cli/commands/configInit.ts index 146c2b5..6caa4b7 100644 --- a/src/cli/commands/configInit.ts +++ b/src/cli/commands/configInit.ts @@ -194,12 +194,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'); - } else if (provider === 'anthropic') { - steps.push('Set ANTHROPIC_API_KEY in your .env'); - } + steps.push( + 'Set KTAVI_TEXT_API_KEY in your .env (or the provider-specific key like OPENAI_API_KEY)', + ); 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 872e6e8..feccea5 100644 --- a/src/cli/commands/cover.ts +++ b/src/cli/commands/cover.ts @@ -7,7 +7,6 @@ import { createTextAIProvider, createImageProvider, createStorageProvider, - getApiKeyEnvName, } from '../shared/providers.js'; import type { ImageSize, StorageTarget } from '../../core/types.js'; @@ -45,7 +44,7 @@ export function registerCoverCommand(program: Command) { const aiProvider = createTextAIProvider(config, process.env); if (!aiProvider) { logger.error( - `${getApiKeyEnvName(config.ai.provider)} is required for cover generation. Add it to your .env file.`, + 'KTAVI_TEXT_API_KEY is required for cover generation. Add it to your .env file.', ); process.exit(1); } @@ -60,7 +59,7 @@ export function registerCoverCommand(program: Command) { imageProvider = createImageProvider(config, process.env); if (!imageProvider) { logger.error( - 'OPENAI_API_KEY is required for image generation. Add it to your .env file.', + 'KTAVI_IMAGE_API_KEY is required for image generation. Add it to your .env file.', ); process.exit(1); } diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index ab75b8e..baa8bbd 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -54,7 +54,7 @@ export function registerPrepareCommand(program: Command) { imageProvider = createImageProvider(config, process.env); if (!imageProvider) { logger.error( - 'OPENAI_API_KEY is required for image generation. Add it to your .env file.', + 'KTAVI_IMAGE_API_KEY is required for image generation. Add it to your .env file.', ); process.exit(1); } diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 8eb04ff..e616df6 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -3,7 +3,7 @@ import { reviewDraftWorkflow } from '../../workflows/reviewDraftWorkflow.js'; import { logger } from '../../core/logger.js'; import { KtaviError, friendlyErrorMessage } from '../../core/errors.js'; import { loadConfig } from '../../core/config.js'; -import { createTextAIProvider, getApiKeyEnvName } from '../shared/providers.js'; +import { createTextAIProvider } from '../shared/providers.js'; import { renderInlineSuggestion } from '../../utils/diffRenderer.js'; import type { WritingMode } from '../../core/types.js'; @@ -20,7 +20,7 @@ export function registerReviewCommand(program: Command) { const aiProvider = createTextAIProvider(config, process.env); if (!aiProvider) { logger.error( - `${getApiKeyEnvName(config.ai.provider)} is required for writing review. Add it to your .env file.`, + 'KTAVI_TEXT_API_KEY is required for writing review. Add it to your .env file.', ); process.exit(1); } diff --git a/src/cli/shared/providers.ts b/src/cli/shared/providers.ts index 0e965f0..c3ad625 100644 --- a/src/cli/shared/providers.ts +++ b/src/cli/shared/providers.ts @@ -11,27 +11,27 @@ import type { import type { KtaviConfig } from '../../core/config.js'; import type { AIProvider, StorageTarget } from '../../core/types.js'; -const API_KEY_ENV: Record = { +const FALLBACK_TEXT_KEY: Record = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', }; -export function getApiKeyEnvName(provider: AIProvider): string { - return API_KEY_ENV[provider]; -} - -export function getApiKeyForProvider( +export function resolveTextApiKey( provider: AIProvider, env: NodeJS.ProcessEnv, ): string | undefined { - return env[API_KEY_ENV[provider]]; + return env.KTAVI_TEXT_API_KEY ?? env[FALLBACK_TEXT_KEY[provider]]; +} + +export function resolveImageApiKey(env: NodeJS.ProcessEnv): string | undefined { + return env.KTAVI_IMAGE_API_KEY ?? env.OPENAI_API_KEY; } export function createTextAIProvider( config: KtaviConfig, env: NodeJS.ProcessEnv, ): TextAIProvider | undefined { - const apiKey = getApiKeyForProvider(config.ai.provider, env); + const apiKey = resolveTextApiKey(config.ai.provider, env); if (!apiKey) return undefined; switch (config.ai.provider) { @@ -47,7 +47,7 @@ export function createImageProvider( config: KtaviConfig, env: NodeJS.ProcessEnv, ): ImageGenerationProvider | undefined { - const apiKey = env.OPENAI_API_KEY; + const apiKey = resolveImageApiKey(env); if (!apiKey) return undefined; return createOpenAIImageProvider(apiKey, config.ai.imageModel); } diff --git a/src/providers/ai/anthropicTextProvider.ts b/src/providers/ai/anthropicTextProvider.ts index 1d3c495..eb03298 100644 --- a/src/providers/ai/anthropicTextProvider.ts +++ b/src/providers/ai/anthropicTextProvider.ts @@ -17,7 +17,7 @@ function extractJsonObject(text: string): string | null { export function createAnthropicTextProvider(apiKey: string, model: string): TextAIProvider { if (!apiKey) { throw new KtaviError( - 'ANTHROPIC_API_KEY is not set. Please add it to your .env file.', + 'API key is not set. Please set KTAVI_TEXT_API_KEY in your .env file.', 'AI_PROVIDER_ERROR', ); } diff --git a/src/providers/ai/openaiTextProvider.ts b/src/providers/ai/openaiTextProvider.ts index 03a7ed0..8db100c 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 in your .env file.', 'AI_PROVIDER_ERROR', ); } diff --git a/src/providers/image/openaiImageProvider.ts b/src/providers/image/openaiImageProvider.ts index 8e909a4..2ace942 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 in your .env file.', 'AI_PROVIDER_ERROR', ); } diff --git a/tests/cli/shared/providers.test.ts b/tests/cli/shared/providers.test.ts index 82e4cc7..9555e65 100644 --- a/tests/cli/shared/providers.test.ts +++ b/tests/cli/shared/providers.test.ts @@ -24,8 +24,8 @@ import { createStorageProvider, createTextAIProvider, createImageProvider, - getApiKeyForProvider, - getApiKeyEnvName, + resolveTextApiKey, + resolveImageApiKey, } from '../../../src/cli/shared/providers.js'; import { createCloudinaryStorageProvider } from '../../../src/providers/storage/cloudinaryStorageProvider.js'; import { createLocalStorageProvider } from '../../../src/providers/storage/localStorageProvider.js'; @@ -132,29 +132,42 @@ describe('createStorageProvider', () => { }); }); -describe('getApiKeyEnvName', () => { - it('returns OPENAI_API_KEY for openai', () => { - expect(getApiKeyEnvName('openai')).toBe('OPENAI_API_KEY'); +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('returns ANTHROPIC_API_KEY for anthropic', () => { - expect(getApiKeyEnvName('anthropic')).toBe('ANTHROPIC_API_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('getApiKeyForProvider', () => { - it('returns OPENAI_API_KEY for openai provider', () => { - expect(getApiKeyForProvider('openai', { OPENAI_API_KEY: 'sk-123' })).toBe('sk-123'); +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('returns ANTHROPIC_API_KEY for anthropic provider', () => { - expect(getApiKeyForProvider('anthropic', { ANTHROPIC_API_KEY: 'sk-ant-123' })).toBe( - 'sk-ant-123', - ); + it('falls back to OPENAI_API_KEY', () => { + expect(resolveImageApiKey({ OPENAI_API_KEY: 'sk-123' })).toBe('sk-123'); }); - it('returns undefined when key is not set', () => { - expect(getApiKeyForProvider('openai', {})).toBeUndefined(); + it('returns undefined when no key is set', () => { + expect(resolveImageApiKey({})).toBeUndefined(); }); }); @@ -165,7 +178,7 @@ describe('createTextAIProvider', () => { it('creates OpenAI provider when config.ai.provider is openai', () => { const config = { ...baseConfig, ai: { provider: 'openai' as const, textModel: 'gpt-4o' } }; - createTextAIProvider(config, { OPENAI_API_KEY: 'sk-123' }); + createTextAIProvider(config, { KTAVI_TEXT_API_KEY: 'sk-123' }); expect(createOpenAITextProvider).toHaveBeenCalledWith('sk-123', 'gpt-4o'); expect(createAnthropicTextProvider).not.toHaveBeenCalled(); @@ -176,7 +189,7 @@ describe('createTextAIProvider', () => { ...baseConfig, ai: { provider: 'anthropic' as const, textModel: 'claude-sonnet-4-20250514' }, }; - createTextAIProvider(config, { ANTHROPIC_API_KEY: 'sk-ant-123' }); + createTextAIProvider(config, { KTAVI_TEXT_API_KEY: 'sk-ant-123' }); expect(createAnthropicTextProvider).toHaveBeenCalledWith( 'sk-ant-123', @@ -185,7 +198,14 @@ describe('createTextAIProvider', () => { expect(createOpenAITextProvider).not.toHaveBeenCalled(); }); - it('returns undefined when API key is not set', () => { + 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(); @@ -198,7 +218,17 @@ describe('createImageProvider', () => { vi.clearAllMocks(); }); - it('creates OpenAI image provider when OPENAI_API_KEY is set', () => { + 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' }, @@ -208,7 +238,7 @@ describe('createImageProvider', () => { expect(createOpenAIImageProvider).toHaveBeenCalledWith('sk-123', 'gpt-image-2'); }); - it('returns undefined when OPENAI_API_KEY is not set', () => { + it('returns undefined when no image key is available', () => { const result = createImageProvider(baseConfig, {}); expect(result).toBeUndefined(); diff --git a/tests/providers/ai/anthropicTextProvider.test.ts b/tests/providers/ai/anthropicTextProvider.test.ts index a7b9e97..9d8eaa6 100644 --- a/tests/providers/ai/anthropicTextProvider.test.ts +++ b/tests/providers/ai/anthropicTextProvider.test.ts @@ -17,7 +17,7 @@ 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( - 'ANTHROPIC_API_KEY', + 'KTAVI_TEXT_API_KEY', ); }); From 86435a7772bd31878ce77c0fe2b3540ff79b3aab Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 13 May 2026 13:49:13 +0300 Subject: [PATCH 4/8] Harden JSON extraction to skip non-JSON brace pairs in Anthropic responses extractJsonObject now tries each `{` candidate until JSON.parse succeeds, handling cases where the model uses braces in explanatory text before the actual JSON payload. Also improves error message clarity. Co-Authored-By: Claude Opus 4.6 --- src/providers/ai/anthropicTextProvider.ts | 33 +++++++++++---- .../ai/anthropicTextProvider.test.ts | 42 ++++++++++++++++++- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/providers/ai/anthropicTextProvider.ts b/src/providers/ai/anthropicTextProvider.ts index eb03298..03aea14 100644 --- a/src/providers/ai/anthropicTextProvider.ts +++ b/src/providers/ai/anthropicTextProvider.ts @@ -2,15 +2,30 @@ import type { TextAIProvider } from '../../core/providers.js'; import { KtaviError } from '../../core/errors.js'; function extractJsonObject(text: string): string | null { - const start = text.indexOf('{'); - if (start === -1) return null; + let searchFrom = 0; - let depth = 0; - for (let i = start; i < text.length; i++) { - if (text[i] === '{') depth++; - else if (text[i] === '}') depth--; - if (depth === 0) return text.slice(start, i + 1); + while (searchFrom < text.length) { + const start = text.indexOf('{', searchFrom); + if (start === -1) return null; + + let depth = 0; + for (let i = start; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') depth--; + if (depth === 0) { + const candidate = text.slice(start, i + 1); + try { + JSON.parse(candidate); + return candidate; + } catch { + break; + } + } + } + + searchFrom = start + 1; } + return null; } @@ -50,7 +65,7 @@ export function createAnthropicTextProvider(apiKey: string, model: string): Text if (!jsonStr) { throw new KtaviError( - 'AI provider did not return valid JSON. Response may need retry.', + 'AI provider did not return valid JSON. You may need to retry the request.', 'AI_PROVIDER_ERROR', ); } @@ -59,7 +74,7 @@ export function createAnthropicTextProvider(apiKey: string, model: string): Text return JSON.parse(jsonStr) as TOutput; } catch (cause) { throw new KtaviError( - 'AI provider returned malformed JSON. Response may need retry.', + 'AI provider returned malformed JSON. You may need to retry the request.', 'AI_PROVIDER_ERROR', cause, ); diff --git a/tests/providers/ai/anthropicTextProvider.test.ts b/tests/providers/ai/anthropicTextProvider.test.ts index 9d8eaa6..2b05b93 100644 --- a/tests/providers/ai/anthropicTextProvider.test.ts +++ b/tests/providers/ai/anthropicTextProvider.test.ts @@ -126,12 +126,52 @@ describe('createAnthropicTextProvider', () => { expect(result).toEqual({ first: true }); }); - it('throws KtaviError with cause when JSON is malformed', async () => { + 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('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', From f9becb4d0feac3a5c7f674a3e70fcd9cc926db58 Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 13 May 2026 23:05:27 +0300 Subject: [PATCH 5/8] Address PR review: string-aware JSON extraction, mention fallback env vars - Make extractJsonObject track string context so braces inside JSON string values (e.g. {"text": "}"}) don't break depth counting - Update all provider and CLI error messages to mention both the KTAVI key and the provider-specific fallback key - Fix integration test asserting old error text Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/cover.ts | 4 ++-- src/cli/commands/prepare.ts | 2 +- src/cli/commands/review.ts | 2 +- src/providers/ai/anthropicTextProvider.ts | 22 ++++++++++++++++--- src/providers/ai/openaiTextProvider.ts | 2 +- src/providers/image/openaiImageProvider.ts | 2 +- .../ai/anthropicTextProvider.test.ts | 20 +++++++++++++++++ .../openaiImageProvider.integration.test.ts | 2 +- 8 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/cover.ts b/src/cli/commands/cover.ts index feccea5..16ac016 100644 --- a/src/cli/commands/cover.ts +++ b/src/cli/commands/cover.ts @@ -44,7 +44,7 @@ export function registerCoverCommand(program: Command) { const aiProvider = createTextAIProvider(config, process.env); if (!aiProvider) { logger.error( - 'KTAVI_TEXT_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); } @@ -59,7 +59,7 @@ export function registerCoverCommand(program: Command) { imageProvider = createImageProvider(config, process.env); if (!imageProvider) { logger.error( - 'KTAVI_IMAGE_API_KEY is required for image generation. Add it to your .env file.', + 'KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) is required for image generation. Add it to your .env file.', ); process.exit(1); } diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index baa8bbd..4af4783 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -54,7 +54,7 @@ export function registerPrepareCommand(program: Command) { imageProvider = createImageProvider(config, process.env); if (!imageProvider) { logger.error( - 'KTAVI_IMAGE_API_KEY is required for image generation. Add it to your .env file.', + 'KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) is required for image generation. Add it to your .env file.', ); process.exit(1); } diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index e616df6..d5fe072 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -20,7 +20,7 @@ export function registerReviewCommand(program: Command) { const aiProvider = createTextAIProvider(config, process.env); if (!aiProvider) { logger.error( - 'KTAVI_TEXT_API_KEY is required for writing review. Add it to your .env file.', + `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); } diff --git a/src/providers/ai/anthropicTextProvider.ts b/src/providers/ai/anthropicTextProvider.ts index 03aea14..f2eeebc 100644 --- a/src/providers/ai/anthropicTextProvider.ts +++ b/src/providers/ai/anthropicTextProvider.ts @@ -9,9 +9,25 @@ function extractJsonObject(text: string): string | null { if (start === -1) return null; let depth = 0; + let inString = false; + let escape = false; for (let i = start; i < text.length; i++) { - if (text[i] === '{') depth++; - else if (text[i] === '}') depth--; + 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 { @@ -32,7 +48,7 @@ function extractJsonObject(text: string): string | 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 in your .env file.', + 'API key is not set. Please set KTAVI_TEXT_API_KEY (or ANTHROPIC_API_KEY) in your .env file.', 'AI_PROVIDER_ERROR', ); } diff --git a/src/providers/ai/openaiTextProvider.ts b/src/providers/ai/openaiTextProvider.ts index 8db100c..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( - 'API key is not set. Please set KTAVI_TEXT_API_KEY in 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 2ace942..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( - 'API key is not set. Please set KTAVI_IMAGE_API_KEY in 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/providers/ai/anthropicTextProvider.test.ts b/tests/providers/ai/anthropicTextProvider.test.ts index 2b05b93..7060dab 100644 --- a/tests/providers/ai/anthropicTextProvider.test.ts +++ b/tests/providers/ai/anthropicTextProvider.test.ts @@ -146,6 +146,26 @@ describe('createAnthropicTextProvider', () => { 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({ diff --git a/tests/providers/image/openaiImageProvider.integration.test.ts b/tests/providers/image/openaiImageProvider.integration.test.ts index b2064f1..ec2405b 100644 --- a/tests/providers/image/openaiImageProvider.integration.test.ts +++ b/tests/providers/image/openaiImageProvider.integration.test.ts @@ -8,7 +8,7 @@ const apiKey = process.env.OPENAI_API_KEY ?? ''; 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(''); From 7d779447c61459910e698ba1a67f54f87b7963a8 Mon Sep 17 00:00:00 2001 From: Maya Date: Thu, 14 May 2026 09:25:30 +0300 Subject: [PATCH 6/8] Provider-aware defaults: textModel, config init wizard, docs, and error messages - Default textModel to claude-sonnet-4 when provider is anthropic (deepMerge applies provider-specific default when textModel is not explicitly set in the user config) - Config init wizard now suggests the correct model for the selected provider and tailors next-steps to mention the right fallback key - Document KTAVI_IMAGE_API_KEY in providers.md - Integration test reads KTAVI_IMAGE_API_KEY with OPENAI_API_KEY fallback Co-Authored-By: Claude Opus 4.6 --- docs/providers.md | 7 ++++--- src/cli/commands/configInit.ts | 11 +++++++---- src/core/config.ts | 8 ++++++++ tests/core/config.test.ts | 8 ++++++++ tests/fixtures/test-config-anthropic.ts | 5 +++++ .../image/openaiImageProvider.integration.test.ts | 2 +- 6 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/test-config-anthropic.ts diff --git a/docs/providers.md b/docs/providers.md index 7f2a01c..72bee65 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -19,13 +19,14 @@ export default { **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 key in `.env`: +Set your API keys in `.env`: ``` -KTAVI_TEXT_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) ``` -Provider-specific keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) are also supported as fallbacks. +Provider-specific keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) are also supported as fallbacks. `KTAVI_IMAGE_API_KEY` falls back to `OPENAI_API_KEY`. ### OpenAI diff --git a/src/cli/commands/configInit.ts b/src/cli/commands/configInit.ts index 6caa4b7..2cd042a 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'; @@ -64,7 +65,7 @@ async function promptForConfig( 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({ @@ -194,9 +195,11 @@ function printNextSteps(filePath: string, config: Partial): void { logger.heading('Next steps'); const steps: string[] = []; - steps.push( - 'Set KTAVI_TEXT_API_KEY in your .env (or the provider-specific key like OPENAI_API_KEY)', - ); + const fallbackKey = config.ai?.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'; + steps.push(`Set KTAVI_TEXT_API_KEY in your .env (or ${fallbackKey})`); + if (config.ai?.imageModel) { + 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/core/config.ts b/src/core/config.ts index d31aaef..c10d3be 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -4,6 +4,11 @@ import { createJiti } from 'jiti'; import { z } from 'zod'; 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: AIProvider; @@ -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/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/image/openaiImageProvider.integration.test.ts b/tests/providers/image/openaiImageProvider.integration.test.ts index ec2405b..be1a712 100644 --- a/tests/providers/image/openaiImageProvider.integration.test.ts +++ b/tests/providers/image/openaiImageProvider.integration.test.ts @@ -3,7 +3,7 @@ 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 ?? ''; +const apiKey = process.env.KTAVI_IMAGE_API_KEY ?? process.env.OPENAI_API_KEY ?? ''; describe('openaiImageProvider', () => { it('throws KtaviError when API key is empty', () => { From 2db88c1b5af0f3d05e916471c20c679c7e75855d Mon Sep 17 00:00:00 2001 From: Maya Shavin <6650139+mayashavin@users.noreply.github.com> Date: Thu, 14 May 2026 10:10:00 +0300 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/cli/commands/cover.ts | 17 ++++++++--------- src/cli/commands/prepare.ts | 17 ++++++++--------- src/cli/shared/providers.ts | 13 +++++++++++-- .../openaiImageProvider.integration.test.ts | 18 +++++++++++++++++- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/cli/commands/cover.ts b/src/cli/commands/cover.ts index 16ac016..1c0fe39 100644 --- a/src/cli/commands/cover.ts +++ b/src/cli/commands/cover.ts @@ -54,15 +54,14 @@ export function registerCoverCommand(program: Command) { config.storage.provider) as StorageTarget; const storageProvider = createStorageProvider(storageTarget, config, process.env); - let imageProvider; - if (opts.generate) { - imageProvider = createImageProvider(config, process.env); - if (!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 imageProvider = opts.generate + ? 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, { diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 4af4783..c62d447 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -49,15 +49,14 @@ export function registerPrepareCommand(program: Command) { ) as StorageTarget; const storageProvider = createStorageProvider(storageTarget, config, process.env); - let imageProvider; - if (opts.generateCover) { - imageProvider = createImageProvider(config, process.env); - if (!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 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, { diff --git a/src/cli/shared/providers.ts b/src/cli/shared/providers.ts index c3ad625..bd6b46e 100644 --- a/src/cli/shared/providers.ts +++ b/src/cli/shared/providers.ts @@ -16,15 +16,24 @@ const FALLBACK_TEXT_KEY: Record = { 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 env.KTAVI_TEXT_API_KEY ?? env[FALLBACK_TEXT_KEY[provider]]; + return firstNonEmptyEnvValue( + env.KTAVI_TEXT_API_KEY, + env[FALLBACK_TEXT_KEY[provider]], + ); } export function resolveImageApiKey(env: NodeJS.ProcessEnv): string | undefined { - return env.KTAVI_IMAGE_API_KEY ?? env.OPENAI_API_KEY; + return firstNonEmptyEnvValue(env.KTAVI_IMAGE_API_KEY, env.OPENAI_API_KEY); } export function createTextAIProvider( diff --git a/tests/providers/image/openaiImageProvider.integration.test.ts b/tests/providers/image/openaiImageProvider.integration.test.ts index be1a712..6564a68 100644 --- a/tests/providers/image/openaiImageProvider.integration.test.ts +++ b/tests/providers/image/openaiImageProvider.integration.test.ts @@ -3,7 +3,23 @@ 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.KTAVI_IMAGE_API_KEY ?? 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', () => { From 4d9ce5a781c81004d18728cf7381225bb3d9c887 Mon Sep 17 00:00:00 2001 From: Maya Shavin <6650139+mayashavin@users.noreply.github.com> Date: Thu, 14 May 2026 10:17:32 +0300 Subject: [PATCH 8/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/cli/commands/configInit.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cli/commands/configInit.ts b/src/cli/commands/configInit.ts index 2cd042a..f62aeb4 100644 --- a/src/cli/commands/configInit.ts +++ b/src/cli/commands/configInit.ts @@ -197,9 +197,7 @@ function printNextSteps(filePath: string, config: Partial): void { const steps: string[] = []; const fallbackKey = config.ai?.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'; steps.push(`Set KTAVI_TEXT_API_KEY in your .env (or ${fallbackKey})`); - if (config.ai?.imageModel) { - steps.push('Set KTAVI_IMAGE_API_KEY (or OPENAI_API_KEY) for cover image generation'); - } + 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'); }