deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { OllamaLanguageModelsManager, OllamaModelDescription } from '../common';
|
||||
import { HOST_PREF, MODELS_PREF } from '../common/ollama-preferences';
|
||||
import { PreferenceService } from '@theia/core';
|
||||
|
||||
const OLLAMA_PROVIDER_ID = 'ollama';
|
||||
@injectable()
|
||||
export class OllamaFrontendApplicationContribution implements FrontendApplicationContribution {
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected preferenceService: PreferenceService;
|
||||
|
||||
@inject(OllamaLanguageModelsManager)
|
||||
protected manager: OllamaLanguageModelsManager;
|
||||
|
||||
protected prevModels: string[] = [];
|
||||
|
||||
onStart(): void {
|
||||
this.preferenceService.ready.then(() => {
|
||||
const host = this.preferenceService.get<string>(HOST_PREF);
|
||||
this.manager.setHost(host || undefined);
|
||||
|
||||
const models = this.preferenceService.get<string[]>(MODELS_PREF, []);
|
||||
this.manager.createOrUpdateLanguageModels(...models.map(modelId => this.createOllamaModelDescription(modelId)));
|
||||
this.prevModels = [...models];
|
||||
|
||||
this.preferenceService.onPreferenceChanged(event => {
|
||||
if (event.preferenceName === HOST_PREF) {
|
||||
this.manager.setHost(this.preferenceService.get<string>(HOST_PREF));
|
||||
} else if (event.preferenceName === MODELS_PREF) {
|
||||
this.handleModelChanges(this.preferenceService.get<string[]>(MODELS_PREF, []));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected handleModelChanges(newModels: string[]): void {
|
||||
const oldModels = new Set(this.prevModels);
|
||||
const updatedModels = new Set(newModels);
|
||||
|
||||
const modelsToRemove = [...oldModels].filter(model => !updatedModels.has(model));
|
||||
const modelsToAdd = [...updatedModels].filter(model => !oldModels.has(model));
|
||||
|
||||
this.manager.removeLanguageModels(...modelsToRemove);
|
||||
this.manager.createOrUpdateLanguageModels(...modelsToAdd.map(modelId => this.createOllamaModelDescription(modelId)));
|
||||
this.prevModels = newModels;
|
||||
}
|
||||
|
||||
protected createOllamaModelDescription(modelId: string): OllamaModelDescription {
|
||||
const id = `${OLLAMA_PROVIDER_ID}/${modelId}`;
|
||||
|
||||
return {
|
||||
id: id,
|
||||
model: modelId
|
||||
};
|
||||
}
|
||||
}
|
||||
32
packages/ai-ollama/src/browser/ollama-frontend-module.ts
Normal file
32
packages/ai-ollama/src/browser/ollama-frontend-module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { OllamaPreferencesSchema } from '../common/ollama-preferences';
|
||||
import { FrontendApplicationContribution, RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser';
|
||||
import { OllamaFrontendApplicationContribution } from './ollama-frontend-application-contribution';
|
||||
import { OLLAMA_LANGUAGE_MODELS_MANAGER_PATH, OllamaLanguageModelsManager } from '../common';
|
||||
import { PreferenceContribution } from '@theia/core';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(PreferenceContribution).toConstantValue({ schema: OllamaPreferencesSchema });
|
||||
bind(OllamaFrontendApplicationContribution).toSelf().inSingletonScope();
|
||||
bind(FrontendApplicationContribution).toService(OllamaFrontendApplicationContribution);
|
||||
bind(OllamaLanguageModelsManager).toDynamicValue(ctx => {
|
||||
const provider = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
return provider.createProxy<OllamaLanguageModelsManager>(OLLAMA_LANGUAGE_MODELS_MANAGER_PATH);
|
||||
}).inSingletonScope();
|
||||
});
|
||||
16
packages/ai-ollama/src/common/index.ts
Normal file
16
packages/ai-ollama/src/common/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
export * from './ollama-language-models-manager';
|
||||
@@ -0,0 +1,36 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export const OLLAMA_LANGUAGE_MODELS_MANAGER_PATH = '/services/ollama/language-model-manager';
|
||||
export const OllamaLanguageModelsManager = Symbol('OllamaLanguageModelsManager');
|
||||
|
||||
export interface OllamaModelDescription {
|
||||
/**
|
||||
* The identifier of the model which will be shown in the UI.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The name or ID of the model in the Ollama environment.
|
||||
*/
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface OllamaLanguageModelsManager {
|
||||
host: string | undefined;
|
||||
setHost(host: string | undefined): Promise<void>;
|
||||
createOrUpdateLanguageModels(...models: OllamaModelDescription[]): Promise<void>;
|
||||
removeLanguageModels(...modelIds: string[]): void;
|
||||
}
|
||||
39
packages/ai-ollama/src/common/ollama-preferences.ts
Normal file
39
packages/ai-ollama/src/common/ollama-preferences.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences';
|
||||
import { PreferenceSchema } from '@theia/core/lib/common';
|
||||
|
||||
export const HOST_PREF = 'ai-features.ollama.ollamaHost';
|
||||
export const MODELS_PREF = 'ai-features.ollama.ollamaModels';
|
||||
|
||||
export const OllamaPreferencesSchema: PreferenceSchema = {
|
||||
properties: {
|
||||
[HOST_PREF]: {
|
||||
type: 'string',
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
default: 'http://localhost:11434'
|
||||
},
|
||||
[MODELS_PREF]: {
|
||||
type: 'array',
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
default: [],
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
38
packages/ai-ollama/src/node/ollama-backend-module.ts
Normal file
38
packages/ai-ollama/src/node/ollama-backend-module.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { OLLAMA_LANGUAGE_MODELS_MANAGER_PATH, OllamaLanguageModelsManager } from '../common/ollama-language-models-manager';
|
||||
import { ConnectionHandler, PreferenceContribution, RpcConnectionHandler } from '@theia/core';
|
||||
import { OllamaLanguageModelsManagerImpl } from './ollama-language-models-manager-impl';
|
||||
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
|
||||
import { OllamaPreferencesSchema } from '../common/ollama-preferences';
|
||||
|
||||
export const OllamaModelFactory = Symbol('OllamaModelFactory');
|
||||
|
||||
// We use a connection module to handle AI services separately for each frontend.
|
||||
const ollamaConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService, bindFrontendService }) => {
|
||||
bind(OllamaLanguageModelsManagerImpl).toSelf().inSingletonScope();
|
||||
bind(OllamaLanguageModelsManager).toService(OllamaLanguageModelsManagerImpl);
|
||||
bind(ConnectionHandler).toDynamicValue(ctx =>
|
||||
new RpcConnectionHandler(OLLAMA_LANGUAGE_MODELS_MANAGER_PATH, () => ctx.container.get(OllamaLanguageModelsManager))
|
||||
).inSingletonScope();
|
||||
});
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(PreferenceContribution).toConstantValue({ schema: OllamaPreferencesSchema });
|
||||
bind(ConnectionContainerModule).toConstantValue(ollamaConnectionModule);
|
||||
});
|
||||
436
packages/ai-ollama/src/node/ollama-language-model.ts
Normal file
436
packages/ai-ollama/src/node/ollama-language-model.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
LanguageModel,
|
||||
LanguageModelParsedResponse,
|
||||
LanguageModelRequest,
|
||||
LanguageModelMessage,
|
||||
LanguageModelResponse,
|
||||
LanguageModelStreamResponse,
|
||||
LanguageModelStreamResponsePart,
|
||||
ToolCall,
|
||||
ToolRequest,
|
||||
ToolRequestParametersProperties,
|
||||
ImageContent,
|
||||
TokenUsageService,
|
||||
LanguageModelStatus
|
||||
} from '@theia/ai-core';
|
||||
import { CancellationToken } from '@theia/core';
|
||||
import { ChatRequest, Message, Ollama, Options, Tool, ToolCall as OllamaToolCall, ChatResponse } from 'ollama';
|
||||
|
||||
export const OllamaModelIdentifier = Symbol('OllamaModelIdentifier');
|
||||
|
||||
export class OllamaModel implements LanguageModel {
|
||||
|
||||
protected readonly DEFAULT_REQUEST_SETTINGS: Partial<Omit<ChatRequest, 'stream' | 'model'>> = {
|
||||
keep_alive: '15m',
|
||||
// options see: https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values
|
||||
options: {}
|
||||
};
|
||||
|
||||
readonly providerId = 'ollama';
|
||||
readonly vendor: string = 'Ollama';
|
||||
|
||||
/**
|
||||
* @param id the unique id for this language model. It will be used to identify the model in the UI.
|
||||
* @param model the unique model name as used in the Ollama environment.
|
||||
* @param hostProvider a function to provide the host URL for the Ollama server.
|
||||
*/
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
protected readonly model: string,
|
||||
public status: LanguageModelStatus,
|
||||
protected host: () => string | undefined,
|
||||
protected readonly tokenUsageService?: TokenUsageService
|
||||
) { }
|
||||
|
||||
async request(request: LanguageModelRequest, cancellationToken?: CancellationToken): Promise<LanguageModelResponse> {
|
||||
const settings = this.getSettings(request);
|
||||
const ollama = this.initializeOllama();
|
||||
const stream = !(request.settings?.stream === false); // true by default, false only if explicitly specified
|
||||
const ollamaRequest: ExtendedChatRequest = {
|
||||
model: this.model,
|
||||
...this.DEFAULT_REQUEST_SETTINGS,
|
||||
...settings,
|
||||
messages: request.messages.map(m => this.toOllamaMessage(m)).filter(m => m !== undefined) as Message[],
|
||||
tools: request.tools?.map(t => this.toOllamaTool(t)),
|
||||
stream
|
||||
};
|
||||
const structured = request.response_format?.type === 'json_schema';
|
||||
return this.dispatchRequest(ollama, ollamaRequest, structured, cancellationToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the settings for the chat request, merging the request-specific settings with the default settings.
|
||||
* @param request The language model request containing specific settings.
|
||||
* @returns A partial ChatRequest object containing the merged settings.
|
||||
*/
|
||||
protected getSettings(request: LanguageModelRequest): Partial<ChatRequest> {
|
||||
const settings = request.settings ?? {};
|
||||
return {
|
||||
options: settings as Partial<Options>
|
||||
};
|
||||
}
|
||||
|
||||
protected async dispatchRequest(ollama: Ollama, ollamaRequest: ExtendedChatRequest, structured: boolean, cancellation?: CancellationToken): Promise<LanguageModelResponse> {
|
||||
|
||||
// Handle structured output request
|
||||
if (structured) {
|
||||
return this.handleStructuredOutputRequest(ollama, ollamaRequest);
|
||||
}
|
||||
|
||||
if (isNonStreaming(ollamaRequest)) {
|
||||
// handle non-streaming request
|
||||
return this.handleNonStreamingRequest(ollama, ollamaRequest, cancellation);
|
||||
}
|
||||
|
||||
// handle streaming request
|
||||
return this.handleStreamingRequest(ollama, ollamaRequest, cancellation);
|
||||
}
|
||||
|
||||
protected async handleStreamingRequest(ollama: Ollama, chatRequest: ExtendedChatRequest, cancellation?: CancellationToken): Promise<LanguageModelStreamResponse> {
|
||||
const responseStream = await ollama.chat({
|
||||
...chatRequest,
|
||||
stream: true,
|
||||
think: await this.checkThinkingSupport(ollama, chatRequest.model)
|
||||
});
|
||||
|
||||
cancellation?.onCancellationRequested(() => {
|
||||
responseStream.abort();
|
||||
});
|
||||
|
||||
const that = this;
|
||||
|
||||
const asyncIterator = {
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<LanguageModelStreamResponsePart> {
|
||||
// Process the response stream and collect thinking, content messages, and tool calls.
|
||||
// Tool calls are handled when the response stream is done.
|
||||
const toolCalls: OllamaToolCall[] = [];
|
||||
let currentContent = '';
|
||||
let currentThought = '';
|
||||
|
||||
// Ollama does not have ids, so we use the most recent chunk.created_at timestamp as repalcement
|
||||
let lastUpdated: Date = new Date();
|
||||
|
||||
try {
|
||||
for await (const chunk of responseStream) {
|
||||
lastUpdated = chunk.created_at;
|
||||
|
||||
const thought = chunk.message.thinking;
|
||||
if (thought) {
|
||||
currentThought += thought;
|
||||
yield { thought, signature: '' };
|
||||
}
|
||||
const textContent = chunk.message.content;
|
||||
if (textContent) {
|
||||
currentContent += textContent;
|
||||
yield { content: textContent };
|
||||
}
|
||||
|
||||
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
|
||||
toolCalls.push(...chunk.message.tool_calls);
|
||||
}
|
||||
|
||||
if (chunk.done) {
|
||||
that.recordTokenUsage(chunk);
|
||||
|
||||
if (chunk.done_reason && chunk.done_reason !== 'stop') {
|
||||
throw new Error('Ollama stopped unexpectedly. Reason: ' + chunk.done_reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
chatRequest.messages.push({
|
||||
role: 'assistant',
|
||||
content: currentContent,
|
||||
thinking: currentThought,
|
||||
tool_calls: toolCalls
|
||||
});
|
||||
|
||||
const toolCallsForResponse = await that.processToolCalls(toolCalls, chatRequest, lastUpdated);
|
||||
yield { tool_calls: toolCallsForResponse };
|
||||
|
||||
// Continue the conversation with tool results
|
||||
const continuedResponse = await that.handleStreamingRequest(
|
||||
ollama,
|
||||
chatRequest,
|
||||
cancellation
|
||||
);
|
||||
|
||||
// Stream the continued response
|
||||
for await (const nestedEvent of continuedResponse.stream) {
|
||||
yield nestedEvent;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in Ollama streaming:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { stream: asyncIterator };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Ollama server supports thinking.
|
||||
*
|
||||
* Use the Ollama 'show' request to get information about the model, so we can check the capabilities for the 'thinking' capability.
|
||||
*
|
||||
* @param ollama The Ollama client instance.
|
||||
* @param model The name of the Ollama model.
|
||||
* @returns A boolean indicating whether the Ollama model supports thinking.
|
||||
*/
|
||||
protected async checkThinkingSupport(ollama: Ollama, model: string): Promise<boolean> {
|
||||
const result = await ollama.show({ model });
|
||||
return result?.capabilities?.includes('thinking') || false;
|
||||
}
|
||||
|
||||
protected async handleStructuredOutputRequest(ollama: Ollama, chatRequest: ChatRequest): Promise<LanguageModelParsedResponse> {
|
||||
const response = await ollama.chat({
|
||||
...chatRequest,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
});
|
||||
try {
|
||||
return {
|
||||
content: response.message.content,
|
||||
parsed: JSON.parse(response.message.content)
|
||||
};
|
||||
} catch (error) {
|
||||
// TODO use ILogger
|
||||
console.log('Failed to parse structured response from the language model.', error);
|
||||
return {
|
||||
content: response.message.content,
|
||||
parsed: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleNonStreamingRequest(ollama: Ollama, chatRequest: ExtendedNonStreamingChatRequest, cancellation?: CancellationToken): Promise<LanguageModelResponse> {
|
||||
try {
|
||||
// even though we have a non-streaming request, we still use the streaming version for two reasons:
|
||||
// 1. we can abort the stream if the request is cancelled instead of having to wait for the entire response
|
||||
// 2. we can use think: true so the Ollama API separates thinking from content and we can filter out the thoughts in the response
|
||||
const responseStream = await ollama.chat({ ...chatRequest, stream: true, think: await this.checkThinkingSupport(ollama, chatRequest.model) });
|
||||
cancellation?.onCancellationRequested(() => {
|
||||
responseStream.abort();
|
||||
});
|
||||
|
||||
const toolCalls: OllamaToolCall[] = [];
|
||||
let content = '';
|
||||
let lastUpdated: Date = new Date();
|
||||
|
||||
// process the response stream
|
||||
for await (const chunk of responseStream) {
|
||||
// if the response contains content, append it to the result
|
||||
const textContent = chunk.message.content;
|
||||
if (textContent) {
|
||||
content += textContent;
|
||||
}
|
||||
|
||||
// record requested tool calls so we can process them later
|
||||
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
|
||||
toolCalls.push(...chunk.message.tool_calls);
|
||||
}
|
||||
|
||||
// if the response is done, record the token usage and check the done reason
|
||||
if (chunk.done) {
|
||||
this.recordTokenUsage(chunk);
|
||||
lastUpdated = chunk.created_at;
|
||||
if (chunk.done_reason && chunk.done_reason !== 'stop') {
|
||||
throw new Error('Ollama stopped unexpectedly. Reason: ' + chunk.done_reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process any tool calls by adding all of them to the messages of the conversation
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
chatRequest.messages.push({
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
tool_calls: toolCalls
|
||||
});
|
||||
|
||||
await this.processToolCalls(toolCalls, chatRequest, lastUpdated);
|
||||
if (cancellation?.isCancellationRequested) {
|
||||
return { text: '' };
|
||||
}
|
||||
|
||||
// recurse to get the final response content (the intermediate content remains hidden, it is only part of the conversation)
|
||||
return this.handleNonStreamingRequest(ollama, chatRequest);
|
||||
}
|
||||
|
||||
// if no tool calls are necessary, return the final response content
|
||||
return { text: content };
|
||||
} catch (error) {
|
||||
console.error('Error in ollama call:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async processToolCalls(toolCalls: OllamaToolCall[], chatRequest: ExtendedChatRequest, lastUpdated: Date): Promise<ToolCall[]> {
|
||||
const tools: ToolWithHandler[] = chatRequest.tools ?? [];
|
||||
const toolCallsForResponse: ToolCall[] = [];
|
||||
for (const [idx, toolCall] of toolCalls.entries()) {
|
||||
const functionToCall = tools.find(tool => tool.function.name === toolCall.function.name);
|
||||
const args = JSON.stringify(toolCall.function?.arguments);
|
||||
let funcResult: string;
|
||||
if (functionToCall) {
|
||||
const rawResult = await functionToCall.handler(args);
|
||||
funcResult = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
|
||||
} else {
|
||||
funcResult = 'error: Tool not found';
|
||||
}
|
||||
|
||||
chatRequest.messages.push({
|
||||
role: 'tool',
|
||||
content: `Tool call ${toolCall.function.name} returned: ${String(funcResult)}`,
|
||||
});
|
||||
toolCallsForResponse.push({
|
||||
id: `ollama_${lastUpdated}_${idx}`,
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
},
|
||||
result: String(funcResult),
|
||||
finished: true
|
||||
});
|
||||
}
|
||||
return toolCallsForResponse;
|
||||
}
|
||||
|
||||
private recordTokenUsage(response: ChatResponse): void {
|
||||
if (this.tokenUsageService && response.prompt_eval_count && response.eval_count) {
|
||||
this.tokenUsageService.recordTokenUsage(this.id, {
|
||||
inputTokens: response.prompt_eval_count,
|
||||
outputTokens: response.eval_count,
|
||||
requestId: `ollama_${response.created_at}`
|
||||
}).catch(error => console.error('Error recording token usage:', error));
|
||||
}
|
||||
}
|
||||
|
||||
protected initializeOllama(): Ollama {
|
||||
const host = this.host();
|
||||
if (!host) {
|
||||
throw new Error('Please provide OLLAMA_HOST in preferences or via environment variable');
|
||||
}
|
||||
return new Ollama({ host: host });
|
||||
}
|
||||
|
||||
protected toOllamaTool(tool: ToolRequest): ToolWithHandler {
|
||||
const transform = (props: ToolRequestParametersProperties | undefined) => {
|
||||
if (!props) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, { type: string, description: string, enum?: string[] }> = {};
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
const type = prop.type;
|
||||
if (type) {
|
||||
const description = typeof prop.description == 'string' ? prop.description : '';
|
||||
result[key] = {
|
||||
type: type,
|
||||
description: description
|
||||
};
|
||||
} else {
|
||||
// TODO: Should handle anyOf, but this is not supported by the Ollama type yet
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description ?? 'Tool named ' + tool.name,
|
||||
parameters: {
|
||||
type: tool.parameters?.type ?? 'object',
|
||||
required: tool.parameters?.required ?? [],
|
||||
properties: transform(tool.parameters?.properties) ?? {}
|
||||
},
|
||||
},
|
||||
handler: tool.handler
|
||||
};
|
||||
}
|
||||
|
||||
protected toOllamaMessage(message: LanguageModelMessage): Message | undefined {
|
||||
const result: Message = {
|
||||
role: this.toOllamaMessageRole(message),
|
||||
content: ''
|
||||
};
|
||||
|
||||
if (LanguageModelMessage.isTextMessage(message) && message.text.length > 0) {
|
||||
result.content = message.text;
|
||||
} else if (LanguageModelMessage.isToolUseMessage(message)) {
|
||||
result.tool_calls = [{ function: { name: message.name, arguments: message.input as Record<string, unknown> } }];
|
||||
} else if (LanguageModelMessage.isToolResultMessage(message)) {
|
||||
result.content = `Tool call ${message.name} returned: ${message.content}`;
|
||||
} else if (LanguageModelMessage.isThinkingMessage(message)) {
|
||||
result.thinking = message.thinking;
|
||||
} else if (LanguageModelMessage.isImageMessage(message) && ImageContent.isBase64(message.image)) {
|
||||
result.images = [message.image.base64data];
|
||||
} else {
|
||||
console.log(`Unknown message type encountered when converting message to Ollama format: ${JSON.stringify(message)}. Ignoring message.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected toOllamaMessageRole(message: LanguageModelMessage): string {
|
||||
if (LanguageModelMessage.isToolResultMessage(message)) {
|
||||
return 'tool';
|
||||
}
|
||||
const actor = message.actor;
|
||||
if (actor === 'ai') {
|
||||
return 'assistant';
|
||||
}
|
||||
if (actor === 'user') {
|
||||
return 'user';
|
||||
}
|
||||
if (actor === 'system') {
|
||||
return 'system';
|
||||
}
|
||||
console.log(`Unknown actor encountered when converting message to Ollama format: ${actor}. Falling back to 'user'.`);
|
||||
return 'user'; // default fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended Tool containing a handler
|
||||
* @see Tool
|
||||
*/
|
||||
type ToolWithHandler = Tool & { handler: (arg_string: string) => Promise<unknown> };
|
||||
|
||||
/**
|
||||
* Extended chat request with mandatory messages and ToolWithHandler tools
|
||||
*
|
||||
* @see ChatRequest
|
||||
* @see ToolWithHandler
|
||||
*/
|
||||
type ExtendedChatRequest = ChatRequest & {
|
||||
messages: Message[]
|
||||
tools?: ToolWithHandler[]
|
||||
};
|
||||
|
||||
type ExtendedNonStreamingChatRequest = ExtendedChatRequest & { stream: false };
|
||||
|
||||
function isNonStreaming(request: ExtendedChatRequest): request is ExtendedNonStreamingChatRequest {
|
||||
return !request.stream;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 TypeFox GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { LanguageModelRegistry, LanguageModelStatus, TokenUsageService } from '@theia/ai-core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { OllamaModel } from './ollama-language-model';
|
||||
import { OllamaLanguageModelsManager, OllamaModelDescription } from '../common';
|
||||
|
||||
@injectable()
|
||||
export class OllamaLanguageModelsManagerImpl implements OllamaLanguageModelsManager {
|
||||
|
||||
protected _host: string | undefined;
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
protected readonly languageModelRegistry: LanguageModelRegistry;
|
||||
|
||||
@inject(TokenUsageService)
|
||||
protected readonly tokenUsageService: TokenUsageService;
|
||||
|
||||
get host(): string | undefined {
|
||||
return this._host ?? process.env.OLLAMA_HOST;
|
||||
}
|
||||
|
||||
// Triggered from frontend. In case you want to use the models on the backend
|
||||
// without a frontend then call this yourself
|
||||
protected calculateStatus(host: string | undefined): LanguageModelStatus {
|
||||
return host ? { status: 'ready' } : { status: 'unavailable', message: 'No Ollama host set' };
|
||||
}
|
||||
|
||||
async createOrUpdateLanguageModels(...models: OllamaModelDescription[]): Promise<void> {
|
||||
for (const modelDescription of models) {
|
||||
const existingModel = await this.languageModelRegistry.getLanguageModel(modelDescription.id);
|
||||
const hostProvider = () => this.host;
|
||||
|
||||
if (existingModel) {
|
||||
if (!(existingModel instanceof OllamaModel)) {
|
||||
console.warn(`Ollama: model ${modelDescription.id} is not an Ollama model`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const status = this.calculateStatus(hostProvider());
|
||||
this.languageModelRegistry.addLanguageModels([
|
||||
new OllamaModel(
|
||||
modelDescription.id,
|
||||
modelDescription.model,
|
||||
status,
|
||||
hostProvider,
|
||||
this.tokenUsageService
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeLanguageModels(...modelIds: string[]): void {
|
||||
this.languageModelRegistry.removeLanguageModels(modelIds.map(id => `ollama/${id}`));
|
||||
}
|
||||
|
||||
async setHost(host: string | undefined): Promise<void> {
|
||||
this._host = host || undefined;
|
||||
const models = await this.languageModelRegistry.getLanguageModels();
|
||||
const ollamaModels = models.filter(model => model instanceof OllamaModel) as OllamaModel[];
|
||||
const status = this.calculateStatus(this.host);
|
||||
for (const model of ollamaModels) {
|
||||
model.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
packages/ai-ollama/src/package.spec.ts
Normal file
68
packages/ai-ollama/src/package.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 TypeFox GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ToolRequest } from '@theia/ai-core';
|
||||
import { OllamaModel } from './node/ollama-language-model';
|
||||
import { Tool } from 'ollama';
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
describe('ai-ollama package', () => {
|
||||
|
||||
it('Transform to Ollama tools', () => {
|
||||
const req: ToolRequest = createToolRequest();
|
||||
const model = new OllamaModelUnderTest();
|
||||
const ollamaTool = model.toOllamaTool(req);
|
||||
|
||||
expect(ollamaTool.function.name).equals('example-tool');
|
||||
expect(ollamaTool.function.description).equals('Example Tool');
|
||||
expect(ollamaTool.function.parameters?.type).equal('object');
|
||||
expect(ollamaTool.function.parameters?.properties).to.deep.equal(req.parameters.properties);
|
||||
expect(ollamaTool.function.parameters?.required).to.deep.equal(['question']);
|
||||
});
|
||||
});
|
||||
|
||||
class OllamaModelUnderTest extends OllamaModel {
|
||||
constructor() {
|
||||
super('id', 'model', { status: 'ready' }, () => '');
|
||||
}
|
||||
|
||||
override toOllamaTool(tool: ToolRequest): Tool & { handler: (arg_string: string) => Promise<unknown> } {
|
||||
return super.toOllamaTool(tool);
|
||||
}
|
||||
}
|
||||
function createToolRequest(): ToolRequest {
|
||||
return {
|
||||
id: 'tool-1',
|
||||
name: 'example-tool',
|
||||
description: 'Example Tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'What is the best pizza topping?'
|
||||
},
|
||||
optional: {
|
||||
type: 'string',
|
||||
description: 'Optional parameter'
|
||||
}
|
||||
},
|
||||
required: ['question']
|
||||
},
|
||||
handler: sinon.stub()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user