Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
OPENAI_API_KEY=
KTAVI_TEXT_API_KEY=
KTAVI_IMAGE_API_KEY=
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
27 changes: 21 additions & 6 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -29,15 +43,16 @@ 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=
```

## 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 |
Expand Down
44 changes: 32 additions & 12 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 63 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 3 additions & 8 deletions src/cli/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
15 changes: 9 additions & 6 deletions src/cli/commands/configInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,13 +56,16 @@ async function promptForConfig(
): Promise<Partial<KtaviConfig>> {
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)' },
Comment thread
mayashavin marked this conversation as resolved.
],
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({
Expand Down Expand Up @@ -191,10 +195,9 @@ function printNextSteps(filePath: string, config: Partial<KtaviConfig>): 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');
}
Expand Down
24 changes: 15 additions & 9 deletions src/cli/commands/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Comment thread
mayashavin marked this conversation as resolved.
const result = await generateAndAttachCoverWorkflow(file, {
generate: opts.generate ?? false,
Expand Down
7 changes: 2 additions & 5 deletions src/cli/commands/fix.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down
Loading
Loading