diff --git a/package-lock.json b/package-lock.json index 2395c2f..3c504bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "gpt-tokenizer": "^3.4.0", "harper.js": "^1.2.0", "html5-qrcode": "^2.3.8", + "jsonrepair": "^3.13.2", "jszip": "^3.10.1", "lucide-svelte": "^0.468.0", "marked": "^17.0.1", @@ -1098,6 +1099,7 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1137,6 +1139,7 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1792,6 +1795,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1804,6 +1808,7 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.67.tgz", "integrity": "sha512-xBnTcByHCj3OcG6V8G1s6zvSEqK0Bdiu+IEXYcpGrve1iGFFRgcrKeZtr/WAW/7gupnSvBbDF24BEv1OOfqi1g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.32", "@ai-sdk/provider": "3.0.7", @@ -1946,6 +1951,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2292,6 +2298,15 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/jsonrepair": { + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", + "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -2692,6 +2707,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2719,6 +2735,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2909,6 +2926,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3025,7 +3043,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -3101,6 +3120,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3170,6 +3190,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 1f9f975..e64ef99 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "gpt-tokenizer": "^3.4.0", "harper.js": "^1.2.0", "html5-qrcode": "^2.3.8", + "jsonrepair": "^3.13.2", "jszip": "^3.10.1", "lucide-svelte": "^0.468.0", "marked": "^17.0.1", diff --git a/src/lib/services/ai/sdk/generate.ts b/src/lib/services/ai/sdk/generate.ts index c3bab84..8eb58df 100644 --- a/src/lib/services/ai/sdk/generate.ts +++ b/src/lib/services/ai/sdk/generate.ts @@ -5,52 +5,53 @@ * Uses explicit provider selection from APIProfile.providerType. */ -import { extractJsonMiddleware, generateText, streamText, Output, generateImage as sdkGenerateImage, wrapLanguageModel } from 'ai'; +import { + extractJsonMiddleware, + generateText, + streamText, + Output, + generateImage as sdkGenerateImage, + wrapLanguageModel, +} from 'ai'; +import type { LanguageModelV3, LanguageModelV3Middleware } from '@ai-sdk/provider'; +import type { ProviderOptions } from '@ai-sdk/provider-utils'; import { createOpenAI } from '@ai-sdk/openai'; import { createChutes } from '@chutes-ai/ai-sdk-provider'; import { createPollinations } from 'ai-sdk-pollinations'; -import type { LanguageModelV3 } from '@ai-sdk/provider'; -import type { ProviderOptions } from '@ai-sdk/provider-utils'; +import { jsonrepair } from 'jsonrepair'; import type { z } from 'zod'; + import { settings } from '$lib/stores/settings.svelte'; -import { createProviderFromProfile } from './providers'; -import { PROVIDER_CAPABILITIES } from './providers/defaults'; import type { ProviderType, GenerationPreset, ReasoningEffort, APIProfile } from '$lib/types'; import { createLogger } from '../core/config'; +import { createProviderFromProfile } from './providers'; +import { PROVIDER_CAPABILITIES } from './providers/defaults'; +import { promptSchemaMiddleware, patchResponseMiddleware, loggingMiddleware } from './middleware'; const log = createLogger('Generate'); -// JSON-compatible types for provider options -type JSONValue = null | string | number | boolean | JSONObject | JSONValue[]; -type JSONObject = { [key: string]: JSONValue | undefined }; - // ============================================================================ // Types // ============================================================================ +type JSONValue = null | string | number | boolean | JSONObject | JSONValue[]; +type JSONObject = { [key: string]: JSONValue | undefined }; + interface BaseGenerateOptions { - /** Preset ID (e.g., 'suggestions', 'classifier') */ presetId: string; - /** System prompt */ system: string; - /** User prompt */ prompt: string; - /** Optional abort signal for cancellation */ signal?: AbortSignal; } interface GenerateObjectOptions extends BaseGenerateOptions { - /** Zod schema for output validation */ schema: T; } // ============================================================================ -// Provider Options Builder +// Provider Options // ============================================================================ -/** - * Map provider types to their providerOptions key in the AI SDK. - */ const PROVIDER_OPTIONS_KEY: Record = { openrouter: 'openrouter', openai: 'openai', @@ -61,58 +62,46 @@ const PROVIDER_OPTIONS_KEY: Record = { pollinations: 'pollinations', }; -/** - * Convert reasoning effort level to token budget for Anthropic. - */ -function effortToBudget(effort: ReasoningEffort): number { - const budgets: Record = { - off: 0, - low: 4000, - medium: 8000, - high: 16000, - }; - return budgets[effort] ?? 8000; -} +const ANTHROPIC_REASONING_BUDGETS: Record = { + off: 0, + low: 4000, + medium: 8000, + high: 16000, +}; /** * Build provider-specific options from preset settings. - * Uses explicit providerType from profile. */ export function buildProviderOptions( preset: GenerationPreset, providerType: ProviderType ): ProviderOptions | undefined { const options: JSONObject = {}; - const providerKey = PROVIDER_OPTIONS_KEY[providerType]; - // Reasoning configuration (provider-specific format) if (preset.reasoningEffort && preset.reasoningEffort !== 'off') { switch (providerType) { case 'openrouter': - // OpenRouter: { reasoning: { effort: 'low'|'medium'|'high' } } options.reasoning = { effort: preset.reasoningEffort }; break; case 'openai': - // OpenAI (o1 models): { reasoningEffort: 'low'|'medium'|'high' } options.reasoningEffort = preset.reasoningEffort; break; case 'anthropic': - // Anthropic: { thinking: { type: 'enabled', budgetTokens: N } } - options.thinking = { type: 'enabled', budgetTokens: effortToBudget(preset.reasoningEffort) }; - break; - case 'google': - // Google: No reasoning support yet + options.thinking = { + type: 'enabled', + budgetTokens: ANTHROPIC_REASONING_BUDGETS[preset.reasoningEffort] ?? 8000, + }; break; } } - // Manual body params (top_p, top_k, penalties, etc.) if (preset.manualBody) { try { const manual = JSON.parse(preset.manualBody) as JSONObject; + const reservedKeys = ['messages', 'tools', 'tool_choice', 'stream', 'model']; if (manual && typeof manual === 'object' && !Array.isArray(manual)) { for (const [key, value] of Object.entries(manual)) { - if (!['messages', 'tools', 'tool_choice', 'stream', 'model'].includes(key)) { + if (!reservedKeys.includes(key)) { options[key] = value; } } @@ -126,6 +115,7 @@ export function buildProviderOptions( return undefined; } + const providerKey = PROVIDER_OPTIONS_KEY[providerType]; return { [providerKey]: options } as ProviderOptions; } @@ -139,12 +129,18 @@ interface ResolvedConfig { providerType: ProviderType; model: LanguageModelV3; providerOptions: ProviderOptions | undefined; + supportsStructuredOutput: boolean; +} + +interface NarrativeConfig { + profile: APIProfile; + providerType: ProviderType; + model: LanguageModelV3; + temperature: number; + maxTokens: number; + providerOptions: ProviderOptions | undefined; } -/** - * Resolve preset → profile → model. - * This is the single place where we go from presetId to a ready-to-use model. - */ function resolveConfig(presetId: string): ResolvedConfig { const preset = settings.getPresetConfig(presetId); const profileId = preset.profileId ?? settings.apiSettings.mainNarrativeProfileId; @@ -155,30 +151,19 @@ function resolveConfig(presetId: string): ResolvedConfig { } const provider = createProviderFromProfile(profile); - // Call provider directly - all providers support provider(modelId) syntax const model = provider(preset.model) as LanguageModelV3; - const providerOptions = buildProviderOptions(preset, profile.providerType); + const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; - return { preset, profile, providerType: profile.providerType, model, providerOptions }; + return { + preset, + profile, + providerType: profile.providerType, + model, + providerOptions: buildProviderOptions(preset, profile.providerType), + supportsStructuredOutput: capabilities?.supportsStructuredOutput ?? true, + }; } -/** - * Resolved config for main narrative profile. - * Uses settings from apiSettings directly. - */ -interface NarrativeConfig { - profile: APIProfile; - providerType: ProviderType; - model: LanguageModelV3; - temperature: number; - maxTokens: number; - providerOptions: ProviderOptions | undefined; -} - -/** - * Resolve config from the main narrative profile. - * Uses apiSettings.defaultModel, temperature, and maxTokens directly. - */ function resolveNarrativeConfig(): NarrativeConfig { const profile = settings.getMainNarrativeProfile(); @@ -188,10 +173,8 @@ function resolveNarrativeConfig(): NarrativeConfig { const provider = createProviderFromProfile(profile); const modelId = settings.apiSettings.defaultModel; - // Call provider directly - all providers support provider(modelId) syntax const model = provider(modelId) as LanguageModelV3; - // Build a minimal preset-like object for provider options const narrativePreset: GenerationPreset = { id: '_narrative', name: 'Narrative', @@ -201,42 +184,67 @@ function resolveNarrativeConfig(): NarrativeConfig { temperature: settings.apiSettings.temperature, maxTokens: settings.apiSettings.maxTokens, reasoningEffort: settings.apiSettings.reasoningEffort ?? 'off', - manualBody: '' + manualBody: '', }; - const providerOptions = buildProviderOptions(narrativePreset, profile.providerType); - return { profile, providerType: profile.providerType, model, temperature: settings.apiSettings.temperature, maxTokens: settings.apiSettings.maxTokens, - providerOptions + providerOptions: buildProviderOptions(narrativePreset, profile.providerType), }; } +// ============================================================================ +// Middleware +// ============================================================================ + +function createJsonExtractMiddleware(): LanguageModelV3Middleware { + return extractJsonMiddleware({ + transform: (text) => { + try { + const repaired = jsonrepair(text); + if (repaired !== text) { + log('JSON repaired by jsonrepair'); + } + return repaired; + } catch (e) { + log('jsonrepair failed:', e); + return text; + } + }, + }); +} + +function buildStructuredMiddleware(supportsStructuredOutput: boolean): LanguageModelV3Middleware[] { + const base = [patchResponseMiddleware(), createJsonExtractMiddleware(), loggingMiddleware()]; + if (supportsStructuredOutput) { + return base; + } + return [patchResponseMiddleware(), promptSchemaMiddleware(), createJsonExtractMiddleware(), loggingMiddleware()]; +} + +function buildPlainTextMiddleware(): LanguageModelV3Middleware[] { + return [patchResponseMiddleware(), loggingMiddleware()]; +} + // ============================================================================ // Generate Functions // ============================================================================ -/** - * Generate structured output from LLM. - * Uses AI SDK's Output.object() for automatic Zod schema validation. - */ export async function generateStructured( options: GenerateObjectOptions ): Promise> { const { presetId, schema, system, prompt, signal } = options; - const { preset, providerType, model, providerOptions } = resolveConfig(presetId); + const config = resolveConfig(presetId); + const { preset, providerType, model, providerOptions, supportsStructuredOutput } = config; - log('generateStructured', { presetId, model: preset.model, providerType }); + log('generateStructured', { presetId, model: preset.model, providerType, supportsStructuredOutput }); const result = await generateText({ - model: wrapLanguageModel({ - model, - middleware: extractJsonMiddleware(), - }), + model: wrapLanguageModel({ model, middleware: buildStructuredMiddleware(supportsStructuredOutput) }), system, prompt, output: Output.object({ schema }), @@ -249,9 +257,6 @@ export async function generateStructured( return result.output as z.infer; } -/** - * Generate plain text output from LLM. - */ export async function generatePlainText(options: BaseGenerateOptions): Promise { const { presetId, system, prompt, signal } = options; const { preset, providerType, model, providerOptions } = resolveConfig(presetId); @@ -259,7 +264,7 @@ export async function generatePlainText(options: BaseGenerateOptions): Promise(options: GenerateObjectOptions) { const { presetId, schema, system, prompt, signal } = options; - const { preset, providerType, model, providerOptions } = resolveConfig(presetId); + const config = resolveConfig(presetId); + const { preset, providerType, model, providerOptions, supportsStructuredOutput } = config; - log('streamStructured', { presetId, model: preset.model, providerType }); + log('streamStructured', { presetId, model: preset.model, providerType, supportsStructuredOutput }); return streamText({ - model: wrapLanguageModel({ - model, - middleware: extractJsonMiddleware(), - }), + model: wrapLanguageModel({ model, middleware: buildStructuredMiddleware(supportsStructuredOutput) }), system, prompt, output: Output.object({ schema }), @@ -316,22 +313,15 @@ export function streamStructured(options: GenerateObjectOpt } // ============================================================================ -// Narrative Generation Functions (Main Profile) +// Narrative Generation (Main Profile) // ============================================================================ interface NarrativeGenerateOptions { - /** System prompt */ system: string; - /** User prompt */ prompt: string; - /** Optional abort signal for cancellation */ signal?: AbortSignal; } -/** - * Stream narrative text using the main narrative profile. - * Uses apiSettings.defaultModel, temperature, and maxTokens. - */ export function streamNarrative(options: NarrativeGenerateOptions) { const { system, prompt, signal } = options; const { providerType, model, temperature, maxTokens, providerOptions } = resolveNarrativeConfig(); @@ -339,7 +329,7 @@ export function streamNarrative(options: NarrativeGenerateOptions) { log('streamNarrative', { model: settings.apiSettings.defaultModel, providerType }); return streamText({ - model, + model: wrapLanguageModel({ model, middleware: buildPlainTextMiddleware() }), system, prompt, temperature, @@ -349,10 +339,6 @@ export function streamNarrative(options: NarrativeGenerateOptions) { }); } -/** - * Generate narrative text using the main narrative profile. - * Uses apiSettings.defaultModel, temperature, and maxTokens. - */ export async function generateNarrative(options: NarrativeGenerateOptions): Promise { const { system, prompt, signal } = options; const { providerType, model, temperature, maxTokens, providerOptions } = resolveNarrativeConfig(); @@ -360,7 +346,7 @@ export async function generateNarrative(options: NarrativeGenerateOptions): Prom log('generateNarrative', { model: settings.apiSettings.defaultModel, providerType }); const { text } = await generateText({ - model, + model: wrapLanguageModel({ model, middleware: buildPlainTextMiddleware() }), system, prompt, temperature, @@ -377,110 +363,78 @@ export async function generateNarrative(options: NarrativeGenerateOptions): Prom // ============================================================================ export interface GenerateImageOptions { - /** API profile ID to use for image generation */ profileId: string; - /** Image model ID */ model: string; - /** Generation prompt */ prompt: string; - /** Image size (e.g., '1024x1024') */ size?: string; - /** Reference images for img2img (base64 data URLs) */ referenceImages?: string[]; - /** Optional abort signal */ signal?: AbortSignal; } export interface GenerateImageResult { - /** Generated image as base64 */ base64: string; - /** Provider's revised prompt if any */ revisedPrompt?: string; } -/** - * Get image model from provider. - * - * Detects available methods at runtime: - * - .imageModel(modelId) - OpenAI-compatible, Chutes, Pollinations - * - .image(modelId) - OpenAI SDK - */ function getImageModel( provider: ReturnType, providerType: ProviderType, modelId: string ) { - // Check for .imageModel() method (OpenAI-compatible, Chutes, Pollinations) if ('imageModel' in provider && typeof provider.imageModel === 'function') { return (provider as ReturnType).imageModel(modelId); } - - // Check for .image() method (OpenAI SDK) if ('image' in provider && typeof provider.image === 'function') { return (provider as ReturnType).image(modelId); } - throw new Error(`Provider ${providerType} does not support image generation`); } -/** - * Generate an image using the SDK. - * - * @param options - Image generation options - * @returns Generated image data - * @throws Error if profile not found or provider doesn't support image generation - */ +function ensureDataUrl(img: string): string { + if (img.startsWith('data:')) { + return img; + } + return `data:image/png;base64,${img}`; +} + export async function generateImage(options: GenerateImageOptions): Promise { const { profileId, model, prompt, size = '1024x1024', referenceImages, signal } = options; - // Get profile const profile = settings.getProfile(profileId); if (!profile) { throw new Error(`Profile not found: ${profileId}`); } - // Verify provider supports image generation const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; if (!capabilities?.supportsImageGeneration) { throw new Error(`Provider ${profile.providerType} does not support image generation`); } - log('generateImage', { profileId, model, providerType: profile.providerType, hasReferences: !!referenceImages?.length }); + log('generateImage', { + profileId, + model, + providerType: profile.providerType, + hasReferences: !!referenceImages?.length, + }); - // Create provider and get image model const provider = createProviderFromProfile(profile); const imageModel = getImageModel(provider, profile.providerType, model); - // Build generation options - // If reference images are provided, use prompt object format for img2img - const generateOptions: Parameters[0] = { + const promptValue = referenceImages?.length + ? { text: prompt, images: referenceImages.map(ensureDataUrl) } + : prompt; + + const result = await sdkGenerateImage({ model: imageModel, size: size as `${number}x${number}`, abortSignal: signal, - prompt: referenceImages?.length - ? { - text: prompt, - images: referenceImages.map(img => { - // Ensure proper data URL format - if (img.startsWith('data:')) { - return img; - } - return `data:image/png;base64,${img}`; - }), - } - : prompt, - }; + prompt: promptValue, + }); - const result = await sdkGenerateImage(generateOptions); - - // Get the first image const image = result.images?.[0] ?? result.image; if (!image) { throw new Error('No image data returned from provider'); } - return { - base64: image.base64, - revisedPrompt: undefined, // SDK doesn't expose this consistently - }; + return { base64: image.base64 }; } diff --git a/src/lib/services/ai/sdk/middleware/index.ts b/src/lib/services/ai/sdk/middleware/index.ts new file mode 100644 index 0000000..6ec13ad --- /dev/null +++ b/src/lib/services/ai/sdk/middleware/index.ts @@ -0,0 +1,8 @@ +/** + * AI SDK Middleware + */ + +export { loggingMiddleware } from './logging'; +export { patchResponseMiddleware } from './patchResponse'; +export { promptSchemaMiddleware } from './promptSchema'; +export type { PromptSchemaMiddlewareOptions } from './promptSchema'; diff --git a/src/lib/services/ai/sdk/middleware/logging.ts b/src/lib/services/ai/sdk/middleware/logging.ts new file mode 100644 index 0000000..3ff623e --- /dev/null +++ b/src/lib/services/ai/sdk/middleware/logging.ts @@ -0,0 +1,83 @@ +/** + * Logging Middleware + * + * Logs the full prompt and output. Place LAST in middleware chain. + */ + +import type { LanguageModelV3Middleware, LanguageModelV3Prompt } from '@ai-sdk/provider'; +import { createLogger } from '../../core/config'; + +const log = createLogger('AI'); + +function promptToString(prompt: LanguageModelV3Prompt): string { + return prompt + .map((msg) => { + const role = msg.role.toUpperCase(); + + if (msg.role === 'system') { + return `[${role}]\n${msg.content}`; + } + + if (msg.role === 'user' || msg.role === 'assistant') { + const content = msg.content + .map((part) => { + if (part.type === 'text') return part.text; + if (part.type === 'reasoning') return `[REASONING]\n${part.text}`; + if (part.type === 'tool-call') return `[TOOL: ${part.toolName}]`; + return `[${part.type.toUpperCase()}]`; + }) + .join('\n'); + return `[${role}]\n${content}`; + } + + if (msg.role === 'tool') { + return `[TOOL RESULT]\n${JSON.stringify(msg.content, null, 2)}`; + } + + return `[${role}]\n${JSON.stringify(msg, null, 2)}`; + }) + .join('\n\n---\n\n'); +} + +function extractText(content: Array<{ type: string; text?: string }>): string | undefined { + return content.find((p) => p.type === 'text' && p.text)?.text; +} + +export function loggingMiddleware(): LanguageModelV3Middleware { + return { + specificationVersion: 'v3', + + wrapGenerate: async ({ doGenerate, params }) => { + log('=== REQUEST ==='); + log('Prompt:\n' + promptToString(params.prompt)); + if (params.responseFormat) { + log('Response Format:', JSON.stringify(params.responseFormat, null, 2)); + } + + const result = await doGenerate(); + + log('=== RESPONSE ==='); + const text = extractText(result.content); + if (text) log('Text:', text); + + const r = result as Record; + if (r.output) log('Output:', JSON.stringify(r.output, null, 2)); + if (result.usage) log('Usage:', result.usage); + if (result.finishReason) log('Finish Reason:', result.finishReason); + + return result; + }, + + wrapStream: async ({ doStream, params }) => { + log('=== STREAM REQUEST ==='); + log('Prompt:\n' + promptToString(params.prompt)); + if (params.responseFormat) { + log('Response Format:', JSON.stringify(params.responseFormat, null, 2)); + } + + const result = await doStream(); + log('=== STREAM STARTED ==='); + return result; + }, + }; +} diff --git a/src/lib/services/ai/sdk/middleware/patchResponse.ts b/src/lib/services/ai/sdk/middleware/patchResponse.ts new file mode 100644 index 0000000..e96e3d6 --- /dev/null +++ b/src/lib/services/ai/sdk/middleware/patchResponse.ts @@ -0,0 +1,59 @@ +/** + * Patch Response Middleware + * + * Fixes provider response issues that cause SDK validation errors. + */ + +import type { LanguageModelV3Middleware } from '@ai-sdk/provider'; +import { createLogger } from '../../core/config'; + +const log = createLogger('PatchResponse'); + +interface UsageV6 { + inputTokens: { total: number }; + outputTokens: { total: number }; +} + +const DEFAULT_USAGE: UsageV6 = { + inputTokens: { total: 0 }, + outputTokens: { total: 0 }, +}; + +function ensureUsage(usage: unknown): UsageV6 { + if (!usage || typeof usage !== 'object') { + return DEFAULT_USAGE; + } + + const u = usage as Record; + + if (u.inputTokens && typeof u.inputTokens === 'object') { + return usage as UsageV6; + } + + return { + inputTokens: { total: typeof u.promptTokens === 'number' ? u.promptTokens : 0 }, + outputTokens: { total: typeof u.completionTokens === 'number' ? u.completionTokens : 0 }, + }; +} + +export function patchResponseMiddleware(): LanguageModelV3Middleware { + return { + specificationVersion: 'v3', + + wrapGenerate: async ({ doGenerate }) => { + const result = await doGenerate(); + + if (!result.usage) log('Patching null usage'); + (result as { usage: UsageV6 }).usage = ensureUsage(result.usage); + + if (!result.finishReason) { + log('Patching missing finishReason'); + result.finishReason = 'stop'; + } + + return result; + }, + + wrapStream: async ({ doStream }) => doStream(), + }; +} diff --git a/src/lib/services/ai/sdk/middleware/promptSchema.ts b/src/lib/services/ai/sdk/middleware/promptSchema.ts new file mode 100644 index 0000000..ae3bee8 --- /dev/null +++ b/src/lib/services/ai/sdk/middleware/promptSchema.ts @@ -0,0 +1,164 @@ +/** + * Prompt Schema Middleware + * + * For providers/models that don't support response_format (structured outputs), + * this middleware injects the schema into the prompt as TypeScript-like types + * and removes response_format from the request. + * + * Works with extractJsonMiddleware to parse the output. + */ + +import type { LanguageModelV3Middleware, LanguageModelV3Prompt } from '@ai-sdk/provider'; +import type { JSONSchema7 } from 'json-schema'; + +// ============================================================================ +// Schema to TypeScript Conversion +// ============================================================================ + +function jsonSchemaToTypeScript(schema: JSONSchema7, indent = 0): string { + const pad = ' '.repeat(indent); + const padInner = ' '.repeat(indent + 1); + + const nullable = Array.isArray(schema.type) && schema.type.includes('null'); + const primaryType = Array.isArray(schema.type) + ? schema.type.find((t) => t !== 'null') + : schema.type; + + function withNullable(type: string): string { + return nullable ? `${type} | null` : type; + } + + switch (primaryType) { + case 'string': + if (schema.enum) { + const enumValues = schema.enum.map((v) => `"${v}"`).join(' | '); + return nullable ? `(${enumValues}) | null` : enumValues; + } + return withNullable('string'); + + case 'number': + case 'integer': + return withNullable('number'); + + case 'boolean': + return withNullable('boolean'); + + case 'array': { + const items = schema.items as JSONSchema7 | undefined; + const itemType = items ? jsonSchemaToTypeScript(items, indent) : 'unknown'; + return withNullable(`${itemType}[]`); + } + + case 'object': { + if (!schema.properties || Object.keys(schema.properties).length === 0) { + return withNullable('Record'); + } + + const required = new Set(schema.required ?? []); + const props = Object.entries(schema.properties) + .map(([key, propSchema]) => { + const propDef = propSchema as JSONSchema7; + const optional = required.has(key) ? '' : '?'; + const propType = jsonSchemaToTypeScript(propDef, indent + 1); + const description = propDef.description ? ` // ${propDef.description}` : ''; + return `${padInner}${key}${optional}: ${propType};${description}`; + }) + .join('\n'); + + return withNullable(`{\n${props}\n${pad}}`); + } + + default: + return 'unknown'; + } +} + +function schemaToTypeScriptBlock(schema: JSONSchema7, name = 'Response'): string { + const typeBody = jsonSchemaToTypeScript(schema, 0); + if (schema.type === 'object' && schema.properties) { + return `interface ${name} ${typeBody}`; + } + return `type ${name} = ${typeBody}`; +} + +// ============================================================================ +// Prompt Injection +// ============================================================================ + +const SCHEMA_INSTRUCTION_TEMPLATE = `Respond strictly with JSON. The JSON should be compatible with the TypeScript type Response from the following: + +{schema} + +Output ONLY the JSON object, no other text or markdown.`; + +const SIMPLE_JSON_INSTRUCTION = 'Respond strictly with valid JSON. Output ONLY the JSON, no other text.'; + +function injectSchemaIntoPrompt( + prompt: LanguageModelV3Prompt, + instruction: string +): LanguageModelV3Prompt { + const newPrompt = [...prompt]; + const lastUserIdx = newPrompt.findLastIndex((msg) => msg.role === 'user'); + + if (lastUserIdx >= 0) { + const lastUserMsg = newPrompt[lastUserIdx]; + if (lastUserMsg.role === 'user') { + const textParts = lastUserMsg.content.filter((p) => p.type === 'text'); + const otherParts = lastUserMsg.content.filter((p) => p.type !== 'text'); + const combinedText = textParts.map((p) => p.text).join('\n') + '\n\n' + instruction; + + newPrompt[lastUserIdx] = { + ...lastUserMsg, + content: [{ type: 'text', text: combinedText }, ...otherParts], + }; + } + } else { + newPrompt.push({ + role: 'user', + content: [{ type: 'text', text: instruction }], + }); + } + + return newPrompt; +} + +// ============================================================================ +// Middleware +// ============================================================================ + +export interface PromptSchemaMiddlewareOptions { + instruction?: string; + typeName?: string; +} + +export function promptSchemaMiddleware( + options: PromptSchemaMiddlewareOptions = {} +): LanguageModelV3Middleware { + const instructionTemplate = options.instruction ?? SCHEMA_INSTRUCTION_TEMPLATE; + const typeName = options.typeName ?? 'Response'; + + return { + specificationVersion: 'v3', + + transformParams: async ({ params }) => { + const { responseFormat } = params; + + if (!responseFormat || responseFormat.type !== 'json') { + return params; + } + + const instruction = responseFormat.schema + ? instructionTemplate.replace( + '{schema}', + schemaToTypeScriptBlock(responseFormat.schema as JSONSchema7, typeName) + ) + : SIMPLE_JSON_INSTRUCTION; + + return { + ...params, + prompt: injectSchemaIntoPrompt(params.prompt, instruction), + responseFormat: undefined, + }; + }, + }; +} diff --git a/src/lib/services/ai/sdk/providers/defaults.ts b/src/lib/services/ai/sdk/providers/defaults.ts index 3ea2f82..e3d091c 100644 --- a/src/lib/services/ai/sdk/providers/defaults.ts +++ b/src/lib/services/ai/sdk/providers/defaults.ts @@ -2,55 +2,47 @@ * Provider Defaults Configuration * * Defines default models and settings for each provider type. - * Used when creating presets, resetting to defaults, or first-time setup. */ import type { ProviderType, ReasoningEffort } from '$lib/types'; -/** - * OpenRouter API URL constant. - * Used throughout the app for OpenRouter-based profiles. - */ -export const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1'; +// ============================================================================ +// API URLs +// ============================================================================ -/** - * NanoGPT API URL constant. - */ +export const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1'; export const NANOGPT_API_URL = 'https://nano-gpt.com/api/v1'; -/** - * Provider capabilities - what each provider supports. - */ +// ============================================================================ +// Provider Capabilities +// ============================================================================ + export interface ProviderCapabilities { supportsTextGeneration: boolean; supportsImageGeneration: boolean; + supportsStructuredOutput: boolean; } -/** - * Provider capabilities lookup. - */ export const PROVIDER_CAPABILITIES: Record = { - openrouter: { supportsTextGeneration: true, supportsImageGeneration: false }, - openai: { supportsTextGeneration: true, supportsImageGeneration: true }, - anthropic: { supportsTextGeneration: true, supportsImageGeneration: false }, - google: { supportsTextGeneration: true, supportsImageGeneration: true }, - nanogpt: { supportsTextGeneration: true, supportsImageGeneration: true }, - chutes: { supportsTextGeneration: true, supportsImageGeneration: true }, - pollinations: { supportsTextGeneration: true, supportsImageGeneration: true }, + openrouter: { supportsTextGeneration: true, supportsImageGeneration: false, supportsStructuredOutput: true }, + openai: { supportsTextGeneration: true, supportsImageGeneration: true, supportsStructuredOutput: true }, + anthropic: { supportsTextGeneration: true, supportsImageGeneration: false, supportsStructuredOutput: true }, + google: { supportsTextGeneration: true, supportsImageGeneration: true, supportsStructuredOutput: true }, + nanogpt: { supportsTextGeneration: true, supportsImageGeneration: true, supportsStructuredOutput: false }, + chutes: { supportsTextGeneration: true, supportsImageGeneration: true, supportsStructuredOutput: true }, + pollinations: { supportsTextGeneration: true, supportsImageGeneration: true, supportsStructuredOutput: false }, }; -/** - * Image model defaults for providers that support image generation. - */ +// ============================================================================ +// Image Model Defaults +// ============================================================================ + export interface ImageModelDefaults { defaultModel: string; - referenceModel: string; // Model that supports image-to-image + referenceModel: string; supportedSizes: string[]; } -/** - * Image model defaults per provider. - */ export const IMAGE_MODEL_DEFAULTS: Partial> = { openai: { defaultModel: 'dall-e-3', @@ -79,9 +71,10 @@ export const IMAGE_MODEL_DEFAULTS: Partial = { openrouter: { name: 'OpenRouter', diff --git a/src/lib/services/ai/sdk/providers/fetch.ts b/src/lib/services/ai/sdk/providers/fetch.ts index 4f4ed2a..c0f9745 100644 --- a/src/lib/services/ai/sdk/providers/fetch.ts +++ b/src/lib/services/ai/sdk/providers/fetch.ts @@ -1,67 +1,78 @@ /** * Tauri Fetch Adapter * - * Wraps @tauri-apps/plugin-http's fetch for use with Vercel AI SDK providers. - * This is necessary because Tauri apps run in a sandboxed WebView that requires - * using Tauri's HTTP plugin for external network requests. + * Wraps @tauri-apps/plugin-http's fetch for Vercel AI SDK providers. + * Patches common provider response issues before SDK validation. */ import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; -/** - * Tauri-compatible fetch function that wraps the Tauri HTTP plugin. - * Converts standard fetch parameters to Tauri's expected format. - * Internal only - used by createTimeoutFetch. - */ -async function tauriFetch( - input: RequestInfo | URL, - init?: RequestInit -): Promise { - const url = typeof input === 'string' ? input : input.toString(); - - // Convert headers from Headers object or array to Record if needed - let headers: Record = {}; - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.forEach((value, key) => { - headers[key] = value; - }); - } else if (Array.isArray(init.headers)) { - for (const [key, value] of init.headers) { - headers[key] = value; - } - } else { - headers = init.headers as Record; - } +function normalizeHeaders(headers: RequestInit['headers']): Record { + if (!headers) return {}; + if (headers instanceof Headers) { + const result: Record = {}; + headers.forEach((value, key) => { result[key] = value; }); + return result; } - - return tauriHttpFetch(url, { - method: init?.method ?? 'GET', - headers, - body: init?.body as string | undefined, - signal: init?.signal, - }); + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return headers as Record; } -/** - * Creates a fetch function with a built-in timeout. - * Uses AbortController to cancel requests that exceed the timeout. - * - * @param timeoutMs - Timeout in milliseconds (default: 180000 = 3 minutes) - * @returns A fetch function that automatically times out - */ -export function createTimeoutFetch(timeoutMs: number = 180000) { +async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return tauriHttpFetch( + typeof input === 'string' ? input : input.toString(), + { + method: init?.method ?? 'GET', + headers: normalizeHeaders(init?.headers), + body: init?.body as string | undefined, + signal: init?.signal, + } + ); +} + +function patchResponseJson(json: Record): Record { + if (!json.usage) { + json.usage = { input_tokens: 0, output_tokens: 0 }; + } else if (typeof json.usage === 'object') { + const usage = json.usage as Record; + usage.input_tokens ??= usage.prompt_tokens ?? 0; + usage.output_tokens ??= usage.completion_tokens ?? 0; + } + return json; +} + +export function createTimeoutFetch(timeoutMs = 180000) { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - // If the caller provided their own signal, chain it - if (init?.signal) { - init.signal.addEventListener('abort', () => controller.abort()); - } + init?.signal?.addEventListener('abort', () => controller.abort()); try { - return await tauriFetch(input, { ...init, signal: controller.signal }); + const response = await tauriFetch(input, { ...init, signal: controller.signal }); + + if (!response.headers.get('content-type')?.includes('application/json')) { + return response; + } + + const text = await response.text(); + try { + const json = JSON.parse(text); + const patched = JSON.stringify(patchResponseJson(json)); + return new Response(patched, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch { + return new Response(text, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } } finally { clearTimeout(timeoutId); } diff --git a/src/lib/services/ai/sdk/providers/registry.ts b/src/lib/services/ai/sdk/providers/registry.ts index b0cbc71..a78afa8 100644 --- a/src/lib/services/ai/sdk/providers/registry.ts +++ b/src/lib/services/ai/sdk/providers/registry.ts @@ -1,60 +1,31 @@ /** * Provider Registry * - * The single entry point for getting Vercel AI SDK providers. - * Supports explicit provider selection from APIProfile.providerType. + * Single entry point for creating Vercel AI SDK providers from APIProfile. */ import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createChutes } from '@chutes-ai/ai-sdk-provider'; import { createPollinations } from 'ai-sdk-pollinations'; + +import type { APIProfile, ProviderType } from '$lib/types'; import { createTimeoutFetch } from './fetch'; import { NANOGPT_API_URL } from './defaults'; -import type { APIProfile, ProviderType } from '$lib/types'; -/** Default HTTP timeout for API requests (3 minutes) */ const DEFAULT_TIMEOUT_MS = 180000; -/** - * Default base URLs for each provider. - * Used when profile.baseUrl is not specified. - */ const DEFAULT_BASE_URLS: Record = { openrouter: 'https://openrouter.ai/api/v1', - openai: undefined, // undefined = SDK default (api.openai.com) - also works for NIM, local LLMs, etc. - anthropic: undefined, // undefined = SDK default (api.anthropic.com) - google: undefined, // Google uses SDK default + openai: undefined, + anthropic: undefined, + google: undefined, nanogpt: NANOGPT_API_URL, - chutes: undefined, // SDK default - pollinations: undefined, // SDK default + chutes: undefined, + pollinations: undefined, }; -/** - * Create a Vercel AI SDK provider from an APIProfile. - * - * This is THE main function for getting a provider ready for any SDK function. - * The returned provider can be passed to generateText, streamText, tools, etc. - * - * All providers support optional custom baseUrl for: - * - Local LLMs (Ollama, LM Studio, etc.) - * - Azure OpenAI - * - Custom deployments - * - * @param profile - The API profile with providerType and credentials - * @returns A configured provider instance - * - * @example - * ```typescript - * const profile = settings.getProfile(profileId); - * const provider = createProviderFromProfile(profile); - * const model = provider('gpt-4o'); // All providers are callable - * - * const result = await generateText({ model, prompt: '...' }); - * ``` - */ export function createProviderFromProfile(profile: APIProfile) { const fetch = createTimeoutFetch(DEFAULT_TIMEOUT_MS); const baseURL = profile.baseUrl || DEFAULT_BASE_URLS[profile.providerType]; @@ -64,53 +35,34 @@ export function createProviderFromProfile(profile: APIProfile) { return createOpenRouter({ apiKey: profile.apiKey, baseURL: baseURL ?? 'https://openrouter.ai/api/v1', - headers: { - 'HTTP-Referer': 'https://aventura.camp', - 'X-Title': 'Aventura', - }, + headers: { 'HTTP-Referer': 'https://aventura.camp', 'X-Title': 'Aventura' }, fetch, }); case 'openai': - return createOpenAI({ - apiKey: profile.apiKey, - baseURL, // undefined = default OpenAI endpoint - fetch, - }); + return createOpenAI({ apiKey: profile.apiKey, baseURL, fetch }); case 'anthropic': - return createAnthropic({ - apiKey: profile.apiKey, - baseURL, - fetch, - }); + return createAnthropic({ apiKey: profile.apiKey, baseURL, fetch }); case 'google': - // Future: import { createGoogleGenerativeAI } from '@ai-sdk/google' throw new Error('Google provider not yet implemented'); case 'nanogpt': - // NanoGPT is OpenAI-compatible with custom base URL - return createOpenAICompatible({ + return createOpenAI({ name: 'nanogpt', apiKey: profile.apiKey, baseURL: baseURL ?? NANOGPT_API_URL, - supportsStructuredOutputs: true, fetch, }); case 'chutes': - return createChutes({ - apiKey: profile.apiKey, - }); + return createChutes({ apiKey: profile.apiKey }); case 'pollinations': - return createPollinations({ - apiKey: profile.apiKey || undefined, // Pollinations works without API key - }); + return createPollinations({ apiKey: profile.apiKey || undefined }); default: { - // TypeScript exhaustive check const _exhaustive: never = profile.providerType; throw new Error(`Unknown provider type: ${_exhaustive}`); }