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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
# Get your API key from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here

# Cerebras (High-performance inference)
# Get your API key from: https://cloud.cerebras.ai/settings
CEREBRAS_API_KEY=your_cerebras_api_key_here

# Fireworks AI (Fast inference with FireAttention engine)
# Get your API key from: https://fireworks.ai/api-keys
FIREWORKS_API_KEY=your_fireworks_api_key_here

# OpenAI GPT models
# Get your API key from: https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_api_key_here
Expand Down Expand Up @@ -59,6 +67,10 @@ XAI_API_KEY=your_xai_api_key_here
# Get your API key from: https://platform.moonshot.ai/console/api-keys
MOONSHOT_API_KEY=your_moonshot_api_key_here

# Z.AI (GLM models with JWT authentication)
# Get your API key from: https://open.bigmodel.cn/usercenter/apikeys
ZAI_API_KEY=your_zai_api_key_here

# Hugging Face
# Get your API key from: https://huggingface.co/settings/tokens
HuggingFace_API_KEY=your_huggingface_api_key_here
Expand Down
85 changes: 78 additions & 7 deletions app/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';

// Fuzzy search utilities
const levenshteinDistance = (str1: string, str2: string): number => {
Expand Down Expand Up @@ -130,6 +131,32 @@ export const ModelSelector = ({
const providerDropdownRef = useRef<HTMLDivElement>(null);
const [showFreeModelsOnly, setShowFreeModelsOnly] = useState(false);

type ConnectionStatus = 'unknown' | 'connected' | 'disconnected';

const [localProviderStatus, setLocalProviderStatus] = useState<Record<string, ConnectionStatus>>({});

// Check connectivity of local providers when provider list changes
useEffect(() => {
const checkLocalProviders = async () => {
const statuses: Record<string, 'connected' | 'disconnected'> = {};

for (const p of providerList) {
if (!LOCAL_PROVIDERS.includes(p.name)) {
continue;
}

// If the provider has models loaded, it's connected
const hasModels = modelList.some((m) => m.provider === p.name);

statuses[p.name] = hasModels ? 'connected' : 'disconnected';
}

setLocalProviderStatus(statuses);
};

checkLocalProviders();
}, [providerList, modelList]);

// Debounce search queries
useEffect(() => {
const timer = setTimeout(() => {
Expand Down Expand Up @@ -440,7 +467,28 @@ export const ModelSelector = ({
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="truncate">{provider?.name || 'Select provider'}</div>
<div className="flex items-center gap-2 truncate">
{provider?.name && LOCAL_PROVIDERS.includes(provider.name) && (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full flex-shrink-0',
localProviderStatus[provider.name] === 'connected'
? 'bg-green-500'
: localProviderStatus[provider.name] === 'disconnected'
? 'bg-red-400'
: 'bg-bolt-elements-textTertiary',
)}
title={
localProviderStatus[provider.name] === 'connected'
? `${provider.name} is running`
: localProviderStatus[provider.name] === 'disconnected'
? `${provider.name} is not reachable`
: 'Checking...'
}
/>
)}
{provider?.name || 'Select provider'}
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
Expand Down Expand Up @@ -559,11 +607,25 @@ export const ModelSelector = ({
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
<div
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
<div className="flex items-center gap-2">
{LOCAL_PROVIDERS.includes(providerOption.name) && (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full flex-shrink-0',
localProviderStatus[providerOption.name] === 'connected'
? 'bg-green-500'
: localProviderStatus[providerOption.name] === 'disconnected'
? 'bg-red-400'
: 'bg-bolt-elements-textTertiary',
)}
/>
)}
<span
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
</div>
</div>
))
)}
Expand Down Expand Up @@ -717,8 +779,17 @@ export const ModelSelector = ({
? `No models match "${debouncedModelSearchQuery}"${showFreeModelsOnly ? ' (free only)' : ''}`
: showFreeModelsOnly
? 'No free models available'
: 'No models available'}
: provider?.name && LOCAL_PROVIDERS.includes(provider.name)
? `No models found — is ${provider.name} running?`
: 'No models available'}
</div>
{!debouncedModelSearchQuery && provider?.name && LOCAL_PROVIDERS.includes(provider.name) && (
<div className="text-xs text-bolt-elements-textTertiary mt-1">
Make sure {provider.name} is running and has at least one model loaded.
{provider.name === 'Ollama' && ' Try: ollama pull llama3.2'}
{provider.name === 'LMStudio' && ' Load a model in LM Studio first.'}
</div>
)}
{debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for model names, context sizes (e.g., "128k", "1M"), or capabilities
Expand Down
61 changes: 57 additions & 4 deletions app/lib/modules/llm/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type { IProviderSetting } from '~/types/model';
import { createOpenAI } from '@ai-sdk/openai';
import { LLMManager } from './manager';

/** Default timeout for model listing API calls (5 seconds) */
const MODEL_FETCH_TIMEOUT = 5_000;

export abstract class BaseProvider implements ProviderInfo {
abstract name: string;
abstract staticModels: ModelInfo[];
Expand All @@ -17,6 +20,48 @@ export abstract class BaseProvider implements ProviderInfo {
labelForGetApiKey?: string;
icon?: string;

/**
* Convert Cloudflare Env bindings to a plain Record<string, string>.
* Useful because provider methods expect Record<string, string> but
* Cloudflare Workers pass an Env interface.
*/
protected convertEnvToRecord(env?: Env): Record<string, string> {
if (!env) {
return {};
}

return Object.entries(env).reduce(
(acc, [key, value]) => {
acc[key] = String(value);

return acc;
},
{} as Record<string, string>,
);
}

/**
* Rewrite localhost / 127.0.0.1 URLs to host.docker.internal when
* running inside Docker. Only applies on the server side.
*/
protected resolveDockerUrl(baseUrl: string, serverEnv?: Record<string, string>): string {
const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true';

if (!isDocker) {
return baseUrl;
}

return baseUrl.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
}

/**
* Create an AbortSignal that times out after the given milliseconds.
* Used to prevent model-listing fetches from hanging indefinitely.
*/
protected createTimeoutSignal(ms: number = MODEL_FETCH_TIMEOUT): AbortSignal {
return AbortSignal.timeout(ms);
}

getProviderBaseUrlAndKey(options: {
apiKeys?: Record<string, string>;
providerSettings?: IProviderSetting;
Expand Down Expand Up @@ -59,16 +104,15 @@ export abstract class BaseProvider implements ProviderInfo {
serverEnv?: Record<string, string>;
}): ModelInfo[] | null {
if (!this.cachedDynamicModels) {
// console.log('no dynamic models',this.name);
return null;
}

const cacheKey = this.cachedDynamicModels.cacheId;
const generatedCacheKey = this.getDynamicModelsCacheKey(options);

if (cacheKey !== generatedCacheKey) {
// console.log('cache key mismatch',this.name,cacheKey,generatedCacheKey);
this.cachedDynamicModels = undefined;

return null;
}

Expand All @@ -79,10 +123,20 @@ export abstract class BaseProvider implements ProviderInfo {
providerSettings?: Record<string, IProviderSetting>;
serverEnv?: Record<string, string>;
}) {
// Only include provider-relevant env keys, not the entire server environment
const relevantEnvKeys = [this.config.baseUrlKey, this.config.apiTokenKey].filter(Boolean) as string[];
const relevantEnv: Record<string, string> = {};

for (const key of relevantEnvKeys) {
if (options.serverEnv?.[key]) {
relevantEnv[key] = options.serverEnv[key];
}
}

return JSON.stringify({
apiKeys: options.apiKeys?.[this.name],
providerSettings: options.providerSettings?.[this.name],
serverEnv: options.serverEnv,
serverEnv: relevantEnv,
});
}
storeDynamicModels(
Expand All @@ -95,7 +149,6 @@ export abstract class BaseProvider implements ProviderInfo {
) {
const cacheId = this.getDynamicModelsCacheKey(options);

// console.log('caching dynamic models',this.name,cacheId);
this.cachedDynamicModels = {
cacheId,
models,
Expand Down
9 changes: 6 additions & 3 deletions app/lib/modules/llm/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export class LLMManager {
private static _instance: LLMManager;
private _providers: Map<string, BaseProvider> = new Map();
private _modelList: ModelInfo[] = [];
private readonly _env: any = {};
private _env: Record<string, string> = {};

private constructor(_env: Record<string, string>) {
this._registerProvidersFromDirectory();
Expand All @@ -19,6 +19,9 @@ export class LLMManager {
static getInstance(env: Record<string, string> = {}): LLMManager {
if (!LLMManager._instance) {
LLMManager._instance = new LLMManager(env);
} else if (Object.keys(env).length > 0) {
// Update env on subsequent calls so Cloudflare Workers get fresh bindings
LLMManager._instance._env = env;
}

return LLMManager._instance;
Expand Down Expand Up @@ -121,10 +124,10 @@ export class LLMManager {
const staticModels = Array.from(this._providers.values()).flatMap((p) => p.staticModels || []);
const dynamicModelsFlat = dynamicModels.flat();
const dynamicModelKeys = dynamicModelsFlat.map((d) => `${d.name}-${d.provider}`);
const filteredStaticModesl = staticModels.filter((m) => !dynamicModelKeys.includes(`${m.name}-${m.provider}`));
const filteredStaticModels = staticModels.filter((m) => !dynamicModelKeys.includes(`${m.name}-${m.provider}`));

// Combine static and dynamic models
const modelList = [...dynamicModelsFlat, ...filteredStaticModesl];
const modelList = [...dynamicModelsFlat, ...filteredStaticModels];
modelList.sort((a, b) => a.name.localeCompare(b.name));
this._modelList = modelList;

Expand Down
Loading
Loading