diff --git a/package-lock.json b/package-lock.json index 3c504bc..925523c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,13 @@ "license": "AGPL-3.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.35", + "@ai-sdk/deepseek": "^2.0.17", + "@ai-sdk/google": "^3.0.20", + "@ai-sdk/groq": "^3.0.21", + "@ai-sdk/mistral": "^3.0.18", "@ai-sdk/openai": "^3.0.25", "@ai-sdk/openai-compatible": "^2.0.26", + "@ai-sdk/xai": "^3.0.46", "@chutes-ai/ai-sdk-provider": "^0.1.2", "@openrouter/ai-sdk-provider": "^2.1.1", "@tauri-apps/api": "^2", @@ -33,7 +38,9 @@ "jszip": "^3.10.1", "lucide-svelte": "^0.468.0", "marked": "^17.0.1", - "tailwind-merge": "^3.4.0" + "ollama-ai-provider": "^1.2.0", + "tailwind-merge": "^3.4.0", + "zhipu-ai-provider": "^0.2.2" }, "devDependencies": { "@lucide/svelte": "^0.482.0", @@ -73,6 +80,22 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/deepseek": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.17.tgz", + "integrity": "sha512-rkZiasQ24UyOMiZd8Mb7R+OF3Yt90bRQyfyzIkrb0zKZj7kU2h2z2nu1CO6j0X8poE+SZhEEaHOBFhRcp6hKVg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.7", + "@ai-sdk/provider-utils": "4.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/gateway": { "version": "3.0.32", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.32.tgz", @@ -90,6 +113,54 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/google": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.20.tgz", + "integrity": "sha512-bVGsulEr6JiipAFlclo9bjL5WaUV0iCSiiekLt+PY6pwmtJeuU2GaD9DoE3OqR8LN2W779mU13IhVEzlTupf8g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.7", + "@ai-sdk/provider-utils": "4.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.21.tgz", + "integrity": "sha512-sYTnGbvUNoDKTUa5BBKDvzSnjgahtEWriohdljtGBd4vgcimqY3XMXAqefOXEiYjON/GBFx6Q/YR3GVcX32Mcg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.7", + "@ai-sdk/provider-utils": "4.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.18.tgz", + "integrity": "sha512-k8nCBBVGOzBigNwBO5kREzsP/e+C3npcL7jt19ZdicIbZ6rvmnSIRI90iENyS9T10vM7sjrXoCpgZSYgJB2pJQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.7", + "@ai-sdk/provider-utils": "4.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/openai": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.25.tgz", @@ -151,6 +222,23 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/xai": { + "version": "3.0.46", + "resolved": "https://registry.npmjs.org/@ai-sdk/xai/-/xai-3.0.46.tgz", + "integrity": "sha512-26qM/jYcFhF5krTM7bQT1CiZcdz22EQmA+r5me1hKYFM/yM20sSUMHnAcUzvzuuG9oQVKF0tziU2IcC0HX5huQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.26", + "@ai-sdk/provider": "3.0.7", + "@ai-sdk/provider-utils": "4.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@chutes-ai/ai-sdk-provider": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@chutes-ai/ai-sdk-provider/-/ai-sdk-provider-0.1.2.tgz", @@ -2666,7 +2754,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2688,12 +2775,69 @@ "dev": true, "license": "MIT" }, + "node_modules/ollama-ai-provider": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", + "integrity": "sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "^1.0.0", + "@ai-sdk/provider-utils": "^2.0.0", + "partial-json": "0.1.7" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/ollama-ai-provider/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ollama-ai-provider/node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2864,6 +3008,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3280,6 +3430,48 @@ } } }, + "node_modules/zhipu-ai-provider": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/zhipu-ai-provider/-/zhipu-ai-provider-0.2.2.tgz", + "integrity": "sha512-UjX1ho4DI9ICUv/mrpAnzmrRe5/LXrGkS5hF6h4WDY2aup5GketWWopFzWYCqsbArXAM5wbzzdH9QzZusgGiBg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/zhipu-ai-provider/node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/zhipu-ai-provider/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.20.tgz", + "integrity": "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", @@ -3287,9 +3479,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index e64ef99..50020d1 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,13 @@ "license": "AGPL-3.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.35", + "@ai-sdk/deepseek": "^2.0.17", + "@ai-sdk/google": "^3.0.20", + "@ai-sdk/groq": "^3.0.21", + "@ai-sdk/mistral": "^3.0.18", "@ai-sdk/openai": "^3.0.25", "@ai-sdk/openai-compatible": "^2.0.26", + "@ai-sdk/xai": "^3.0.46", "@chutes-ai/ai-sdk-provider": "^0.1.2", "@openrouter/ai-sdk-provider": "^2.1.1", "@tauri-apps/api": "^2", @@ -37,7 +42,9 @@ "jszip": "^3.10.1", "lucide-svelte": "^0.468.0", "marked": "^17.0.1", - "tailwind-merge": "^3.4.0" + "ollama-ai-provider": "^1.2.0", + "tailwind-merge": "^3.4.0", + "zhipu-ai-provider": "^0.2.2" }, "devDependencies": { "@lucide/svelte": "^0.482.0", diff --git a/src/lib/components/settings/ProviderTypeSelector.svelte b/src/lib/components/settings/ProviderTypeSelector.svelte index 0c64aad..07b6e2f 100644 --- a/src/lib/components/settings/ProviderTypeSelector.svelte +++ b/src/lib/components/settings/ProviderTypeSelector.svelte @@ -1,5 +1,6 @@ @@ -78,16 +37,10 @@ {#each providers as provider} - + {provider.label} - {provider.description} + {provider.description} {/each} diff --git a/src/lib/components/settings/tabs/api-connection.svelte b/src/lib/components/settings/tabs/api-connection.svelte index 2f1dce7..fd161d5 100644 --- a/src/lib/components/settings/tabs/api-connection.svelte +++ b/src/lib/components/settings/tabs/api-connection.svelte @@ -2,6 +2,7 @@ import { settings } from "$lib/stores/settings.svelte"; import type { APIProfile, ProviderType } from "$lib/types"; import { fetchModelsFromProvider } from "$lib/services/ai/sdk/providers"; + import { PROVIDERS, hasDefaultEndpoint } from "$lib/services/ai/sdk/providers/config"; import ProviderTypeSelector from "$lib/components/settings/ProviderTypeSelector.svelte"; import { Plus, @@ -16,7 +17,6 @@ Box, AlertCircle, Star, - Zap, RotateCcw, Search, } from "lucide-svelte"; @@ -63,38 +63,6 @@ let modelFilterInput = $state(""); let showBaseUrlCollapsible = $state(false); - // Provider defaults for base URLs - const providerDefaults: Record = { - openrouter: "https://openrouter.ai/api/v1", - openai: "", - anthropic: "", - google: "", - nanogpt: "https://nano-gpt.com/api/v1", - chutes: "", - pollinations: "", - }; - - // Providers that have a built-in default API endpoint and can fetch models without a custom baseUrl - const providerHasDefaultEndpoint: Record = { - openrouter: true, - openai: false, - anthropic: true, - google: true, - nanogpt: true, - chutes: true, - pollinations: true, - }; - - const providerDisplayNames: Record = { - openrouter: "OpenRouter", - openai: "OpenAI Compatible", - anthropic: "Anthropic", - google: "Google AI", - nanogpt: "NanoGPT", - chutes: "Chutes", - pollinations: "Pollinations", - }; - // Auto-save debounce state let saveTimeout: ReturnType | null = null; @@ -295,21 +263,6 @@ } } - // Quick-fill presets for OpenAI-compatible endpoints - function quickFillOpenai() { - formName = "OpenAI"; - formBaseUrl = "https://api.openai.com/v1"; - } - - function quickFillNvidianim() { - formName = "NVIDIA NIM"; - formBaseUrl = "https://integrate.api.nvidia.com/v1"; - } - - function quickFillSelfHosted(port: string, name: string) { - formName = name; - formBaseUrl = `http://127.0.0.1:${port}/v1`; - } function handleOpenChange(open: boolean, profile: APIProfile) { if (open) { @@ -414,7 +367,7 @@ value={formProviderType} onchange={(v) => { formProviderType = v; - formName = providerDisplayNames[v]; + formName = PROVIDERS[v].name; formBaseUrl = ""; formFetchedModels = []; formCustomModels = []; @@ -426,58 +379,7 @@ }} /> - {#if formProviderType === "openai"} - - - - Quick fill: - - - - OpenAI - - - NVIDIA NIM - - quickFillSelfHosted("11434", "Ollama")} - class="text-xs h-8" - > - Ollama - - quickFillSelfHosted("1234", "LM Studio")} - class="text-xs h-8" - > - LM Studio - - quickFillSelfHosted("8080", "llama.cpp")} - class="text-xs h-8" - > - llama.cpp - - - - {/if} - - {#if formProviderType === "openai"} + {#if formProviderType === "openai-compatible"} Base URL (required) @@ -501,7 +403,7 @@ {#if showBaseUrlCollapsible || formBaseUrl} @@ -545,7 +447,7 @@ variant="outline" size="sm" onclick={handleFetchModels} - disabled={isFetchingModels || (!formBaseUrl && !providerHasDefaultEndpoint[formProviderType])} + disabled={isFetchingModels || (!formBaseUrl && !hasDefaultEndpoint(formProviderType))} > {#if isFetchingModels} @@ -624,7 +526,7 @@ > @@ -750,58 +652,7 @@ }} /> - {#if formProviderType === "openai"} - - - - Quick fill: - - - - OpenAI - - - NVIDIA NIM - - quickFillSelfHosted("11434", "Ollama")} - class="text-xs h-8" - > - Ollama - - quickFillSelfHosted("1234", "LM Studio")} - class="text-xs h-8" - > - LM Studio - - quickFillSelfHosted("8080", "llama.cpp")} - class="text-xs h-8" - > - llama.cpp - - - - {/if} - - {#if formProviderType === "openai"} + {#if formProviderType === "openai-compatible"} Base URL (required) @@ -824,7 +675,7 @@ {#if showBaseUrlCollapsible || formBaseUrl} @@ -863,7 +714,7 @@ variant="outline" size="sm" onclick={handleFetchModels} - disabled={isFetchingModels || (!formBaseUrl && !providerHasDefaultEndpoint[formProviderType])} + disabled={isFetchingModels || (!formBaseUrl && !hasDefaultEndpoint(formProviderType))} > {#if isFetchingModels} @@ -1043,7 +894,7 @@ Base URL - {profile.baseUrl || providerDefaults[profile.providerType] || "(default)"} + {profile.baseUrl || PROVIDERS[profile.providerType].baseUrl || "(default)"} diff --git a/src/lib/components/settings/tabs/images.svelte b/src/lib/components/settings/tabs/images.svelte index 836174c..c07bf0e 100644 --- a/src/lib/components/settings/tabs/images.svelte +++ b/src/lib/components/settings/tabs/images.svelte @@ -7,7 +7,7 @@ import { Slider } from "$lib/components/ui/slider"; import { RotateCcw } from "lucide-svelte"; import { listImageModels, clearModelsCache, type ImageModelInfo } from "$lib/services/ai/image/modelListing"; - import { PROVIDER_CAPABILITIES } from "$lib/services/ai/sdk/providers/defaults"; + import { PROVIDERS } from "$lib/services/ai/sdk/providers/config"; import ImageModelSelect from "$lib/components/settings/ImageModelSelect.svelte"; import type { APIProfile } from "$lib/types"; @@ -26,7 +26,7 @@ // Get profiles that support image generation function getImageCapableProfiles(): APIProfile[] { return settings.apiSettings.profiles.filter(p => - PROVIDER_CAPABILITIES[p.providerType]?.supportsImageGeneration + PROVIDERS[p.providerType]?.capabilities.imageGeneration ); } diff --git a/src/lib/services/ai/image/InlineImageService.ts b/src/lib/services/ai/image/InlineImageService.ts index e8fc991..d4bd0f9 100644 --- a/src/lib/services/ai/image/InlineImageService.ts +++ b/src/lib/services/ai/image/InlineImageService.ts @@ -13,7 +13,7 @@ import type { Character, EmbeddedImage } from '$lib/types'; import { generateImage as sdkGenerateImage } from '$lib/services/ai/sdk/generate'; -import { PROVIDER_CAPABILITIES } from '$lib/services/ai/sdk/providers/defaults'; +import { PROVIDERS } from '$lib/services/ai/sdk/providers/config'; import { database } from '$lib/services/database'; import { promptService } from '$lib/services/prompts'; import { settings } from '$lib/stores/settings.svelte'; @@ -50,7 +50,7 @@ export class InlineImageGenerationService { if (!profile) return false; // Check if provider supports image generation - const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; + const capabilities = PROVIDERS[profile.providerType].capabilities; return capabilities?.supportsImageGeneration ?? false; } diff --git a/src/lib/services/ai/image/InlineImageTracker.ts b/src/lib/services/ai/image/InlineImageTracker.ts index 92db964..b99a791 100644 --- a/src/lib/services/ai/image/InlineImageTracker.ts +++ b/src/lib/services/ai/image/InlineImageTracker.ts @@ -16,7 +16,7 @@ import { extractPicTags, type ParsedPicTag } from '$lib/utils/inlineImageParser'; import { generateImage as sdkGenerateImage } from '$lib/services/ai/sdk/generate'; -import { PROVIDER_CAPABILITIES } from '$lib/services/ai/sdk/providers/defaults'; +import { PROVIDERS } from '$lib/services/ai/sdk/providers/config'; import { database } from '$lib/services/database'; import { promptService } from '$lib/services/prompts'; import { settings } from '$lib/stores/settings.svelte'; @@ -122,7 +122,7 @@ export class InlineImageTracker { // Check if provider supports image generation const profile = settings.getProfile(profileId); if (!profile) return; - const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; + const capabilities = PROVIDERS[profile.providerType].capabilities; if (!capabilities?.supportsImageGeneration) return; // Build full prompt with style diff --git a/src/lib/services/ai/image/imageUtils.ts b/src/lib/services/ai/image/imageUtils.ts index f4938d9..a41cb41 100644 --- a/src/lib/services/ai/image/imageUtils.ts +++ b/src/lib/services/ai/image/imageUtils.ts @@ -7,7 +7,7 @@ import type { EmbeddedImage } from '$lib/types'; import { generateImage } from '$lib/services/ai/sdk/generate'; -import { PROVIDER_CAPABILITIES } from '$lib/services/ai/sdk/providers/defaults'; +import { PROVIDERS } from '$lib/services/ai/sdk/providers/config'; import { database } from '$lib/services/database'; import { settings } from '$lib/stores/settings.svelte'; import { emitImageReady, emitImageAnalysisFailed } from '$lib/services/events'; @@ -29,7 +29,7 @@ export function isImageGenerationEnabled(): boolean { const profile = settings.getProfile(profileId); if (!profile) return false; - const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; + const capabilities = PROVIDERS[profile.providerType].capabilities; return capabilities?.supportsImageGeneration ?? false; } @@ -46,7 +46,7 @@ export function hasRequiredCredentials(): boolean { if (!profile) return false; // Check if provider supports image generation - const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; + const capabilities = PROVIDERS[profile.providerType].capabilities; if (!capabilities?.supportsImageGeneration) return false; // All profile-based providers have credentials if the profile exists diff --git a/src/lib/services/ai/sdk/generate.ts b/src/lib/services/ai/sdk/generate.ts index 8eb58df..6be9a78 100644 --- a/src/lib/services/ai/sdk/generate.ts +++ b/src/lib/services/ai/sdk/generate.ts @@ -25,7 +25,7 @@ import { settings } from '$lib/stores/settings.svelte'; 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 { PROVIDERS } from './providers/config'; import { promptSchemaMiddleware, patchResponseMiddleware, loggingMiddleware } from './middleware'; const log = createLogger('Generate'); @@ -54,12 +54,22 @@ interface GenerateObjectOptions extends BaseGenerateOptions const PROVIDER_OPTIONS_KEY: Record = { openrouter: 'openrouter', - openai: 'openai', - anthropic: 'anthropic', - google: 'google', nanogpt: 'nanogpt', chutes: 'chutes', pollinations: 'pollinations', + ollama: 'ollama', + lmstudio: 'lmstudio', + llamacpp: 'llamacpp', + 'nvidia-nim': 'openai', + 'openai-compatible': 'openai', + openai: 'openai', + anthropic: 'anthropic', + google: 'google', + xai: 'xai', + groq: 'groq', + zhipu: 'zhipu', + deepseek: 'deepseek', + mistral: 'mistral', }; const ANTHROPIC_REASONING_BUDGETS: Record = { @@ -152,7 +162,7 @@ function resolveConfig(presetId: string): ResolvedConfig { const provider = createProviderFromProfile(profile); const model = provider(preset.model) as LanguageModelV3; - const capabilities = PROVIDER_CAPABILITIES[profile.providerType]; + const capabilities = PROVIDERS[profile.providerType].capabilities; return { preset, @@ -405,7 +415,7 @@ export async function generateImage(options: GenerateImageOptions): Promise = { + openrouter: { + name: 'OpenRouter', + description: 'Access 100+ models from one API', + baseUrl: 'https://openrouter.ai/api/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['anthropic/claude-sonnet-4', 'openai/gpt-4o', 'google/gemini-2.0-flash', 'deepseek/deepseek-chat', 'x-ai/grok-3'], + services: { + narrative: { model: 'anthropic/claude-sonnet-4', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'x-ai/grok-4.1-fast', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'high' }, + memory: { model: 'x-ai/grok-4.1-fast', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'high' }, + suggestions: { model: 'deepseek/deepseek-chat', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'anthropic/claude-sonnet-4', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'high' }, + wizard: { model: 'deepseek/deepseek-chat', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'deepseek/deepseek-chat', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + nanogpt: { + name: 'NanoGPT', + description: 'Subscription-Based LLMs and image generation', + baseUrl: 'https://nano-gpt.com/api/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: true, structuredOutput: false }, + imageDefaults: { defaultModel: 'z-image-turbo', referenceModel: 'qwen-image', supportedSizes: ['512x512', '1024x1024', '2048x2048'] }, + fallbackModels: ['deepseek-chat', 'claude-sonnet-4', 'gpt-4o', 'gemini-2.0-flash'], + services: { + narrative: { model: 'zai-or/glm-4.7', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'zai-org/glm-4.7', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'deepseek-chat', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'deepseek-chat', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + chutes: { + name: 'Chutes', + description: 'Text and image generation', + baseUrl: 'https://api.chutes.ai', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: true, structuredOutput: true }, + imageDefaults: { defaultModel: 'z-image-turbo', referenceModel: 'qwen-image-edit-2511', supportedSizes: ['576x576', '1024x1024', '2048x2048'] }, + fallbackModels: ['deepseek-ai/DeepSeek-V3-0324', 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8'], + services: { + narrative: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'deepseek-ai/DeepSeek-V3-0324', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + pollinations: { + name: 'Pollinations', + description: 'Free text and image generation (no API key needed)', + baseUrl: 'https://text.pollinations.ai/openai', + requiresApiKey: false, + capabilities: { textGeneration: true, imageGeneration: true, structuredOutput: false }, + imageDefaults: { defaultModel: 'flux', referenceModel: 'kontext', supportedSizes: ['512x512', '1024x1024', '2048x2048'] }, + fallbackModels: ['openai', 'mistral', 'llama'], + services: { + narrative: { model: 'openai', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'openai', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'openai', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'openai', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'openai', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'openai', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'openai', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + ollama: { + name: 'Ollama', + description: 'Run local LLMs (requires Ollama installed)', + baseUrl: 'http://localhost:11434', + requiresApiKey: false, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'qwen2.5', 'phi3', 'gemma2'], + services: { + narrative: { model: 'llama3.2', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'llama3.2', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'llama3.2', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'llama3.2', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'llama3.2', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'llama3.2', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'llama3.2', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + lmstudio: { + name: 'LM Studio', + description: 'Run local LLMs (requires LM Studio installed)', + baseUrl: 'http://localhost:1234/v1', + requiresApiKey: false, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: false }, + fallbackModels: ['loaded-model'], + services: { + narrative: { model: 'loaded-model', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'loaded-model', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'loaded-model', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'loaded-model', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'loaded-model', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'loaded-model', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'loaded-model', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + llamacpp: { + name: 'llama.cpp', + description: 'Run local LLMs (requires llama.cpp server)', + baseUrl: 'http://localhost:8080/v1', + requiresApiKey: false, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: false }, + fallbackModels: ['loaded-model'], + services: { + narrative: { model: 'loaded-model', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'loaded-model', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'loaded-model', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'loaded-model', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'loaded-model', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'loaded-model', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'loaded-model', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + 'nvidia-nim': { + name: 'NVIDIA NIM', + description: 'NVIDIA hosted inference microservices', + baseUrl: 'https://integrate.api.nvidia.com/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['meta/llama-3.1-70b-instruct', 'meta/llama-3.1-8b-instruct', 'nvidia/llama-3.1-nemotron-70b-instruct'], + services: { + narrative: { model: 'meta/llama-3.1-70b-instruct', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'meta/llama-3.1-8b-instruct', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'meta/llama-3.1-8b-instruct', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'meta/llama-3.1-8b-instruct', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'meta/llama-3.1-70b-instruct', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'meta/llama-3.1-8b-instruct', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'meta/llama-3.1-8b-instruct', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + 'openai-compatible': { + name: 'OpenAI Compatible', + description: 'Any OpenAI-compatible API (requires custom URL)', + baseUrl: '', // Requires custom baseUrl + requiresApiKey: false, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: false }, + fallbackModels: ['default'], + services: { + narrative: { model: 'default', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'default', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'default', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'default', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'default', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'default', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'default', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + openai: { + name: 'OpenAI', + description: 'GPT models from OpenAI', + baseUrl: '', // SDK default + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: true, structuredOutput: true }, + imageDefaults: { defaultModel: 'dall-e-3', referenceModel: 'dall-e-2', supportedSizes: ['1024x1024', '1024x1792', '1792x1024'] }, + fallbackModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo', 'o1', 'o1-mini'], + services: { + narrative: { model: 'gpt-4o', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'gpt-4o-mini', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'gpt-4o-mini', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'gpt-4o-mini', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'gpt-4o', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'gpt-4o-mini', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'gpt-4o-mini', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + anthropic: { + name: 'Anthropic', + description: 'Claude models', + baseUrl: '', // SDK default + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001', 'claude-sonnet-4-5-20250929', 'claude-opus-4-1-20250805', 'claude-sonnet-4-20250514', 'claude-opus-4-20250514'], + services: { + narrative: { model: 'claude-sonnet-4-20250514', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'claude-haiku-4-20250514', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'claude-haiku-4-20250514', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'claude-haiku-4-20250514', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'claude-sonnet-4-20250514', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'claude-haiku-4-20250514', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'claude-haiku-4-20250514', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + google: { + name: 'Google AI', + description: 'Gemini models', + baseUrl: '', // SDK default + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: true, structuredOutput: true }, + imageDefaults: { defaultModel: 'imagen-3.0-generate-002', referenceModel: 'imagen-3.0-generate-002', supportedSizes: ['512x512', '1024x1024'] }, + fallbackModels: ['gemini-3-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'], + services: { + narrative: { model: 'gemini-2.0-flash', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'gemini-2.0-flash', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'gemini-2.0-flash', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'gemini-2.0-flash', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'gemini-2.0-flash', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'gemini-2.0-flash', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'gemini-2.0-flash', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + xai: { + name: 'xAI (Grok)', + description: 'Grok models from xAI', + baseUrl: 'https://api.x.ai/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['grok-3', 'grok-3-fast', 'grok-2', 'grok-2-vision'], + services: { + narrative: { model: 'grok-3', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'grok-3-fast', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'grok-3-fast', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'grok-3-fast', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'grok-3', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'grok-3-fast', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'grok-3-fast', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + groq: { + name: 'Groq', + description: 'Ultra-fast inference for open models', + baseUrl: 'https://api.groq.com/openai/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'], + services: { + narrative: { model: 'llama-3.3-70b-versatile', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'llama-3.1-8b-instant', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'llama-3.1-8b-instant', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'llama-3.1-8b-instant', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'llama-3.3-70b-versatile', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'llama-3.1-8b-instant', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'llama-3.1-8b-instant', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + zhipu: { + name: 'Zhipu AI', + description: 'GLM models (Chinese AI provider)', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: true, structuredOutput: true }, + imageDefaults: { defaultModel: 'cogview-3-plus', referenceModel: 'cogview-3', supportedSizes: ['512x512', '1024x1024'] }, + fallbackModels: ['glm-4-plus', 'glm-4-flash', 'glm-4-air', 'glm-4v', 'glm-4v-plus', 'cogview-3-plus'], + services: { + narrative: { model: 'glm-4-plus', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'glm-4-flash', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'glm-4-flash', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'glm-4-flash', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'glm-4-plus', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'glm-4-flash', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'glm-4-flash', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + deepseek: { + name: 'DeepSeek', + description: 'Cost-effective reasoning models', + baseUrl: 'https://api.deepseek.com/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['deepseek-chat', 'deepseek-reasoner'], + services: { + narrative: { model: 'deepseek-chat', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'deepseek-chat', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'deepseek-chat', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'deepseek-chat', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, + + mistral: { + name: 'Mistral', + description: 'European AI provider with strong coding models', + baseUrl: 'https://api.mistral.ai/v1', + requiresApiKey: true, + capabilities: { textGeneration: true, imageGeneration: false, structuredOutput: true }, + fallbackModels: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest', 'pixtral-large-latest', 'ministral-8b-latest', 'ministral-3b-latest'], + services: { + narrative: { model: 'mistral-large-latest', temperature: 0.8, maxTokens: 8192, reasoningEffort: 'off' }, + classification: { model: 'mistral-small-latest', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + memory: { model: 'mistral-small-latest', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + suggestions: { model: 'mistral-small-latest', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + agentic: { model: 'mistral-large-latest', temperature: 0.3, maxTokens: 8192, reasoningEffort: 'off' }, + wizard: { model: 'mistral-small-latest', temperature: 0.7, maxTokens: 8192, reasoningEffort: 'off' }, + translation: { model: 'mistral-small-latest', temperature: 0.3, maxTokens: 4096, reasoningEffort: 'off' }, + }, + }, +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** Get the base URL for a provider, or undefined if SDK default should be used */ +export function getBaseUrl(providerType: ProviderType): string | undefined { + const url = PROVIDERS[providerType].baseUrl; + return url || undefined; +} + +/** Check if a provider has a default endpoint (doesn't require custom URL) */ +export function hasDefaultEndpoint(providerType: ProviderType): boolean { + return providerType !== 'openai-compatible'; +} + +/** Get all providers as a list for UI dropdowns */ +export function getProviderList(): Array<{ value: ProviderType; label: string; description: string }> { + return (Object.keys(PROVIDERS) as ProviderType[]).map((key) => ({ + value: key, + label: PROVIDERS[key].name, + description: PROVIDERS[key].description, + })); +} diff --git a/src/lib/services/ai/sdk/providers/defaults.ts b/src/lib/services/ai/sdk/providers/defaults.ts deleted file mode 100644 index e3d091c..0000000 --- a/src/lib/services/ai/sdk/providers/defaults.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Provider Defaults Configuration - * - * Defines default models and settings for each provider type. - */ - -import type { ProviderType, ReasoningEffort } from '$lib/types'; - -// ============================================================================ -// API URLs -// ============================================================================ - -export const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1'; -export const NANOGPT_API_URL = 'https://nano-gpt.com/api/v1'; - -// ============================================================================ -// Provider Capabilities -// ============================================================================ - -export interface ProviderCapabilities { - supportsTextGeneration: boolean; - supportsImageGeneration: boolean; - supportsStructuredOutput: boolean; -} - -export const PROVIDER_CAPABILITIES: Record = { - 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 -// ============================================================================ - -export interface ImageModelDefaults { - defaultModel: string; - referenceModel: string; - supportedSizes: string[]; -} - -export const IMAGE_MODEL_DEFAULTS: Partial> = { - openai: { - defaultModel: 'dall-e-3', - referenceModel: 'dall-e-2', // DALL-E 2 supports image editing - supportedSizes: ['1024x1024', '1024x1792', '1792x1024'], - }, - google: { - defaultModel: 'imagen-3.0-generate-002', - referenceModel: 'imagen-3.0-generate-002', - supportedSizes: ['512x512', '1024x1024'], - }, - nanogpt: { - defaultModel: 'z-image-turbo', - referenceModel: 'qwen-image', - supportedSizes: ['512x512', '1024x1024', '2048x2048'], - }, - chutes: { - defaultModel: 'z-image-turbo', - referenceModel: 'qwen-image-edit-2511', - supportedSizes: ['576x576', '1024x1024', '2048x2048'], - }, - pollinations: { - defaultModel: 'flux', - referenceModel: 'kontext', - supportedSizes: ['512x512', '1024x1024', '2048x2048'], - }, -}; - -// ============================================================================ -// Service Defaults -// ============================================================================ - -export interface ServiceModelDefaults { - model: string; - temperature: number; - maxTokens: number; - reasoningEffort: ReasoningEffort; -} - -export interface ProviderDefaults { - name: string; - baseUrl: string; - narrative: ServiceModelDefaults; - classification: ServiceModelDefaults; - memory: ServiceModelDefaults; - suggestions: ServiceModelDefaults; - agentic: ServiceModelDefaults; - wizard: ServiceModelDefaults; - translation: ServiceModelDefaults; -} - -export const PROVIDER_DEFAULTS: Record = { - openrouter: { - name: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - narrative: { - model: 'anthropic/claude-sonnet-4', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'x-ai/grok-4.1-fast', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'high', - }, - memory: { - model: 'x-ai/grok-4.1-fast', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'high', - }, - suggestions: { - model: 'deepseek/deepseek-chat', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'anthropic/claude-sonnet-4', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'high', - }, - wizard: { - model: 'deepseek/deepseek-chat', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'deepseek/deepseek-chat', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, - - openai: { - name: 'OpenAI', - baseUrl: '', // SDK default - narrative: { - model: 'gpt-4o', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'gpt-4o-mini', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - memory: { - model: 'gpt-4o-mini', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - suggestions: { - model: 'gpt-4o-mini', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'gpt-4o', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - wizard: { - model: 'gpt-4o-mini', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'gpt-4o-mini', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, - - anthropic: { - name: 'Anthropic', - baseUrl: '', // SDK default - narrative: { - model: 'claude-sonnet-4-20250514', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'claude-haiku-4-20250514', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - memory: { - model: 'claude-haiku-4-20250514', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - suggestions: { - model: 'claude-haiku-4-20250514', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'claude-sonnet-4-20250514', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - wizard: { - model: 'claude-haiku-4-20250514', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'claude-haiku-4-20250514', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, - - google: { - name: 'Google AI', - baseUrl: '', // SDK default - narrative: { - model: 'gemini-2.0-flash', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'gemini-2.0-flash', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - memory: { - model: 'gemini-2.0-flash', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - suggestions: { - model: 'gemini-2.0-flash', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'gemini-2.0-flash', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - wizard: { - model: 'gemini-2.0-flash', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'gemini-2.0-flash', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, - - nanogpt: { - name: 'NanoGPT', - baseUrl: 'https://nano-gpt.com/api/v1', - narrative: { - model: 'deepseek-chat', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'deepseek-chat', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - memory: { - model: 'deepseek-chat', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - suggestions: { - model: 'deepseek-chat', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'deepseek-chat', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - wizard: { - model: 'deepseek-chat', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'deepseek-chat', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, - - chutes: { - name: 'Chutes', - baseUrl: '', // SDK default - narrative: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - memory: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - suggestions: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - wizard: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'deepseek-ai/DeepSeek-V3-0324', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, - - pollinations: { - name: 'Pollinations', - baseUrl: '', // SDK default - narrative: { - model: 'openai', - temperature: 0.8, - maxTokens: 8192, - reasoningEffort: 'off', - }, - classification: { - model: 'openai', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - memory: { - model: 'openai', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - suggestions: { - model: 'openai', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - agentic: { - model: 'openai', - temperature: 0.3, - maxTokens: 8192, - reasoningEffort: 'off', - }, - wizard: { - model: 'openai', - temperature: 0.7, - maxTokens: 8192, - reasoningEffort: 'off', - }, - translation: { - model: 'openai', - temperature: 0.3, - maxTokens: 4096, - reasoningEffort: 'off', - }, - }, -}; diff --git a/src/lib/services/ai/sdk/providers/index.ts b/src/lib/services/ai/sdk/providers/index.ts index a37b36a..b5a08d2 100644 --- a/src/lib/services/ai/sdk/providers/index.ts +++ b/src/lib/services/ai/sdk/providers/index.ts @@ -5,7 +5,15 @@ */ export { createProviderFromProfile } from './registry'; -export { PROVIDER_DEFAULTS } from './defaults'; export { fetchModelsFromProvider } from './modelFetcher'; -export type { ProviderDefaults, ServiceModelDefaults } from './defaults'; +export { + PROVIDERS, + getBaseUrl, + hasDefaultEndpoint, + getProviderList, + type ProviderConfig, + type ServiceModelDefaults, + type ProviderCapabilities, + type ImageDefaults, +} from './config'; export type { ProviderType, APIProfile } from '$lib/types'; diff --git a/src/lib/services/ai/sdk/providers/modelFetcher.ts b/src/lib/services/ai/sdk/providers/modelFetcher.ts index aab3c28..ec3593a 100644 --- a/src/lib/services/ai/sdk/providers/modelFetcher.ts +++ b/src/lib/services/ai/sdk/providers/modelFetcher.ts @@ -1,218 +1,103 @@ /** * Model Fetcher * - * Fetches available models from AI providers using SDK-compatible fetch infrastructure. - * Uses the same Tauri-compatible fetch that Vercel AI SDK providers use internally. + * Fetches available models from AI providers. */ import { createTimeoutFetch } from './fetch'; +import { PROVIDERS, getBaseUrl } from './config'; import type { ProviderType } from '$lib/types'; -/** - * Default base URLs for model fetching endpoints. - * Used when baseUrl is not explicitly provided. - */ -const DEFAULT_BASE_URLS: Record = { - openrouter: 'https://openrouter.ai/api/v1', - openai: 'https://api.openai.com/v1', - anthropic: 'https://api.anthropic.com/v1', - google: 'https://generativelanguage.googleapis.com/v1beta', - nanogpt: 'https://nano-gpt.com/api/v1', - chutes: 'https://api.chutes.ai', - pollinations: 'https://gen.pollinations.ai/v1', -}; +/** URLs that don't require authentication for model fetching */ +const NO_AUTH_PATTERNS = ['nano-gpt.com', 'gen.pollinations.ai', '127.0.0.1', 'localhost']; /** - * Fallback model list for Anthropic, used when the /v1/models endpoint fails. - */ -const ANTHROPIC_FALLBACK_MODELS = [ - 'claude-opus-4-5-20251101', - 'claude-haiku-4-5-20251001', - 'claude-sonnet-4-5-20250929', - 'claude-opus-4-1-20250805', - 'claude-sonnet-4-20250514', - 'claude-opus-4-20250514', -]; - -/** - * URLs that don't require authentication for model fetching. - * Used for OpenAI-compatible providers that have public /models endpoints. - */ -const NO_AUTH_PATTERNS = [ - 'integrate.api.nvidia.com', // NVIDIA NIM - 'nano-gpt.com', // NanoGPT - 'gen.pollinations.ai', // Pollinations - '127.0.0.1', // Self-hosted (Ollama, LM Studio, llama.cpp) - 'localhost', // Self-hosted alternative -]; - -/** - * Fetches available models from a provider using the SDK's fetch infrastructure. - * - * This function uses createTimeoutFetch() which wraps Tauri's HTTP plugin, - * making it compatible with Tauri's sandboxed environment and consistent - * with how the Vercel AI SDK providers make their API calls. - * - * @param providerType - The provider to fetch models from - * @param baseUrl - Optional custom base URL (for local LLMs, custom deployments, etc.) - * @param apiKey - Optional API key for authentication (required for some providers) - * @returns Array of model IDs available from the provider - * @throws Error if the provider doesn't support model fetching or if the request fails - * - * @example - * ```typescript - * // Fetch OpenRouter models - * const models = await fetchModelsFromProvider('openrouter'); - * - * // Fetch OpenAI models with API key - * const models = await fetchModelsFromProvider('openai', undefined, apiKey); - * - * // Fetch from local Ollama instance - * const models = await fetchModelsFromProvider('openai', 'http://localhost:11434/v1'); - * ``` + * Fetches available models from a provider. */ export async function fetchModelsFromProvider( providerType: ProviderType, baseUrl?: string, apiKey?: string ): Promise { - // Google uses a different endpoint format with API key as query param - if (providerType === 'google') { - return fetchGoogleModels(baseUrl, apiKey); - } + // Provider-specific fetch logic + if (providerType === 'google') return fetchGoogleModels(baseUrl, apiKey); + if (providerType === 'anthropic') return fetchAnthropicModels(baseUrl, apiKey); + if (providerType === 'chutes') return fetchChutesModels(baseUrl, apiKey); + if (providerType === 'ollama') return fetchOllamaModels(baseUrl); + if (providerType === 'zhipu') return fetchZhipuModels(baseUrl, apiKey); + if (providerType === 'mistral') return fetchMistralModels(baseUrl, apiKey); - // Anthropic uses a different auth header and response format - if (providerType === 'anthropic') { - return fetchAnthropicModels(baseUrl, apiKey); - } - - // Chutes uses a different endpoint format - if (providerType === 'chutes') { - return fetchChutesModels(baseUrl, apiKey); - } - - // Determine effective base URL - const effectiveBaseUrl = baseUrl || DEFAULT_BASE_URLS[providerType]; + // Standard OpenAI-compatible endpoint + const effectiveBaseUrl = baseUrl || getBaseUrl(providerType); if (!effectiveBaseUrl) { throw new Error(`No base URL available for provider: ${providerType}`); } - // Check if this URL requires authentication for model fetching - const requiresAuth = !NO_AUTH_PATTERNS.some(pattern => - effectiveBaseUrl.toLowerCase().includes(pattern) - ); - - // Create SDK-compatible fetch with 30s timeout (shorter than default for model fetching) + const requiresAuth = !NO_AUTH_PATTERNS.some((p) => effectiveBaseUrl.toLowerCase().includes(p)); const fetch = createTimeoutFetch(30000); const modelsUrl = effectiveBaseUrl.replace(/\/$/, '') + '/models'; - // Make the request using SDK's Tauri-compatible fetch - // Only include auth header if required AND apiKey is provided const response = await fetch(modelsUrl, { method: 'GET', - headers: (requiresAuth && apiKey) ? { Authorization: `Bearer ${apiKey}` } : {}, + headers: requiresAuth && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, }); if (!response.ok) { - throw new Error( - `Failed to fetch models: ${response.status} ${response.statusText}` - ); + throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`); } const data = await response.json(); - // Handle OpenAI/OpenRouter format: { data: [{ id: "..." }] } if (data.data && Array.isArray(data.data)) { return data.data.map((m: { id: string }) => m.id); } - - // Handle alternative format: [{ id: "...", name: "..." }] if (Array.isArray(data)) { - return data - .map((m: { id?: string; name?: string }) => m.id || m.name || '') - .filter(Boolean); + return data.map((m: { id?: string; name?: string }) => m.id || m.name || '').filter(Boolean); } throw new Error('Unexpected API response format'); } -/** - * Fetches models from the Anthropic API. - * Anthropic uses `x-api-key` header and a different response format: { data: [{ id, type }] } - * Falls back to a curated static list if the API call fails. - */ async function fetchAnthropicModels(baseUrl?: string, apiKey?: string): Promise { - const effectiveBaseUrl = baseUrl || DEFAULT_BASE_URLS.anthropic!; + const effectiveBaseUrl = baseUrl || 'https://api.anthropic.com/v1'; const modelsUrl = effectiveBaseUrl.replace(/\/$/, '') + '/models'; try { const fetchFn = createTimeoutFetch(30000); - const headers: Record = { - 'anthropic-version': '2023-06-01', - }; - if (apiKey) { - headers['x-api-key'] = apiKey; - } + const headers: Record = { 'anthropic-version': '2023-06-01' }; + if (apiKey) headers['x-api-key'] = apiKey; - const response = await fetchFn(modelsUrl, { - method: 'GET', - headers, - }); + const response = await fetchFn(modelsUrl, { method: 'GET', headers }); if (!response.ok) { console.warn(`[ModelFetcher] Anthropic API returned ${response.status}, using fallback models`); - return ANTHROPIC_FALLBACK_MODELS; + return PROVIDERS.anthropic.fallbackModels; } const data = await response.json(); - if (data.data && Array.isArray(data.data)) { - const models = data.data - .map((m: { id: string }) => m.id) - .filter(Boolean); + const models = data.data.map((m: { id: string }) => m.id).filter(Boolean); if (models.length > 0) return models; } - console.warn('[ModelFetcher] Unexpected Anthropic response format, using fallback models'); - return ANTHROPIC_FALLBACK_MODELS; + return PROVIDERS.anthropic.fallbackModels; } catch (error) { - console.warn('[ModelFetcher] Failed to fetch Anthropic models, using fallback list:', error); - return ANTHROPIC_FALLBACK_MODELS; + console.warn('[ModelFetcher] Failed to fetch Anthropic models:', error); + return PROVIDERS.anthropic.fallbackModels; } } -/** - * Fallback model list for Google, used when the API call fails. - */ -const GOOGLE_FALLBACK_MODELS = [ - 'gemini-3-pro-preview', - 'gemini-3-flash-preview', - 'gemini-2.5-pro', - 'gemini-2.5-flash', - 'gemini-2.5-flash-lite', -]; - -/** - * Google AI model entry from the /v1beta/models endpoint. - */ interface GoogleModelEntry { name: string; - displayName?: string; supportedGenerationMethods?: string[]; } -/** - * Fetches models from Google AI API. - * Google uses API key as a query param and a `{ models: [...] }` response format. - * Only returns models that support `generateContent`. - * Falls back to a curated static list if the API call fails. - */ async function fetchGoogleModels(baseUrl?: string, apiKey?: string): Promise { - const effectiveBaseUrl = baseUrl || DEFAULT_BASE_URLS.google!; + const effectiveBaseUrl = baseUrl || 'https://generativelanguage.googleapis.com/v1beta'; if (!apiKey) { - console.warn('[ModelFetcher] Google API key required for model fetching, using fallback models'); - return GOOGLE_FALLBACK_MODELS; + console.warn('[ModelFetcher] Google API key required, using fallback models'); + return PROVIDERS.google.fallbackModels; } const modelsUrl = effectiveBaseUrl.replace(/\/$/, '') + '/models?key=' + apiKey; @@ -223,11 +108,10 @@ async function fetchGoogleModels(baseUrl?: string, apiKey?: string): Promise m.supportedGenerationMethods?.includes('generateContent')) @@ -236,24 +120,19 @@ async function fetchGoogleModels(baseUrl?: string, apiKey?: string): Promise 0) return models; } - console.warn('[ModelFetcher] Unexpected Google response format, using fallback models'); - return GOOGLE_FALLBACK_MODELS; + return PROVIDERS.google.fallbackModels; } catch (error) { - console.warn('[ModelFetcher] Failed to fetch Google models, using fallback list:', error); - return GOOGLE_FALLBACK_MODELS; + console.warn('[ModelFetcher] Failed to fetch Google models:', error); + return PROVIDERS.google.fallbackModels; } } -/** - * Fetches models from the Chutes API. - * Chutes uses a `/chutes` endpoint with Bearer auth and returns `{ data: [{ name }] }`. - */ async function fetchChutesModels(baseUrl?: string, apiKey?: string): Promise { if (!apiKey) { throw new Error('Chutes requires an API key to fetch models'); } - const effectiveBaseUrl = baseUrl || 'https://api.chutes.ai'; + const effectiveBaseUrl = baseUrl || PROVIDERS.chutes.baseUrl; const chutesUrl = effectiveBaseUrl.replace(/\/$/, '') + '/chutes'; const fetchFn = createTimeoutFetch(30000); @@ -267,18 +146,106 @@ async function fetchChutesModels(baseUrl?: string, apiKey?: string): Promise m.id || m.name || '') - .filter(Boolean); + return data.data.map((m: { id?: string; name?: string }) => m.id || m.name || '').filter(Boolean); } - if (Array.isArray(data)) { - return data - .map((m: { id?: string; name?: string }) => m.id || m.name || '') - .filter(Boolean); + return data.map((m: { id?: string; name?: string }) => m.id || m.name || '').filter(Boolean); } throw new Error('Unexpected Chutes API response format'); } + +async function fetchOllamaModels(baseUrl?: string): Promise { + const effectiveBaseUrl = baseUrl || PROVIDERS.ollama.baseUrl; + const tagsUrl = effectiveBaseUrl.replace(/\/$/, '').replace(/\/api$/, '') + '/api/tags'; + + try { + const fetchFn = createTimeoutFetch(10000); + const response = await fetchFn(tagsUrl, { method: 'GET' }); + + if (!response.ok) { + console.warn(`[ModelFetcher] Ollama returned ${response.status}, using fallback models`); + return PROVIDERS.ollama.fallbackModels; + } + + const data = await response.json(); + if (data.models && Array.isArray(data.models)) { + const models = data.models.map((m: { name?: string; model?: string }) => m.name || m.model || '').filter(Boolean); + if (models.length > 0) return models; + } + + return PROVIDERS.ollama.fallbackModels; + } catch (error) { + console.warn('[ModelFetcher] Failed to fetch Ollama models (is Ollama running?):', error); + return PROVIDERS.ollama.fallbackModels; + } +} + +async function fetchZhipuModels(baseUrl?: string, apiKey?: string): Promise { + if (!apiKey) { + console.warn('[ModelFetcher] Zhipu API key required, using fallback models'); + return PROVIDERS.zhipu.fallbackModels; + } + + const effectiveBaseUrl = baseUrl || PROVIDERS.zhipu.baseUrl; + const modelsUrl = effectiveBaseUrl.replace(/\/$/, '') + '/models'; + + try { + const fetchFn = createTimeoutFetch(30000); + const response = await fetchFn(modelsUrl, { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + console.warn(`[ModelFetcher] Zhipu API returned ${response.status}, using fallback models`); + return PROVIDERS.zhipu.fallbackModels; + } + + const data = await response.json(); + if (data.data && Array.isArray(data.data)) { + const models = data.data.map((m: { id?: string }) => m.id || '').filter(Boolean); + if (models.length > 0) return models; + } + + return PROVIDERS.zhipu.fallbackModels; + } catch (error) { + console.warn('[ModelFetcher] Failed to fetch Zhipu models:', error); + return PROVIDERS.zhipu.fallbackModels; + } +} + +async function fetchMistralModels(baseUrl?: string, apiKey?: string): Promise { + if (!apiKey) { + console.warn('[ModelFetcher] Mistral API key required, using fallback models'); + return PROVIDERS.mistral.fallbackModels; + } + + const effectiveBaseUrl = baseUrl || PROVIDERS.mistral.baseUrl; + const modelsUrl = effectiveBaseUrl.replace(/\/$/, '') + '/models'; + + try { + const fetchFn = createTimeoutFetch(30000); + const response = await fetchFn(modelsUrl, { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + console.warn(`[ModelFetcher] Mistral API returned ${response.status}, using fallback models`); + return PROVIDERS.mistral.fallbackModels; + } + + const data = await response.json(); + if (data.data && Array.isArray(data.data)) { + const models = data.data.map((m: { id?: string }) => m.id || '').filter(Boolean); + if (models.length > 0) return models; + } + + return PROVIDERS.mistral.fallbackModels; + } catch (error) { + console.warn('[ModelFetcher] Failed to fetch Mistral models:', error); + return PROVIDERS.mistral.fallbackModels; + } +} diff --git a/src/lib/services/ai/sdk/providers/registry.ts b/src/lib/services/ai/sdk/providers/registry.ts index a78afa8..e7f35a8 100644 --- a/src/lib/services/ai/sdk/providers/registry.ts +++ b/src/lib/services/ai/sdk/providers/registry.ts @@ -1,40 +1,37 @@ /** * Provider Registry * - * Single entry point for creating Vercel AI SDK providers from APIProfile. + * Creates Vercel AI SDK providers from APIProfile. */ import { createOpenAI } from '@ai-sdk/openai'; import { createAnthropic } from '@ai-sdk/anthropic'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createChutes } from '@chutes-ai/ai-sdk-provider'; import { createPollinations } from 'ai-sdk-pollinations'; +import { createOllama } from 'ollama-ai-provider'; +import { createXai } from '@ai-sdk/xai'; +import { createGroq } from '@ai-sdk/groq'; +import { createZhipu } from 'zhipu-ai-provider'; +import { createDeepSeek } from '@ai-sdk/deepseek'; +import { createMistral } from '@ai-sdk/mistral'; -import type { APIProfile, ProviderType } from '$lib/types'; +import type { APIProfile } from '$lib/types'; import { createTimeoutFetch } from './fetch'; -import { NANOGPT_API_URL } from './defaults'; +import { PROVIDERS, getBaseUrl } from './config'; const DEFAULT_TIMEOUT_MS = 180000; -const DEFAULT_BASE_URLS: Record = { - openrouter: 'https://openrouter.ai/api/v1', - openai: undefined, - anthropic: undefined, - google: undefined, - nanogpt: NANOGPT_API_URL, - chutes: undefined, - pollinations: undefined, -}; - export function createProviderFromProfile(profile: APIProfile) { const fetch = createTimeoutFetch(DEFAULT_TIMEOUT_MS); - const baseURL = profile.baseUrl || DEFAULT_BASE_URLS[profile.providerType]; + const baseURL = profile.baseUrl || getBaseUrl(profile.providerType); switch (profile.providerType) { case 'openrouter': return createOpenRouter({ apiKey: profile.apiKey, - baseURL: baseURL ?? 'https://openrouter.ai/api/v1', + baseURL: baseURL ?? PROVIDERS.openrouter.baseUrl, headers: { 'HTTP-Referer': 'https://aventura.camp', 'X-Title': 'Aventura' }, fetch, }); @@ -46,13 +43,13 @@ export function createProviderFromProfile(profile: APIProfile) { return createAnthropic({ apiKey: profile.apiKey, baseURL, fetch }); case 'google': - throw new Error('Google provider not yet implemented'); + return createGoogleGenerativeAI({ apiKey: profile.apiKey, baseURL, fetch }); case 'nanogpt': return createOpenAI({ name: 'nanogpt', apiKey: profile.apiKey, - baseURL: baseURL ?? NANOGPT_API_URL, + baseURL: baseURL ?? PROVIDERS.nanogpt.baseUrl, fetch, }); @@ -62,6 +59,59 @@ export function createProviderFromProfile(profile: APIProfile) { case 'pollinations': return createPollinations({ apiKey: profile.apiKey || undefined }); + case 'ollama': + return createOllama({ baseURL: baseURL ?? PROVIDERS.ollama.baseUrl }); + + case 'lmstudio': + return createOpenAI({ + name: 'lmstudio', + apiKey: profile.apiKey || 'lm-studio', + baseURL: baseURL ?? PROVIDERS.lmstudio.baseUrl, + fetch, + }); + + case 'llamacpp': + return createOpenAI({ + name: 'llamacpp', + apiKey: profile.apiKey || 'llamacpp', + baseURL: baseURL ?? PROVIDERS.llamacpp.baseUrl, + fetch, + }); + + case 'nvidia-nim': + return createOpenAI({ + name: 'nvidia-nim', + apiKey: profile.apiKey, + baseURL: baseURL ?? PROVIDERS['nvidia-nim'].baseUrl, + fetch, + }); + + case 'openai-compatible': + if (!baseURL) { + throw new Error('OpenAI-compatible provider requires a custom base URL'); + } + return createOpenAI({ + name: 'openai-compatible', + apiKey: profile.apiKey, + baseURL, + fetch, + }); + + case 'xai': + return createXai({ apiKey: profile.apiKey, baseURL, fetch }); + + case 'groq': + return createGroq({ apiKey: profile.apiKey, baseURL, fetch }); + + case 'zhipu': + return createZhipu({ apiKey: profile.apiKey, baseURL, fetch }); + + case 'deepseek': + return createDeepSeek({ apiKey: profile.apiKey, baseURL, fetch }); + + case 'mistral': + return createMistral({ apiKey: profile.apiKey, baseURL, fetch }); + default: { const _exhaustive: never = profile.providerType; throw new Error(`Unknown provider type: ${_exhaustive}`); diff --git a/src/lib/services/ai/utils/TTSService.ts b/src/lib/services/ai/utils/TTSService.ts index 19b8185..deb2a71 100644 --- a/src/lib/services/ai/utils/TTSService.ts +++ b/src/lib/services/ai/utils/TTSService.ts @@ -4,7 +4,7 @@ * Designed for extensibility to support multiple TTS providers. */ -import { OPENROUTER_API_URL } from '../sdk/providers/defaults'; +import { PROVIDERS } from '../sdk/providers/config'; import type { APIProfile } from "$lib/types"; import { corsFetch } from "$lib/services/discovery/utils"; @@ -319,7 +319,7 @@ export class OpenAICompatibleTTSProvider extends TTSProvider { private getEndpoint(): string { // If no custom endpoint, use OpenRouter default if (!this.settings.endpoint) { - return `${OPENROUTER_API_URL}/audio/speech`; + return `${PROVIDERS.openrouter.baseUrl}/audio/speech`; } // Ensure endpoint ends with /audio/speech const url = this.settings.endpoint.replace(/\/$/, ""); diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 837ab89..442ae49 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -5,7 +5,7 @@ import { getDefaultAdvancedSettings, getDefaultAdvancedSettingsForProvider, } from '$lib/services/ai/wizard/ScenarioService'; -import { PROVIDER_DEFAULTS, OPENROUTER_API_URL } from '$lib/services/ai/sdk/providers/defaults'; +import { PROVIDERS } from '$lib/services/ai/sdk/providers/config'; import { promptService, type PromptSettings, getDefaultPromptSettings } from '$lib/services/prompts'; import type { ReasoningEffort } from '$lib/types'; import { ui } from '$lib/stores/ui.svelte'; @@ -19,9 +19,6 @@ export type ProviderPreset = 'openrouter' | 'nanogpt' | 'custom'; export const DEFAULT_OPENROUTER_PROFILE_ID = 'default-openrouter-profile'; export const DEFAULT_NANOGPT_PROFILE_ID = 'default-nanogpt-profile'; -// Provider URLs -export const NANOGPT_API_URL = 'https://nano-gpt.com/api/v1'; - // NOTE: Default story prompts are now in the centralized prompt system at // src/lib/services/prompts/definitions.ts (template ids: 'adventure', 'creative-writing') // The prompt fields in StoryGenerationSettings are kept for backwards compatibility @@ -919,11 +916,11 @@ export function getDefaultSystemServicesSettingsForProvider(provider: ProviderTy /** * Get default generation presets (Agent Profiles) for a specific provider. - * Uses PROVIDER_DEFAULTS from the SDK for model and settings defaults. + * Uses PROVIDERS from the SDK for model and settings defaults. * @param provider - The provider type to get defaults for */ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): GenerationPreset[] { - const defaults = PROVIDER_DEFAULTS[provider]; + const defaults = PROVIDERS[provider]; return [ { @@ -931,10 +928,10 @@ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): name: 'Classification', description: 'World state, lorebook parsing, entity extraction', profileId: null, - model: defaults.classification.model, - temperature: defaults.classification.temperature, - maxTokens: defaults.classification.maxTokens, - reasoningEffort: defaults.classification.reasoningEffort, + model: defaults.services.classification.model, + temperature: defaults.services.classification.temperature, + maxTokens: defaults.services.classification.maxTokens, + reasoningEffort: defaults.services.classification.reasoningEffort, manualBody: '' }, { @@ -942,10 +939,10 @@ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): name: 'Memory & Context', description: 'Chapter analysis, timeline, context retrieval', profileId: null, - model: defaults.memory.model, - temperature: defaults.memory.temperature, - maxTokens: defaults.memory.maxTokens, - reasoningEffort: defaults.memory.reasoningEffort, + model: defaults.services.memory.model, + temperature: defaults.services.memory.temperature, + maxTokens: defaults.services.memory.maxTokens, + reasoningEffort: defaults.services.memory.reasoningEffort, manualBody: '' }, { @@ -953,10 +950,10 @@ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): name: 'Suggestions', description: 'Plot suggestions, action choices, style review', profileId: null, - model: defaults.suggestions.model, - temperature: defaults.suggestions.temperature, - maxTokens: defaults.suggestions.maxTokens, - reasoningEffort: defaults.suggestions.reasoningEffort, + model: defaults.services.suggestions.model, + temperature: defaults.services.suggestions.temperature, + maxTokens: defaults.services.suggestions.maxTokens, + reasoningEffort: defaults.services.suggestions.reasoningEffort, manualBody: '' }, { @@ -964,10 +961,10 @@ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): name: 'Agentic', description: 'Autonomous lore management and retrieval', profileId: null, - model: defaults.agentic.model, - temperature: defaults.agentic.temperature, - maxTokens: defaults.agentic.maxTokens, - reasoningEffort: defaults.agentic.reasoningEffort, + model: defaults.services.agentic.model, + temperature: defaults.services.agentic.temperature, + maxTokens: defaults.services.agentic.maxTokens, + reasoningEffort: defaults.services.agentic.reasoningEffort, manualBody: '' }, { @@ -975,10 +972,10 @@ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): name: 'Story Wizard', description: 'Story setup, character and setting generation', profileId: null, - model: defaults.wizard.model, - temperature: defaults.wizard.temperature, - maxTokens: defaults.wizard.maxTokens, - reasoningEffort: defaults.wizard.reasoningEffort, + model: defaults.services.wizard.model, + temperature: defaults.services.wizard.temperature, + maxTokens: defaults.services.wizard.maxTokens, + reasoningEffort: defaults.services.wizard.reasoningEffort, manualBody: '' }, { @@ -986,10 +983,10 @@ export function getDefaultGenerationPresetsForProvider(provider: ProviderType): name: 'Translation', description: 'Text translation between languages', profileId: null, - model: defaults.translation.model, - temperature: defaults.translation.temperature, - maxTokens: defaults.translation.maxTokens, - reasoningEffort: defaults.translation.reasoningEffort, + model: defaults.services.translation.model, + temperature: defaults.services.translation.temperature, + maxTokens: defaults.services.translation.maxTokens, + reasoningEffort: defaults.services.translation.reasoningEffort, manualBody: '' } ]; @@ -1018,7 +1015,7 @@ class SettingsStore { apiSettings = $state({ openaiApiKey: null, - openaiApiURL: OPENROUTER_API_URL, + openaiApiURL: PROVIDERS.openrouter.baseUrl, profiles: [], activeProfileId: null, mainNarrativeProfileId: DEFAULT_OPENROUTER_PROFILE_ID, @@ -1177,7 +1174,7 @@ class SettingsStore { try { // Load API settings - const apiURL = await database.getSetting('openai_api_url') ?? OPENROUTER_API_URL; //Default to OpenRouter. + const apiURL = await database.getSetting('openai_api_url') ?? PROVIDERS.openrouter.baseUrl; //Default to OpenRouter. // Load API key - check multiple locations for migration // Must handle empty strings explicitly since ?? only checks for null/undefined @@ -1507,7 +1504,7 @@ class SettingsStore { // Only ensure default profile and migrate for existing users (who have completed first run) // New users will get their profile created in initializeWithProvider after selecting a provider if (this.firstRunComplete) { - const isOpenRouterUrl = apiURL === OPENROUTER_API_URL; + const isOpenRouterUrl = apiURL === PROVIDERS.openrouter.baseUrl; const isOpenRouterKey = !!apiKey && apiKey.startsWith('sk-or-'); const shouldEnsureOpenRouterProfile = this.providerPreset === 'openrouter' || isOpenRouterUrl || isOpenRouterKey; const openRouterApiKey = (isOpenRouterUrl || isOpenRouterKey) ? apiKey : null; @@ -1716,12 +1713,12 @@ class SettingsStore { const defaultProfile = this.getProfile(defaultProfileId); if (defaultProfile) { this.apiSettings.activeProfileId = defaultProfileId; - this.apiSettings.openaiApiURL = defaultProfile.baseUrl ?? OPENROUTER_API_URL; + this.apiSettings.openaiApiURL = defaultProfile.baseUrl ?? PROVIDERS.openrouter.baseUrl; this.apiSettings.openaiApiKey = defaultProfile.apiKey; } else if (this.apiSettings.profiles.length > 0) { const fallbackProfile = this.apiSettings.profiles[0]; this.apiSettings.activeProfileId = fallbackProfile.id; - this.apiSettings.openaiApiURL = fallbackProfile.baseUrl ?? OPENROUTER_API_URL; + this.apiSettings.openaiApiURL = fallbackProfile.baseUrl ?? PROVIDERS.openrouter.baseUrl; this.apiSettings.openaiApiKey = fallbackProfile.apiKey; } else { this.apiSettings.activeProfileId = null; @@ -1811,7 +1808,7 @@ class SettingsStore { if (defaultProfile) { return { ...this.apiSettings, - openaiApiURL: defaultProfile.baseUrl ?? OPENROUTER_API_URL, + openaiApiURL: defaultProfile.baseUrl ?? PROVIDERS.openrouter.baseUrl, openaiApiKey: defaultProfile.apiKey, }; } @@ -1821,7 +1818,7 @@ class SettingsStore { return { ...this.apiSettings, - openaiApiURL: profile.baseUrl ?? OPENROUTER_API_URL, + openaiApiURL: profile.baseUrl ?? PROVIDERS.openrouter.baseUrl, openaiApiKey: profile.apiKey, }; } @@ -1957,7 +1954,7 @@ class SettingsStore { id: DEFAULT_OPENROUTER_PROFILE_ID, name: 'OpenRouter', providerType: 'openrouter', - baseUrl: OPENROUTER_API_URL, + baseUrl: PROVIDERS.openrouter.baseUrl, apiKey: existingApiKey || '', // Migrate existing key if present customModels: allModels, // Include all models in use plus defaults fetchedModels: [], // Will be populated when user fetches from API @@ -1973,7 +1970,7 @@ class SettingsStore { if (!this.apiSettings.activeProfileId) { this.apiSettings.activeProfileId = DEFAULT_OPENROUTER_PROFILE_ID; // Also set the current URL/key to match the profile (legacy fields) - this.apiSettings.openaiApiURL = defaultProfile.baseUrl ?? OPENROUTER_API_URL; + this.apiSettings.openaiApiURL = defaultProfile.baseUrl ?? PROVIDERS.openrouter.baseUrl; this.apiSettings.openaiApiKey = defaultProfile.apiKey; } @@ -2247,7 +2244,7 @@ class SettingsStore { } // Fall back to legacy check for pre-profile installations - return (!this.apiSettings.openaiApiKey && this.apiSettings.openaiApiURL === OPENROUTER_API_URL); + return (!this.apiSettings.openaiApiKey && this.apiSettings.openaiApiURL === PROVIDERS.openrouter.baseUrl); } // Wizard settings methods @@ -2492,16 +2489,16 @@ class SettingsStore { */ async resetAllSettings(preserveApiSettings = true) { const provider = this.getDefaultProviderType(); - const defaults = PROVIDER_DEFAULTS[provider]; + const defaults = PROVIDERS[provider]; const apiKey = preserveApiSettings ? this.apiSettings.openaiApiKey : null; - const apiURL = preserveApiSettings ? this.apiSettings.openaiApiURL : (defaults.baseUrl || OPENROUTER_API_URL); + const apiURL = preserveApiSettings ? this.apiSettings.openaiApiURL : (defaults.baseUrl || PROVIDERS.openrouter.baseUrl); const profiles = preserveApiSettings ? this.apiSettings.profiles : []; const activeProfileId = preserveApiSettings ? this.apiSettings.activeProfileId : null; const mainNarrativeProfileId = preserveApiSettings ? this.apiSettings.mainNarrativeProfileId : null; - const defaultNarrativeModel = defaults.narrative.model; - const defaultReasoningEffort = defaults.narrative.reasoningEffort; + const defaultNarrativeModel = defaults.services.narrative.model; + const defaultReasoningEffort = defaults.services.narrative.reasoningEffort; // Reset API settings (except URL/key/profiles if preserving) this.apiSettings = { @@ -2603,7 +2600,7 @@ class SettingsStore { * This sets up the default profile and all settings based on the provider. */ async initializeWithProvider(provider: ProviderType, apiKey: string) { - const defaults = PROVIDER_DEFAULTS[provider]; + const defaults = PROVIDERS[provider]; // Set the provider preset this.providerPreset = provider; @@ -2611,7 +2608,7 @@ class SettingsStore { // Create a unique profile ID const defaultProfileId = `default-${provider}-profile`; - const defaultApiURL = defaults.baseUrl || OPENROUTER_API_URL; + const defaultApiURL = defaults.baseUrl || PROVIDERS.openrouter.baseUrl; const defaultProfile: APIProfile = { id: defaultProfileId, @@ -2641,10 +2638,10 @@ class SettingsStore { this.apiSettings.openaiApiKey = apiKey; // Set provider-specific defaults - this.apiSettings.defaultModel = defaults.narrative.model; - this.apiSettings.temperature = defaults.narrative.temperature; - this.apiSettings.maxTokens = defaults.narrative.maxTokens; - this.apiSettings.reasoningEffort = defaults.narrative.reasoningEffort; + this.apiSettings.defaultModel = defaults.services.narrative.model; + this.apiSettings.temperature = defaults.services.narrative.temperature; + this.apiSettings.maxTokens = defaults.services.narrative.maxTokens; + this.apiSettings.reasoningEffort = defaults.services.narrative.reasoningEffort; this.apiSettings.manualBody = ''; this.apiSettings.enableThinking = false; await database.setSetting('default_model', this.apiSettings.defaultModel); diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index bd53f74..9d631b7 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -629,13 +629,23 @@ export interface UIState { // Provider types matching Vercel AI SDK providers export type ProviderType = - | 'openrouter' // @openrouter/ai-sdk-provider - | 'openai' // @ai-sdk/openai - | 'anthropic' // @ai-sdk/anthropic - | 'google' // @ai-sdk/google - | 'nanogpt' // OpenAI-compatible at nano-gpt.com - | 'chutes' // @chutes-ai/ai-sdk-provider - | 'pollinations'; // ai-sdk-pollinations + | 'openrouter' // @openrouter/ai-sdk-provider + | 'nanogpt' // OpenAI-compatible at nano-gpt.com + | 'chutes' // @chutes-ai/ai-sdk-provider + | 'pollinations' // ai-sdk-pollinations + | 'ollama' // ollama-ai-provider (local) + | 'lmstudio' // @ai-sdk/openai (local, default localhost:1234) + | 'llamacpp' // @ai-sdk/openai (local, default localhost:8080) + | 'nvidia-nim' // @ai-sdk/openai (NVIDIA NIM) + | 'openai-compatible' // @ai-sdk/openai (requires custom baseUrl) + | 'openai' // @ai-sdk/openai + | 'anthropic' // @ai-sdk/anthropic + | 'google' // @ai-sdk/google + | 'xai' // @ai-sdk/xai (Grok) + | 'groq' // @ai-sdk/groq + | 'zhipu' // zhipu-ai-provider (Z.AI/GLM) + | 'deepseek' // @ai-sdk/deepseek + | 'mistral'; // @ai-sdk/mistral // API Profile for saving OpenAI-compatible endpoint configurations export interface APIProfile {
@@ -863,7 +714,7 @@ variant="outline" size="sm" onclick={handleFetchModels} - disabled={isFetchingModels || (!formBaseUrl && !providerHasDefaultEndpoint[formProviderType])} + disabled={isFetchingModels || (!formBaseUrl && !hasDefaultEndpoint(formProviderType))} > {#if isFetchingModels} @@ -1043,7 +894,7 @@