diff --git a/.env.example b/.env.example index 06d8d2ba6..32fb09ade 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,12 @@ +# AI provider — set one of the following depending on studio.ai.provider: +# - GROQ_API_KEY for Groq (free tier via GroqCloud, works in all regions) +# - NUXT_STUDIO_AI_API_KEY for Vercel AI Gateway +# - GEMINI_API_KEY for Google Gemini (region-limited free tier) +GROQ_API_KEY= +NUXT_STUDIO_AI_API_KEY= +GEMINI_API_KEY= +NUXT_STUDIO_AI_GEMINI_API_KEY= + # If using GitHub provider STUDIO_GITHUB_CLIENT_ID= STUDIO_GITHUB_CLIENT_SECRET= @@ -11,7 +20,6 @@ STUDIO_GITLAB_MODERATORS= STUDIO_GOOGLE_CLIENT_ID= STUDIO_GOOGLE_CLIENT_SECRET= STUDIO_GOOGLE_MODERATORS=user@domain.com,user2@domain.com - # If using GitHub provider with Google Oauth STUDIO_GITHUB_TOKEN= diff --git a/docs/content/7.ai.md b/docs/content/7.ai.md index 43a36e94f..21830f16a 100644 --- a/docs/content/7.ai.md +++ b/docs/content/7.ai.md @@ -6,10 +6,10 @@ navigation: icon: i-lucide-sparkles seo: title: AI Content Assistance - Nuxt Studio - description: Enable Claude-powered AI features in Nuxt Studio for smart content completion, grammar fixes, improvements, and translation using Vercel AI Gateway. + description: Enable AI-powered features in Nuxt Studio for smart content completion, grammar fixes, improvements, and translation using Groq, Google Gemini, or Vercel AI Gateway. --- -Nuxt Studio integrates AI-powered content assistance using models via Vercel AI Gateway. These features help you write, improve, and transform content faster with intelligent suggestions and transformations. +Nuxt Studio integrates AI-powered content assistance supporting GroqCloud, Google Gemini, and Vercel AI Gateway. These features help you write, improve, and transform content faster with intelligent suggestions and transformations. ::video --- @@ -21,7 +21,7 @@ src: https://res.cloudinary.com/nuxt/video/upload/v1770661582/studio/studio-ai_b :: ::note{type="info"} -AI features are optional and require a Vercel AI Gateway API key. Vercel provides $5 in free credits, which is typically sufficient for most editing scenarios over several days. +AI features are optional. They require either a Vercel AI Gateway API key, a Groq API key, or a Google Gemini API key. Vercel provides $5 in free credits, which is typically sufficient for most editing scenarios over several days. :: ## Configuration @@ -46,13 +46,54 @@ Generate a new API key from the AI Gateway dashboard. ### Set Environment Variable -To enable AI-powered editing in Studio, simply set the `NUXT_STUDIO_AI_API_KEY` environment variable. +To enable AI-powered editing in Studio, set one of the following environment variables: ```bash [.env] NUXT_STUDIO_AI_API_KEY=your_vercel_ai_gateway_api_key ``` -### Contextualization +### Groq (free, works in all regions) + +[GroqCloud](https://console.groq.com) offers a generous **free tier** for Llama, Gemma, and Qwen models, works in all regions, and does not require a credit card. + +```bash [.env] +GROQ_API_KEY=your_groq_api_key +``` + +### Google Gemini + +Nuxt Studio also supports Google Gemini as the AI provider without Vercel. + +```bash [.env] +GEMINI_API_KEY=your_google_ai_studio_api_key +``` + +`NUXT_STUDIO_AI_GEMINI_API_KEY` is also accepted. You can create a key at [Google AI Studio](https://aistudio.google.com/apikey). Note that the Gemini free tier is not available in all regions. + +#### Choosing a Provider + +The provider is selected with the `provider` option on `studio.ai`: + +- `auto` (default): uses Groq when `GROQ_API_KEY` is set, then Gemini, then Vercel AI Gateway. +- `gateway`: always uses Vercel AI Gateway. +- `gemini`: always uses Google Gemini. +- `groq`: always uses Groq. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + studio: { + ai: { + provider: 'groq', // or 'gateway' / 'gemini' / 'auto' + groqModel: 'llama-3.3-70b-versatile', + groqFastModel: 'llama-3.1-8b-instant', + geminiModel: 'gemini-2.0-flash', + geminiFastModel: 'gemini-2.0-flash-lite', + }, + }, +}) +``` + +#### Contextualization You can then configure AI behavior in your `nuxt.config.ts` file to provide contextual guidance and ensure the AI output aligns with your preferred writing style. @@ -83,7 +124,7 @@ AI Completion provides intelligent text suggestions as you type in the TipTap vi - **Manual trigger**: Press `Cmd/Ctrl+J` to request a suggestion anytime - **Accept**: Press `Tab` to insert the suggestion - **Dismiss**: Press `Escape` or continue typing to ignore -- Powered by **Claude Haiku 4.5** for fast responses (~300-500ms) +- Powered by a fast model (Claude Haiku 4.5 via Vercel AI Gateway, or `geminiFastModel` via Gemini / `groqFastModel` via Groq) for quick responses - **Toggle On/Off:** Click the sparkles ✨ button in the footer toolbar to enable/disable AI completion ### AI Transform @@ -103,7 +144,7 @@ Transform selected text with AI-powered improvements, fixes, translations, and s - **Select** a mode from the dropdown menu - **Review** the proposed changes in the preview pane before applying them. - **Accept or decline** the AI proposition -- Powered by **Claude Sonnet 4.5** for high-quality results +- Powered by a quality model (Claude Sonnet 4.5 via Vercel AI Gateway, or `geminiModel` via Gemini / `groqModel` via Groq) for high-quality results ### Contextualization diff --git a/package.json b/package.json index 73a61b21a..4f63bb479 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,11 @@ ], "main": "./dist/module/module.mjs", "scripts": { - "prepack": "nuxt-module-build build src/module; vite build src/app", - "dev": "pnpm run dev:prepare && STUDIO_DEV_SERVER=http://localhost:5151 nuxi dev playground/docus", - "dev:minimal": "pnpm run dev:prepare && STUDIO_DEV_SERVER=http://localhost:5151 nuxt dev playground/minimal", + "prepack": "nuxt-module-build build src/module && vite build src/app", + "dev": "pnpm run dev:prepare && pnpm run dev:docus", + "dev:docus": "STUDIO_DEV_SERVER=http://localhost:5151 nuxi dev playground/docus", + "dev:minimal": "pnpm run dev:prepare && pnpm run dev:minimal:serve", + "dev:minimal:serve": "STUDIO_DEV_SERVER=http://localhost:5151 nuxt dev playground/minimal", "dev:app": "vite src/app --port 5151", "dev:docs": "nuxt dev docs", "dev:prepare": "nuxt-module-build build --stub src/module && nuxt-module-build prepare src/module && nuxi prepare playground/docus && nuxi prepare docs", @@ -51,6 +53,8 @@ "ipx": "^3.1.1" }, "dependencies": { + "@ai-sdk/groq": "^3.0.0", + "@ai-sdk/google": "^3.0.0", "@ai-sdk/gateway": "^3.0.126", "@ai-sdk/vue": "^3.0.198", "@iconify-json/lucide": "^1.2.111", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944ba5ded..00fcb8b94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: '@ai-sdk/gateway': specifier: ^3.0.126 version: 3.0.127(zod@4.4.3) + '@ai-sdk/google': + specifier: ^3.0.0 + version: 3.0.103(zod@4.4.3) + '@ai-sdk/groq': + specifier: ^3.0.0 + version: 3.0.55(zod@4.4.3) '@ai-sdk/vue': specifier: ^3.0.198 version: 3.0.199(vue@3.5.35(typescript@5.9.3))(zod@4.4.3) @@ -238,6 +244,18 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/google@3.0.103': + resolution: {integrity: sha512-i/I92bfAeRPBvAwrW052NmPTkc+ZgVYs3vZjGhHgReE04cV37xJtbhZgr6U3CZKDArIAN0NhL0GoLpZ7FYV+dA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/groq@3.0.55': + resolution: {integrity: sha512-0iM7IX4xZqFJumNZpmLh11ksZqh8PZUJKky7D5urj1Pi8C03sHOr7GG5bk5xQnCj6cAEs/8I5rnolmFwq7ZtTw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@1.0.52': resolution: {integrity: sha512-yudE3Mdl8fsbzjMepOPbikA6nIRWOez+5e/IvOv1jK6XMAtsZ4+Xz8vjRQHI77VMv2TdiaMfsKKFoW6dlZRT3g==} engines: {node: '>=18'} @@ -256,10 +274,20 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.41': + resolution: {integrity: sha512-I7hhjfw01yEI8NkuAsT8Mv6xbWFr/lqLXMdaJQ2zWfXEpxog1eT7skDcv1+RY29/+5btzH8wD+vVvy48bk9oNQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.10': resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + '@ai-sdk/vue@3.0.199': resolution: {integrity: sha512-BwFvbV5ORA04oYe0Zz0SrXUc/qUkBRvu8dyWTFk32yQ3ygiZcaRWCxOhHtg+LiCwegO10ybwCg2ezug1DVNS8A==} engines: {node: '>=18'} @@ -1033,6 +1061,10 @@ packages: '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@fingerprintjs/botd@2.0.0': resolution: {integrity: sha512-yhuz23NKEcBDTHmGz/ULrXlGnbHenO+xZmVwuBkuqHUkqvaZ5TAA0kAgcRy4Wyo5dIBdkIf57UXX8/c9UlMLJg==} @@ -8570,6 +8602,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@6.24.1: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} @@ -9317,6 +9353,18 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.3 + '@ai-sdk/google@3.0.103(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.4.3) + zod: 4.4.3 + + '@ai-sdk/groq@3.0.55(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/mcp@1.0.52(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -9338,10 +9386,22 @@ snapshots: eventsource-parser: 3.1.0 zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.41(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 5.29.0 + zod: 4.4.3 + '@ai-sdk/provider@3.0.10': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/vue@3.0.199(vue@3.5.35(typescript@5.9.3))(zod@4.4.3)': dependencies: '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) @@ -9923,6 +9983,8 @@ snapshots: '@fastify/accept-negotiator@2.0.1': optional: true + '@fastify/busboy@2.1.1': {} + '@fingerprintjs/botd@2.0.0': {} '@floating-ui/core@1.7.5': @@ -19815,6 +19877,10 @@ snapshots: undici-types@7.18.2: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@6.24.1: {} undici@7.24.5: {} diff --git a/src/module/src/module.ts b/src/module/src/module.ts index 0fac79cdf..9c6c73c66 100644 --- a/src/module/src/module.ts +++ b/src/module/src/module.ts @@ -65,8 +65,8 @@ interface EditorOptions { interface MediaUploadOptions { /** * Enable external storage for media uploads. - * When enabled, media files are uploaded to cloud storage (S3, Vercel Blob, Cloudflare R2, etc.) - * instead of being committed to Git. NuxtHub auto-detects the driver from environment variables. + * When enabled, media files are uploaded to cloud storage (S3, Vercel Blob, Cloudflare R2, Cloudinary, etc.) + * instead of being committed to Git. * * @default false */ @@ -157,6 +157,33 @@ export interface ModuleOptions { * AI-powered content generation settings. */ ai?: { + /** + * The AI provider used for content generation. + * + * - `auto` (default): uses Groq when a Groq API key is set, then Gemini, + * then the Vercel AI Gateway. + * - `gateway`: always uses the Vercel AI Gateway (`apiKey`). + * - `gemini`: always uses the Google Gemini API (`geminiApiKey`). + * - `groq`: always uses the Groq API (`groqApiKey`) with Llama/Gemma/Qwen models. + * + * @default 'auto' + */ + provider?: 'auto' | 'gateway' | 'gemini' | 'groq' + /** + * The Groq API key for AI features (free tier via GroqCloud). + * Set via `GROQ_API_KEY` environment variable at runtime. + */ + groqApiKey?: string + /** + * The Groq model to use for quality transforms. + * @default 'llama-3.3-70b-versatile' + */ + groqModel?: string + /** + * The Groq model to use for quick/continue completions. + * @default 'llama-3.1-8b-instant' + */ + groqFastModel?: string /** * The Vercel AI Gateway key for AI features. * When set, AI-powered content generation will be enabled. @@ -164,6 +191,24 @@ export interface ModuleOptions { * Set via `NUXT_STUDIO_AI_API_KEY` environment variable at runtime. */ apiKey?: string + /** + * The Google Gemini API key for AI features. + * When set (and provider is not explicitly `gateway`), AI-powered content + * generation uses the Gemini API directly. + * + * Set via `GEMINI_API_KEY` or `NUXT_STUDIO_AI_GEMINI_API_KEY` environment variable at runtime. + */ + geminiApiKey?: string + /** + * The Google Gemini model to use. + * @default 'gemini-2.0-flash' + */ + geminiModel?: string + /** + * The Google Gemini model to use for quick/continue completions. + * @default 'gemini-2.0-flash-lite' + */ + geminiFastModel?: string /** * Contextual information to guide AI content generation. */ @@ -518,7 +563,6 @@ export default defineNuxtModule({ options.media!.publicUrl = resolve(nuxt.options.rootDir, 'public') } - // Public runtime config nuxt.options.runtimeConfig.public.studio = { route: options.route!, dev: Boolean(options.dev), @@ -527,8 +571,9 @@ export default defineNuxtModule({ }, ai: { // Honest build-time baseline; the studio-env middleware recomputes this at runtime - // once NUXT_STUDIO_AI_API_KEY is resolved. - enabled: Boolean(options.ai?.apiKey), + // once the API keys are resolved. + enabled: Boolean(options.ai?.apiKey || options.ai?.geminiApiKey || options.ai?.groqApiKey), + provider: options.ai?.provider || 'auto', context: { collectionName: options.ai?.context?.collection?.name as string, contentFolder: options.ai?.context?.collection?.folder as string, @@ -552,6 +597,13 @@ export default defineNuxtModule({ nuxt.options.runtimeConfig.studio = { ai: { apiKey: options.ai?.apiKey || '', + geminiApiKey: options.ai?.geminiApiKey || '', + geminiModel: options.ai?.geminiModel || 'gemini-2.0-flash', + geminiFastModel: options.ai?.geminiFastModel || 'gemini-2.0-flash-lite', + groqApiKey: options.ai?.groqApiKey || '', + groqModel: options.ai?.groqModel || 'llama-3.3-70b-versatile', + groqFastModel: options.ai?.groqFastModel || 'llama-3.1-8b-instant', + provider: options.ai?.provider || 'auto', context: options.ai?.context as never, experimental: options.ai?.experimental, }, diff --git a/src/module/src/runtime/server/middleware/studio-env.ts b/src/module/src/runtime/server/middleware/studio-env.ts index e11289caa..0585b409e 100644 --- a/src/module/src/runtime/server/middleware/studio-env.ts +++ b/src/module/src/runtime/server/middleware/studio-env.ts @@ -12,6 +12,14 @@ export default defineEventHandler((event) => { if (config.studio.ai) { config.studio.ai.apiKey = config.studio.ai.apiKey || process.env.AI_GATEWAY_API_KEY || '' + const aiConfig = config.studio.ai as typeof config.studio.ai & { geminiApiKey?: string, provider?: string, geminiModel?: string, geminiFastModel?: string, groqApiKey?: string, groqModel?: string, groqFastModel?: string } + aiConfig.geminiApiKey = aiConfig.geminiApiKey || process.env.GEMINI_API_KEY || process.env.NUXT_STUDIO_AI_GEMINI_API_KEY || '' + aiConfig.groqApiKey = aiConfig.groqApiKey || process.env.GROQ_API_KEY || '' + aiConfig.provider = aiConfig.provider || 'auto' + aiConfig.geminiModel = aiConfig.geminiModel || 'gemini-2.0-flash' + aiConfig.geminiFastModel = aiConfig.geminiFastModel || 'gemini-2.0-flash-lite' + aiConfig.groqModel = aiConfig.groqModel || 'llama-3.3-70b-versatile' + aiConfig.groqFastModel = aiConfig.groqFastModel || 'llama-3.1-8b-instant' } const github = config.studio.auth.github @@ -53,12 +61,21 @@ export default defineEventHandler((event) => { config.studio.git.gitlabToken = config.studio.git.gitlabToken || process.env.STUDIO_GITLAB_TOKEN || '' } - if (config.studio.media && !config.studio.media.publicUrl) { - config.studio.media.publicUrl = process.env.S3_PUBLIC_URL || '' + if (config.studio.media) { + config.studio.media.publicUrl = config.studio.media.publicUrl || process.env.S3_PUBLIC_URL || process.env.CLOUDINARY_CLOUD_URL || '' + const mediaConfig = config.studio.media as typeof config.studio.media & { cloudinary?: { cloudName?: string, apiKey?: string, apiSecret?: string, folder?: string } } + mediaConfig.cloudinary = mediaConfig.cloudinary || {} + mediaConfig.cloudinary.cloudName = mediaConfig.cloudinary.cloudName || process.env.CLOUDINARY_CLOUD_NAME || '' + mediaConfig.cloudinary.apiKey = mediaConfig.cloudinary.apiKey || process.env.CLOUDINARY_API_KEY || '' + mediaConfig.cloudinary.apiSecret = mediaConfig.cloudinary.apiSecret || process.env.CLOUDINARY_API_SECRET || '' + mediaConfig.cloudinary.folder = mediaConfig.cloudinary.folder || process.env.CLOUDINARY_FOLDER || '' } if (config.public?.studio?.ai) { - config.public.studio.ai.enabled = Boolean(config.studio.ai?.apiKey) + const aiConfig = config.studio.ai as typeof config.studio.ai & { geminiApiKey?: string, groqApiKey?: string, provider?: string } + const publicAi = config.public.studio.ai as { enabled?: boolean, provider?: string } + publicAi.enabled = Boolean(aiConfig?.apiKey || aiConfig?.geminiApiKey || aiConfig?.groqApiKey) + publicAi.provider = aiConfig?.provider || 'auto' } if (ciRepository && config.public?.studio?.repository !== undefined) { diff --git a/src/module/src/runtime/server/routes/ai/analyze.post.ts b/src/module/src/runtime/server/routes/ai/analyze.post.ts index 4a3f86762..4ff146c3a 100644 --- a/src/module/src/runtime/server/routes/ai/analyze.post.ts +++ b/src/module/src/runtime/server/routes/ai/analyze.post.ts @@ -1,9 +1,10 @@ import { streamText } from 'ai' -import { createGateway } from '@ai-sdk/gateway' + import { eventHandler, createError, readBody } from 'h3' import { consola } from 'consola' import { useRuntimeConfig } from '#imports' import { requireStudioAuth } from '../../utils/auth' +import { getAIModel } from '../../utils/ai/provider' import { queryCollection } from '@nuxt/content/server' import type { Collections, CollectionInfo } from '@nuxt/content' import type { DatabasePageItem } from 'nuxt-studio/app' @@ -57,8 +58,6 @@ export default eventHandler(async (event) => { }) } - const gateway = createGateway({ apiKey }) - // Build project context const projectContext = aiConfig?.context const projectInfo = buildProjectInfoContext(projectContext) @@ -164,7 +163,7 @@ export default eventHandler(async (event) => { const system = getAnalyzeSystem() return streamText({ - model: gateway.languageModel('anthropic/claude-sonnet-4.5'), + model: await getAIModel(event), system, prompt, maxOutputTokens: 2000, diff --git a/src/module/src/runtime/server/routes/ai/commit.post.ts b/src/module/src/runtime/server/routes/ai/commit.post.ts index ca2cd0815..f08a98476 100644 --- a/src/module/src/runtime/server/routes/ai/commit.post.ts +++ b/src/module/src/runtime/server/routes/ai/commit.post.ts @@ -1,8 +1,8 @@ import { streamText } from 'ai' -import { createGateway } from '@ai-sdk/gateway' import { eventHandler, readBody, createError } from 'h3' import { useRuntimeConfig } from '#imports' import { getCommitSystem } from '../../utils/ai/generate' +import { getAIModel } from '../../utils/ai/provider' import { requireStudioAuth } from '../../utils/auth' export default eventHandler(async (event) => { @@ -10,14 +10,6 @@ export default eventHandler(async (event) => { const config = useRuntimeConfig(event) - const apiKey = config.studio?.ai?.apiKey - if (!apiKey) { - throw createError({ - statusCode: 503, - statusMessage: 'AI features are not enabled. Please set AI_GATEWAY_API_KEY environment variable.', - }) - } - const { changes } = await readBody<{ changes: string }>(event) if (!changes) { @@ -29,10 +21,8 @@ export default eventHandler(async (event) => { const messagePrefix = config.public.studio?.git?.commit?.messagePrefix || undefined - const gateway = createGateway({ apiKey }) - return streamText({ - model: gateway.languageModel('anthropic/claude-haiku-4.5'), + model: await getAIModel(event, { fast: true }), system: getCommitSystem(messagePrefix), prompt: changes, maxOutputTokens: 60, diff --git a/src/module/src/runtime/server/routes/ai/generate.post.ts b/src/module/src/runtime/server/routes/ai/generate.post.ts index b887dfc74..d06b5111d 100644 --- a/src/module/src/runtime/server/routes/ai/generate.post.ts +++ b/src/module/src/runtime/server/routes/ai/generate.post.ts @@ -1,5 +1,5 @@ import { streamText } from 'ai' -import { createGateway } from '@ai-sdk/gateway' + import { eventHandler, readBody, createError } from 'h3' import { useRuntimeConfig } from '#imports' import { @@ -7,6 +7,7 @@ import { calculateMaxTokens, getSystem, } from '../../utils/ai/generate' +import { getAIModel } from '../../utils/ai/provider' import type { AIGenerateOptions } from 'nuxt-studio/app' import { requireStudioAuth } from '../../utils/auth' @@ -16,15 +17,6 @@ export default eventHandler(async (event) => { const config = useRuntimeConfig(event) const aiConfig = config.studio?.ai - const apiKey = aiConfig?.apiKey - if (!apiKey) { - throw createError({ - statusCode: 503, - statusMessage: 'AI features are not enabled. Please set NUXT_STUDIO_AI_API_KEY environment variable.', - }) - } - - const gateway = createGateway({ apiKey }) const { prompt, previousContext, nextContext, mode, language, selectionLength, fsPath, collectionName, hintOptions } = await readBody(event) @@ -93,17 +85,15 @@ export default eventHandler(async (event) => { const maxOutputTokens = calculateMaxTokens(selectionLength, mode || 'continue', hintOptions) // Select model based on mode: - // - Continue mode: Haiku 4.5 (optimized for speed, ~300-500ms) - // - Transform modes: Sonnet 4.5 (optimized for quality) - const modelName = mode === 'continue' - ? 'anthropic/claude-haiku-4.5' - : 'anthropic/claude-sonnet-4.5' + // - Continue mode: faster/cheaper model + // - Transform modes: quality model + const model = await getAIModel(event, { fast: mode === 'continue' }) // Temperature: continue mode benefits from creativity (0.7), transform modes are more deterministic (0.3) const temperature = mode === 'continue' ? 0.7 : 0.3 return streamText({ - model: gateway.languageModel(modelName), + model, system, prompt: finalPrompt, maxOutputTokens, diff --git a/src/module/src/runtime/server/utils/ai/provider.ts b/src/module/src/runtime/server/utils/ai/provider.ts new file mode 100644 index 000000000..1da221757 --- /dev/null +++ b/src/module/src/runtime/server/utils/ai/provider.ts @@ -0,0 +1,101 @@ +import { createGateway } from '@ai-sdk/gateway' +import { createError } from 'h3' +import type { H3Event } from 'h3' +import type { LanguageModel } from 'ai' +import { useRuntimeConfig } from '#imports' + +export type AIProviderName = 'auto' | 'gateway' | 'gemini' | 'groq' + +export type AIProviderConfig = { + provider: AIProviderName + apiKey: string + geminiApiKey: string + geminiModel: string + geminiFastModel: string + groqApiKey: string + groqModel: string + groqFastModel: string +} + +function getConfig(event: H3Event): AIProviderConfig { + const config = useRuntimeConfig(event).studio?.ai as unknown as AIProviderConfig | undefined + if (!config) { + throw createError({ statusCode: 503, statusMessage: 'AI features are not configured.' }) + } + return config +} + +/** + * Resolves the effective provider name from the configured preference and + * the available API keys. + * + * - `groq` requires a Groq API key (free tier via GroqCloud, works in all regions). + * - `gemini` requires a Gemini API key. + * - `gateway` requires the Vercel AI Gateway API key. + * - `auto` uses Groq when a Groq key is present, then Gemini, then the gateway. + */ +function resolveProvider(config: AIProviderConfig): 'groq' | 'gemini' | 'gateway' { + if (config.provider === 'groq') { + if (!config.groqApiKey) { + throw createError({ statusCode: 503, statusMessage: 'AI features are not enabled. Please set GROQ_API_KEY.' }) + } + return 'groq' + } + if (config.provider === 'gemini') { + if (!config.geminiApiKey) { + throw createError({ statusCode: 503, statusMessage: 'AI features are not enabled. Please set GEMINI_API_KEY or NUXT_STUDIO_AI_GEMINI_API_KEY.' }) + } + return 'gemini' + } + if (config.provider === 'gateway') { + if (!config.apiKey) { + throw createError({ statusCode: 503, statusMessage: 'AI features are not enabled. Please set NUXT_STUDIO_AI_API_KEY.' }) + } + return 'gateway' + } + if (config.groqApiKey) { + return 'groq' + } + if (config.geminiApiKey) { + return 'gemini' + } + if (config.apiKey) { + return 'gateway' + } + throw createError({ statusCode: 503, statusMessage: 'AI features are not enabled. Please set GROQ_API_KEY, NUXT_STUDIO_AI_API_KEY, or GEMINI_API_KEY.' }) +} + +/** + * Returns a Vercel AI SDK language model for the configured provider. + * + * @param event - Current H3 event (used to read server runtime config). + * @param [opts] - Model options. + * @param [opts.fast] - When `true`, selects a cheaper/faster model for quick + * completions (e.g. continue mode); otherwise the quality model is used. + */ +export async function getAIModel(event: H3Event, opts: { fast?: boolean } = {}): Promise { + const config = getConfig(event) + const provider = resolveProvider(config) + + if (provider === 'groq') { + // @ai-sdk/groq is loaded lazily so that projects not using Groq do not bundle it. + const { createGroq } = await import('@ai-sdk/groq') + const groqProvider = createGroq({ apiKey: config.groqApiKey }) + const modelId = opts.fast ? config.groqFastModel : config.groqModel + return groqProvider(modelId) + } + + if (provider === 'gemini') { + // @ai-sdk/google is loaded lazily so that projects not using Gemini do not bundle it. + const { createGoogleGenerativeAI } = await import('@ai-sdk/google') + const googleProvider = createGoogleGenerativeAI({ apiKey: config.geminiApiKey }) + const modelId = opts.fast ? config.geminiFastModel : config.geminiModel + return googleProvider(modelId) + } + + const gateway = createGateway({ apiKey: config.apiKey }) + const modelId = opts.fast + ? 'anthropic/claude-haiku-4.5' + : 'anthropic/claude-sonnet-4.5' + return gateway.languageModel(modelId) +}