From 3db144274f03cb588c0583aaab4565e7ae5c49d4 Mon Sep 17 00:00:00 2001 From: 3L0935 Date: Sat, 16 May 2026 22:08:31 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Ollama=20provider=20=E2=80=94=20loc?= =?UTF-8?q?al=20&=20cloud=20LLM=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a full Ollama provider driver integrated with the existing ProviderDriver pattern, enabling T3 Code to use local and cloud Ollama instances as a first-class provider alongside Codex, Claude, OpenCode, and Cursor. ## Backend - — HTTP helpers for Ollama REST API: /api/chat (streaming SSE), /api/tags (model listing), /api/version (health check). Supports Bearer token auth via OLLAMA_API_KEY env variable. - — 7 built-in tools: read_file, write_file, edit_file, bash, list_directory, search_files, web_fetch. Each tool has classify + execute phases matching the tool-calling protocol. - — Full provider adapter with session lifecycle, tool-calling loop, approval flow via Deferred, and fork support (runForkWith). Streams structured runtime events (turn.started, content.delta, item.completed, tool.use, tool.result, etc.). - — Health check provider: pings /api/tags at startup and on 5-minute refresh interval, exposes available models directly from the Ollama server. - — ProviderDriver bridging adapter + text generation + snapshot management into a single entry point. - — Commit message, PR content, branch name, and thread title generation via Ollama chat completions. - — OllamaDriver registered in BUILT_IN_DRIVERS array and BuiltInDriversEnv union type. ## Contracts - — OllamaSettings schema: enabled, baseUrl (default http://localhost:11434), model, customModels. Patch schema for runtime updates. - — Ollama driver kind registered in driver model contract for model selection routing. ## Frontend - — OllamaIcon SVG (simple-icons Ollama logo). - — Ollama entry in PROVIDER_CLIENT_DEFINITIONS with icon, label, and settings schema for the settings panel. - — Ollama added to PROVIDER_ICON_BY_PROVIDER mapping. - — Typecheck fix for the added model field. ## Usage Configure Ollama in Settings → Providers: - Server URL: http://localhost:11434 (local) or https://ollama.com - API key via OLLAMA_API_KEY environment variable (for Ollama Cloud) - Models are auto-discovered from /api/tags --- .../src/provider/Drivers/OllamaDriver.ts | 62 +++ .../src/provider/Layers/OllamaAdapter.ts | 400 ++++++++++++++++++ .../src/provider/Layers/OllamaProvider.ts | 52 +++ apps/server/src/provider/OllamaTools.ts | 280 ++++++++++++ .../src/provider/Services/OllamaAdapter.ts | 9 + apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/provider/ollamaRuntime.ts | 285 +++++++++++++ .../textGeneration/OllamaTextGeneration.ts | 69 +++ apps/web/public/ollama-icon.png | Bin 0 -> 1974 bytes apps/web/src/components/Icons.tsx | 10 + .../components/KeybindingsToast.browser.tsx | 6 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 9 +- packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.ts | 45 ++ 15 files changed, 1236 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OllamaDriver.ts create mode 100644 apps/server/src/provider/Layers/OllamaAdapter.ts create mode 100644 apps/server/src/provider/Layers/OllamaProvider.ts create mode 100644 apps/server/src/provider/OllamaTools.ts create mode 100644 apps/server/src/provider/Services/OllamaAdapter.ts create mode 100644 apps/server/src/provider/ollamaRuntime.ts create mode 100644 apps/server/src/textGeneration/OllamaTextGeneration.ts create mode 100644 apps/web/public/ollama-icon.png diff --git a/apps/server/src/provider/Drivers/OllamaDriver.ts b/apps/server/src/provider/Drivers/OllamaDriver.ts new file mode 100644 index 00000000000..1d9abe39948 --- /dev/null +++ b/apps/server/src/provider/Drivers/OllamaDriver.ts @@ -0,0 +1,62 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { OllamaSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; + +import { makeOllamaTextGeneration } from "../../textGeneration/OllamaTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOllamaAdapter } from "../Layers/OllamaAdapter.ts"; +import { checkOllamaProviderStatus, makePendingOllamaProvider } from "../Layers/OllamaProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance } from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; + +const decodeOllamaSettings = Schema.decodeSync(OllamaSettings); +const DRIVER_KIND = ProviderDriverKind.make("ollama"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type OllamaDriverEnv = ProviderEventLoggers; + +const withInstanceIdentity = (input: { readonly instanceId: ProviderInstance["instanceId"]; readonly displayName: string | undefined; readonly accentColor: string | undefined; readonly continuationGroupKey: string }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OllamaDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { displayName: "Ollama", supportsMultipleInstances: true }, + configSchema: OllamaSettings, + defaultConfig: (): OllamaSettings => decodeOllamaSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const processEnv = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies OllamaSettings; + const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId }); + const stampIdentity = withInstanceIdentity({ instanceId, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey }); + + const adapter = yield* makeOllamaAdapter(effectiveConfig, processEnv, { instanceId }); + const textGeneration = yield* makeOllamaTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOllamaProviderStatus(effectiveConfig).pipe(Effect.map(stampIdentity)); + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities: { provider: DRIVER_KIND, packageName: null, update: null }, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => makePendingOllamaProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => publishSnapshot(snapshot), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe(Effect.mapError((cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, detail: `Failed to build Ollama snapshot: ${cause.message ?? String(cause)}`, cause }))); + + return { instanceId, driverKind: DRIVER_KIND, continuationIdentity, displayName, accentColor, enabled, snapshot, adapter, textGeneration } satisfies ProviderInstance; + }), +}; \ No newline at end of file diff --git a/apps/server/src/provider/Layers/OllamaAdapter.ts b/apps/server/src/provider/Layers/OllamaAdapter.ts new file mode 100644 index 00000000000..5e833a78908 --- /dev/null +++ b/apps/server/src/provider/Layers/OllamaAdapter.ts @@ -0,0 +1,400 @@ +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { + ApprovalRequestId, + EventId, + OllamaSettings, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderSendTurnInput, + type ProviderTurnStartResult, + RuntimeItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import type { OllamaAdapterShape } from "../Services/OllamaAdapter.ts"; +import { ProviderAdapterRequestError, ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError } from "../Errors.ts"; +import { ollamaChat, type OllamaChatMessage, type OllamaRuntimeError } from "../ollamaRuntime.js"; +import { OLLAMA_TOOL_DEFINITIONS, executeOllamaTool, classifyOllamaToolItemType, classifyOllamaRequestType, summarizeOllamaToolCall } from "../OllamaTools.js"; + +const PROVIDER = ProviderDriverKind.make("ollama"); + +interface PendingApproval { + readonly requestType: string; + readonly detail: string; + readonly decision: Deferred.Deferred; +} + +interface OllamaSessionContext { + session: ProviderSession; + readonly threadId: ThreadId; + readonly messages: OllamaChatMessage[]; + readonly runtimeEvents: Queue.Queue; + readonly stopped: Ref.Ref; + readonly pendingApprovals: Map; + activeModel: string; + activeTurnId: TurnId | undefined; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +const buildEventBase = (input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId; + readonly itemId?: string; +}) => + Effect.gen(function* () { + const uuid = yield* Random.nextUUIDv4; + const createdAt = yield* nowIso; + return { + eventId: EventId.make(uuid), + provider: PROVIDER, + threadId: input.threadId, + createdAt, + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + }; + }); + +export const makeOllamaAdapter = ( + ollamaSettings: OllamaSettings, + processEnv: Record, + options?: { readonly instanceId?: ProviderInstanceId }, +) => + Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("ollama"); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + const apiKey = processEnv.OLLAMA_API_KEY; + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + sessions.clear(); + yield* Queue.shutdown(runtimeEvents); + }), + ); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + + const startSession: OllamaAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input: ProviderSessionStartInput) { + sessions.delete(input.threadId); + const createdAt = yield* nowIso; + const effectiveModel = + (input.modelSelection?.model?.trim().length ?? 0) > 0 + ? input.modelSelection!.model + : ollamaSettings.model?.trim() || "qwen2.5:7b"; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: input.cwd ?? process.cwd(), + model: effectiveModel, + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + const stopped = yield* Ref.make(false); + sessions.set(input.threadId, { + session, + threadId: input.threadId, + messages: [], + runtimeEvents, + stopped, + pendingApprovals: new Map(), + activeModel: effectiveModel, + activeTurnId: undefined, + }); + return session; + }, + ); + + const sendTurn: OllamaAdapterShape["sendTurn"] = Effect.fn("sendTurn")( + function* (input: ProviderSendTurnInput) { + const context = sessions.get(input.threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId: input.threadId }); + } + if (yield* Ref.get(context.stopped)) { + return yield* new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId: input.threadId }); + } + const text = input.input?.trim(); + if (!text || text.length === 0) { + return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", issue: "Ollama turns require text input." }); + } + const model = input.modelSelection?.model ?? context.activeModel; + context.activeModel = model; + const turnId = TurnId.make(`ollama-turn-${yield* Random.nextUUIDv4}`); + context.activeTurnId = turnId; + context.messages.push({ role: "user", content: text }); + context.session = { ...context.session, status: "running", activeTurnId: turnId } as ProviderSession; + const cwd = context.session.cwd ?? process.cwd(); + const runtimeMode = context.session.runtimeMode ?? "full-access"; + const runtimeCtx = yield* Effect.context(); + const runFork = Effect.runForkWith(runtimeCtx); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { model }, + }); + + const runTurnLoop = Effect.gen(function* () { + let looping = true; + while (looping) { + if (yield* Ref.get(context.stopped)) break; + + const response = yield* ollamaChat({ + baseUrl: ollamaSettings.baseUrl, + apiKey, + model, + messages: context.messages, + tools: OLLAMA_TOOL_DEFINITIONS, + }).pipe( + Effect.catch((error: OllamaRuntimeError) => + Effect.gen(function* () { + context.activeTurnId = undefined; + context.session = { ...context.session, status: "error", lastError: error.detail } as ProviderSession; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { state: "failed", errorMessage: error.detail }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "runtime.error", + payload: { message: error.detail, class: "provider_error" }, + }); + return yield* Effect.fail(error); + }), + ), + ); + + const toolCalls = response.message.tool_calls; + if (toolCalls && toolCalls.length > 0) { + // Append assistant message with tool_calls to history + context.messages.push({ role: "assistant", content: response.message.content ?? "", tool_calls: toolCalls }); + + for (const toolCall of toolCalls) { + if (yield* Ref.get(context.stopped)) { + looping = false; + break; + } + + const toolName = toolCall.function.name; + const toolArgs = toolCall.function.arguments; + const itemType = classifyOllamaToolItemType(toolName); + const requestType = classifyOllamaRequestType(toolName); + const detail = summarizeOllamaToolCall(toolName, toolArgs); + const itemId = `ollama:tool:${turnId}:${toolName}:${yield* Random.nextUUIDv4}`; + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "item.started", + payload: { itemType, title: detail }, + }); + + let approved = true; + if (runtimeMode !== "full-access") { + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { requestType, detail, decision: decisionDeferred }; + context.pendingApprovals.set(requestId, pendingApproval); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "request.opened", + requestId: RuntimeRequestId.make(requestId), + payload: { requestType, detail, args: { toolName, input: toolArgs } }, + }); + + const decision = yield* Deferred.await(decisionDeferred); + context.pendingApprovals.delete(requestId); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "request.resolved", + requestId: RuntimeRequestId.make(requestId), + payload: { requestType, decision }, + }); + + if (decision === "cancel" || decision === "decline") { + approved = false; + looping = false; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "item.completed", + payload: { itemType, status: "declined", title: detail }, + }); + break; + } + } + + if (approved) { + const toolResult = yield* executeOllamaTool(toolCall, cwd).pipe( + Effect.catch((err) => Effect.succeed(`Error: ${err.detail}`)), + ); + + context.messages.push({ role: "tool", content: toolResult }); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "item.completed", + payload: { itemType, status: "completed", title: detail, detail: toolResult.slice(0, 500) || undefined }, + }); + } + } + } else { + // No tool calls → final assistant message + const content = response.message.content ?? ""; + context.messages.push({ role: "assistant", content }); + + if (content.length > 0) { + const itemId = `ollama:item:${turnId}:assistant`; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "content.delta", + payload: { streamKind: "assistant_text", delta: content }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId, itemId })), + type: "item.completed", + payload: { itemType: "assistant_message", status: "completed", title: "Assistant message", detail: content }, + }); + } + + looping = false; + } + } + + const isStopped = yield* Ref.get(context.stopped); + context.activeTurnId = undefined; + context.session = { ...context.session, status: "ready" } as ProviderSession; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { state: isStopped ? "interrupted" : "completed" }, + }); + }); + + runFork(runTurnLoop); + return { threadId: input.threadId, turnId } satisfies ProviderTurnStartResult; + }, + ); + + const interruptTurn: OllamaAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId: ThreadId) { + const context = sessions.get(threadId); + if (context) { + yield* Ref.set(context.stopped, true); + for (const [, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + } + context.pendingApprovals.clear(); + context.activeTurnId = undefined; + context.session = { ...context.session, status: "ready" } as ProviderSession; + } + }, + ); + + const respondToRequest: OllamaAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( + function* (threadId: ThreadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + context.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); + }, + ); + + const respondToUserInput: OllamaAdapterShape["respondToUserInput"] = Effect.fn("respondToUserInput")(function* () {}); + + const stopSession: OllamaAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId: ThreadId) { + const context = sessions.get(threadId); + if (context) { + yield* Ref.set(context.stopped, true); + for (const [, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + } + context.pendingApprovals.clear(); + sessions.delete(threadId); + } + }, + ); + + const listSessions: OllamaAdapterShape["listSessions"] = Effect.fn("listSessions")( + function* () { return Array.from(sessions.values()).map((ctx) => ctx.session); }, + ); + + const hasSession: OllamaAdapterShape["hasSession"] = Effect.fn("hasSession")( + function* (threadId: ThreadId) { return sessions.has(threadId); }, + ); + + const readThread: OllamaAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId: ThreadId) { + if (!sessions.has(threadId)) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + return { threadId, turns: [] }; + }, + ); + + const rollbackThread: OllamaAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId: ThreadId, numTurns: number) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + context.messages.splice(context.messages.length - numTurns * 2, numTurns * 2); + return { threadId, turns: [] }; + }, + ); + + const stopAll: OllamaAdapterShape["stopAll"] = Effect.fn("stopAll")(function* () { + const keys = Array.from(sessions.keys()); + for (const threadId of keys) { + yield* stopSession(threadId); + } + }); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEvents), + } satisfies OllamaAdapterShape; + }); diff --git a/apps/server/src/provider/Layers/OllamaProvider.ts b/apps/server/src/provider/Layers/OllamaProvider.ts new file mode 100644 index 00000000000..3b09f9fa851 --- /dev/null +++ b/apps/server/src/provider/Layers/OllamaProvider.ts @@ -0,0 +1,52 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; +import { type OllamaSettings, ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { buildServerProvider, providerModelsFromSettings, type ServerProviderDraft } from "../providerSnapshot.js"; +import { ollamaListModels, ollamaVersion } from "../ollamaRuntime.js"; + +const PROVIDER = ProviderDriverKind.make("ollama"); +const OLLAMA_PRESENTATION = { displayName: "Ollama", showInteractionModeToggle: false } as const; +const DEFAULT_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +export const makePendingOllamaProvider = ( + ollamaSettings: OllamaSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = providerModelsFromSettings([], PROVIDER, ollamaSettings.customModels, DEFAULT_CAPABILITIES); + if (!ollamaSettings.enabled) { + return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: false, checkedAt, models, probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Ollama is disabled." } }); + } + return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: true, checkedAt, models, probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Ollama status has not been checked yet." } }); + }); + +export const checkOllamaProviderStatus = Effect.fn("checkOllamaProviderStatus")(function* ( + ollamaSettings: OllamaSettings, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const baseUrl = ollamaSettings.baseUrl; + const apiKey = process.env.OLLAMA_API_KEY; + const customModels = ollamaSettings.customModels; + + if (!ollamaSettings.enabled) { + return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: false, checkedAt, models: providerModelsFromSettings([], PROVIDER, customModels, DEFAULT_CAPABILITIES), probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Ollama is disabled." } }); + } + + const version = yield* ollamaVersion(baseUrl, apiKey); + const modelsExit = yield* Effect.exit(ollamaListModels(baseUrl, apiKey)); + + if (Exit.isFailure(modelsExit)) { + const detail = Cause.isCause(modelsExit.cause) ? Cause.pretty(modelsExit.cause) : String(modelsExit.cause); + return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: true, checkedAt, models: providerModelsFromSettings([], PROVIDER, customModels, DEFAULT_CAPABILITIES), probe: { installed: true, version: version || null, status: "error", auth: { status: "unknown" }, message: `Could not reach Ollama at ${baseUrl}: ${detail}` } }); + } + + const rawModels = modelsExit.value as ReadonlyArray<{ name: string }>; + const remoteModels = rawModels.map((m) => ({ slug: m.name, name: m.name, isCustom: false, capabilities: DEFAULT_CAPABILITIES })); + const finalModels = providerModelsFromSettings(remoteModels, PROVIDER, customModels, DEFAULT_CAPABILITIES); + + return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: true, checkedAt, models: finalModels, probe: { installed: true, version: version || null, status: "ready", auth: { status: "authenticated", type: "ollama" }, message: `${finalModels.length} model${finalModels.length === 1 ? "" : "s"} available via Ollama at ${baseUrl}.` } }); +}); \ No newline at end of file diff --git a/apps/server/src/provider/OllamaTools.ts b/apps/server/src/provider/OllamaTools.ts new file mode 100644 index 00000000000..f718a4a54d0 --- /dev/null +++ b/apps/server/src/provider/OllamaTools.ts @@ -0,0 +1,280 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as fs from "node:fs"; +import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type { CanonicalItemType, CanonicalRequestType } from "@t3tools/contracts"; +import type { OllamaToolDefinition, OllamaToolCall } from "./ollamaRuntime.js"; + +// ── Error ────────────────────────────────────────────────────────────── + +export class OllamaToolError extends Data.TaggedError("OllamaToolError")<{ + readonly toolName: string; + readonly detail: string; + readonly cause?: unknown; +}> {} + +// ── Tool definitions ─────────────────────────────────────────────────── + +export const OLLAMA_TOOL_DEFINITIONS: readonly OllamaToolDefinition[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read the contents of a file. Returns the file content as text.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to read." }, + offset: { type: "integer", description: "Line number to start reading from (1-based, optional)." }, + limit: { type: "integer", description: "Maximum number of lines to read (optional)." }, + }, + required: ["path"], + }, + }, + }, + { + type: "function", + function: { + name: "write_file", + description: "Create or overwrite a file with the given content.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to write." }, + content: { type: "string", description: "Content to write to the file." }, + }, + required: ["path", "content"], + }, + }, + }, + { + type: "function", + function: { + name: "bash", + description: "Run a shell command and return its output.", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Shell command to run." }, + cwd: { type: "string", description: "Working directory for the command (optional)." }, + }, + required: ["command"], + }, + }, + }, + { + type: "function", + function: { + name: "list_directory", + description: "List the files and directories inside a path.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Directory path to list." }, + recursive: { type: "boolean", description: "List recursively (optional, default false)." }, + }, + required: ["path"], + }, + }, + }, + { + type: "function", + function: { + name: "search_files", + description: "Search for a pattern in files using grep.", + parameters: { + type: "object", + properties: { + pattern: { type: "string", description: "Regex or literal pattern to search for." }, + path: { type: "string", description: "Directory to search in (optional, defaults to cwd)." }, + file_glob: { type: "string", description: "File glob pattern to filter files (optional, e.g. '*.ts')." }, + }, + required: ["pattern"], + }, + }, + }, + { + type: "function", + function: { + name: "edit_file", + description: "Edit a file by replacing an exact string with a new string. Use this for targeted edits rather than rewriting the whole file. Fails if old_string is not found or appears more than once.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to edit." }, + old_string: { type: "string", description: "Exact string to replace (must appear exactly once in the file)." }, + new_string: { type: "string", description: "Replacement string." }, + }, + required: ["path", "old_string", "new_string"], + }, + }, + }, + { + type: "function", + function: { + name: "web_fetch", + description: "Fetch the content of a URL and return it as text. Useful for reading documentation, GitHub files, or any public web page.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "URL to fetch." }, + max_length: { type: "integer", description: "Maximum number of characters to return (optional, default 20000)." }, + }, + required: ["url"], + }, + }, + }, +] as const; + +// ── Classification ───────────────────────────────────────────────────── + +export function classifyOllamaToolItemType(toolName: string): CanonicalItemType { + if (toolName === "bash") return "command_execution"; + if (toolName === "write_file" || toolName === "edit_file") return "file_change"; + return "dynamic_tool_call"; +} + +export function classifyOllamaRequestType(toolName: string): CanonicalRequestType { + if (toolName === "bash") return "command_execution_approval"; + if (toolName === "write_file" || toolName === "edit_file") return "file_change_approval"; + if (toolName === "read_file") return "file_read_approval"; + return "dynamic_tool_call"; +} + +export function summarizeOllamaToolCall(toolName: string, args: Record): string { + switch (toolName) { + case "read_file": return `Read file: ${args.path}`; + case "write_file": return `Write file: ${args.path}`; + case "edit_file": return `Edit file: ${args.path}`; + case "bash": return `Run: ${args.command}`; + case "list_directory": return `List directory: ${args.path}`; + case "search_files": return `Search "${args.pattern}"${args.path ? ` in ${args.path}` : ""}`; + case "web_fetch": return `Fetch: ${args.url}`; + default: return `Tool call: ${toolName}`; + } +} + +// ── Execution ────────────────────────────────────────────────────────── + +function listDirRecursive(dirPath: string, prefix = ""): string[] { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const lines: string[] = []; + for (const entry of entries) { + lines.push(`${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`); + if (entry.isDirectory()) { + lines.push(...listDirRecursive(path.join(dirPath, entry.name), `${prefix}${entry.name}/`)); + } + } + return lines; +} + +export const executeOllamaTool = ( + call: OllamaToolCall, + cwd: string, +): Effect.Effect => { + const name = call.function.name; + const args = call.function.arguments; + + if (name === "web_fetch") { + const url = String(args.url); + const maxLength = typeof args.max_length === "number" ? args.max_length : 20_000; + return Effect.tryPromise({ + try: async () => { + const response = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; T3Code/1.0)" }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const text = await response.text(); + // Strip HTML tags for readability when content looks like HTML + const stripped = text.includes("]+>/g, " ").replace(/\s{2,}/g, " ").trim() : text; + return stripped.length > maxLength ? stripped.slice(0, maxLength) + "\n…(truncated)" : stripped; + }, + catch: (cause) => new OllamaToolError({ toolName: name, detail: cause instanceof Error ? cause.message : String(cause), cause }), + }); + } + + return Effect.try({ + try: () => { + + if (name === "read_file") { + const filePath = path.resolve(cwd, String(args.path)); + const raw = fs.readFileSync(filePath, "utf8"); + const lines = raw.split("\n"); + const offset = typeof args.offset === "number" ? Math.max(0, args.offset - 1) : 0; + const limit = typeof args.limit === "number" ? args.limit : lines.length; + return lines.slice(offset, offset + limit).join("\n"); + } + + if (name === "write_file") { + const filePath = path.resolve(cwd, String(args.path)); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, String(args.content ?? ""), "utf8"); + return `Wrote ${filePath}`; + } + + if (name === "bash") { + // spawnSync with shell:true is intentional here — the bash tool is a + // deliberate shell executor. User must approve via the approval flow + // before this code runs, so the command is safe-by-consent. + const result = spawnSync(String(args.command), [], { + cwd: args.cwd ? String(args.cwd) : cwd, + encoding: "utf8", + shell: true, + timeout: 30_000, + }); + if (result.error) throw result.error; + const out = (result.stdout ?? "") + (result.stderr ? `\nSTDERR: ${result.stderr}` : ""); + return out || `(exit code ${result.status ?? 0})`; + } + + if (name === "list_directory") { + const dirPath = path.resolve(cwd, String(args.path)); + const recursive = args.recursive === true; + if (recursive) { + return listDirRecursive(dirPath).join("\n"); + } + return fs.readdirSync(dirPath).join("\n"); + } + + if (name === "search_files") { + const searchPath = args.path ? path.resolve(cwd, String(args.path)) : cwd; + const grepArgs: string[] = ["-r", "-n"]; + if (args.file_glob) grepArgs.push("--include", String(args.file_glob)); + grepArgs.push(String(args.pattern), searchPath); + const result = spawnSync("grep", grepArgs, { + cwd, + encoding: "utf8", + timeout: 15_000, + }); + if (result.error) throw result.error; + // grep exits with status 1 when no matches — not an error + if (result.status === 1) return "(no matches)"; + if (result.status !== 0) throw new Error(`grep exited with status ${result.status}: ${result.stderr}`); + return result.stdout || "(no matches)"; + } + + if (name === "edit_file") { + const filePath = path.resolve(cwd, String(args.path)); + const content = fs.readFileSync(filePath, "utf8"); + const oldStr = String(args.old_string); + const newStr = String(args.new_string); + const count = content.split(oldStr).length - 1; + if (count === 0) throw new Error(`old_string not found in ${args.path}`); + if (count > 1) throw new Error(`old_string found ${count} times in ${args.path} — be more specific`); + fs.writeFileSync(filePath, content.replace(oldStr, newStr), "utf8"); + return `Edited ${filePath}`; + } + + return `Unknown tool: ${name}`; + }, + catch: (cause) => + new OllamaToolError({ + toolName: name, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); +}; diff --git a/apps/server/src/provider/Services/OllamaAdapter.ts b/apps/server/src/provider/Services/OllamaAdapter.ts new file mode 100644 index 00000000000..76f7dc4144b --- /dev/null +++ b/apps/server/src/provider/Services/OllamaAdapter.ts @@ -0,0 +1,9 @@ +/** + * OllamaAdapter — shape type for the Ollama provider adapter. + * + * @module OllamaAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OllamaAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..6f266c20ea6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { OllamaDriver, type OllamaDriverEnv } from "./Drivers/OllamaDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | OllamaDriverEnv | OpenCodeDriverEnv; /** @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray; + }; +} + +export interface OllamaToolCall { + readonly function: { + readonly name: string; + readonly arguments: Record; + }; +} + +export interface OllamaChatMessage { + readonly role: OllamaChatRole; + readonly content: string; + readonly tool_calls?: readonly OllamaToolCall[]; +} + +export interface OllamaChatResponse { + readonly model: string; + readonly createdAt: string; + readonly message: OllamaChatMessage; + readonly done: boolean; + readonly doneReason?: string; + readonly totalDuration?: number; +} + +export interface OllamaChatChunk { + readonly model: string; + readonly createdAt: string; + readonly message: { readonly role: OllamaChatRole; readonly content: string; readonly tool_calls?: readonly OllamaToolCall[] }; + readonly done: boolean; + readonly doneReason?: string; +} + +export interface OllamaModelInfo { + readonly name: string; + readonly modifiedAt: string; + readonly size: number; + readonly digest: string; +} + +// ── Error ────────────────────────────────────────────────────────────── + +const RUNTIME_ERROR_TAG = "OllamaRuntimeError"; + +export class OllamaRuntimeError extends Data.TaggedError(RUNTIME_ERROR_TAG)<{ + readonly operation: string; + readonly detail: string; + readonly cause?: unknown; +}> { + static readonly is = (u: unknown): u is OllamaRuntimeError => + typeof u === "object" && u !== null && (u as Record)._tag === RUNTIME_ERROR_TAG; +} + +function fail(operation: string, detail: string, cause?: unknown): Effect.Effect { + return Effect.fail(new OllamaRuntimeError({ operation, detail, cause })); +} + +// ── Headers helper ───────────────────────────────────────────────────── + +function buildHeaders(apiKey?: string): Record { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey && apiKey.trim().length > 0) { + headers["Authorization"] = `Bearer ${apiKey}`; + } + return headers; +} + +// ── Non-streaming chat ───────────────────────────────────────────────── + +export const ollamaChat = (input: { + readonly baseUrl: string; + readonly apiKey?: string | undefined; + readonly model: string; + readonly messages: ReadonlyArray; + readonly tools?: ReadonlyArray; + readonly options?: Record; + readonly signal?: AbortSignal; +}) => + Effect.gen(function* () { + const body = { + model: input.model, + messages: [...input.messages], + stream: false, + ...(input.tools && input.tools.length > 0 ? { tools: [...input.tools] } : {}), + ...(input.options ? { options: input.options } : {}), + }; + let response: Response; + try { + response = yield* Effect.promise(() => + fetch(`${input.baseUrl}/api/chat`, { + method: "POST", + headers: buildHeaders(input.apiKey), + body: JSON.stringify(body), + signal: input.signal ?? null, + }), + ); + } catch (cause) { + return yield* fail("ollamaChat", String(cause), cause); + } + if (!response.ok) { + const text = yield* Effect.promise(() => response.text()); + return yield* fail("ollamaChat", `Ollama /api/chat returned status ${response.status}: ${text}`); + } + const json = (yield* Effect.promise(() => response.json())) as Record; + if ( + typeof json.model !== "string" || + typeof json.message !== "object" || !json.message || + typeof (json.message as Record).role !== "string" || + typeof json.done !== "boolean" + ) { + return yield* fail("ollamaChat", "Ollama /api/chat returned unexpected response shape.", json); + } + const msgObj = json.message as Record; + const createdAt = (json.created_at ?? json.createdAt ?? "") as string; + const doneReason = (json.done_reason ?? json.doneReason) as string | undefined; + const totalDuration = (json.total_duration ?? json.totalDuration) as number | undefined; + return { + model: json.model as string, + createdAt, + message: { + role: msgObj.role as OllamaChatRole, + content: (msgObj.content ?? "") as string, + ...(Array.isArray(msgObj.tool_calls) ? { tool_calls: msgObj.tool_calls as readonly OllamaToolCall[] } : {}), + }, + done: json.done as boolean, + ...(typeof doneReason === "string" ? { doneReason } : {}), + ...(typeof totalDuration === "number" ? { totalDuration } : {}), + } satisfies OllamaChatResponse; + }); + +// ── Streaming chat (SSE via fetch) ───────────────────────────────────── + +export const ollamaChatStream = (input: { + readonly baseUrl: string; + readonly apiKey?: string | undefined; + readonly model: string; + readonly messages: ReadonlyArray; + readonly tools?: ReadonlyArray; + readonly options?: Record; + readonly signal?: AbortSignal; +}): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const body = { + model: input.model, + messages: [...input.messages], + stream: true, + ...(input.tools && input.tools.length > 0 ? { tools: [...input.tools] } : {}), + ...(input.options ? { options: input.options } : {}), + }; + let responseBody: ReadableStream; + try { + const response = yield* Effect.promise(() => + fetch(`${input.baseUrl}/api/chat`, { + method: "POST", + headers: buildHeaders(input.apiKey), + body: JSON.stringify(body), + signal: input.signal ?? null, + }), + ); + if (!response.ok) { + const text = yield* Effect.promise(() => response.text()); + return yield* fail("ollamaChatStream", `Ollama /api/chat stream returned status ${response.status}: ${text}`); + } + if (!response.body) { + return yield* fail("ollamaChatStream", "Ollama response body is null."); + } + responseBody = response.body; + } catch (cause) { + return yield* fail("ollamaChatStream", String(cause), cause); + } + return Stream.fromAsyncIterable( + lineByLine(responseBody), + (cause) => + new OllamaRuntimeError({ + operation: "ollamaChatStream.parse", + detail: String(cause), + cause, + }), + ).pipe( + Stream.filter((line) => line.trim().length > 0), + Stream.mapEffect((line) => + Effect.try({ + try: () => JSON.parse(line) as OllamaChatChunk, + catch: (cause) => + new OllamaRuntimeError({ + operation: "ollamaChatStream.parse", + detail: `Failed to parse SSE line: ${line.slice(0, 200)}`, + cause, + }), + }), + ), + ); + }), + ); + +async function* lineByLine(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + if (buffer.length > 0) yield buffer; + return; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + yield line; + } + } + } finally { + reader.releaseLock(); + } +} + +// ── Model listing ────────────────────────────────────────────────────── + +export const ollamaListModels = (baseUrl: string, apiKey?: string) => + Effect.gen(function* () { + let response: Response; + try { + response = yield* Effect.promise(() => + fetch(`${baseUrl}/api/tags`, { headers: buildHeaders(apiKey) }), + ); + } catch (cause) { + return yield* fail("ollamaListModels", String(cause), cause); + } + if (!response.ok) { + const text = yield* Effect.promise(() => response.text()); + return yield* fail("ollamaListModels", `Ollama /api/tags returned status ${response.status}: ${text}`); + } + const json = (yield* Effect.promise(() => response.json())) as Record; + if (!json || typeof json !== "object" || !Array.isArray(json.models)) { + return yield* fail("ollamaListModels", "Ollama /api/tags returned unexpected response shape.", json); + } + return (json.models as Array>).map((m) => ({ + name: String(m.name ?? ""), + modifiedAt: String(m.modifiedAt ?? ""), + size: typeof m.size === "number" ? m.size : 0, + digest: String(m.digest ?? ""), + })) satisfies ReadonlyArray; + }); + +// ── Version check ────────────────────────────────────────────────────── + +export const ollamaVersion = (baseUrl: string, apiKey?: string) => + Effect.gen(function* () { + try { + const response = yield* Effect.promise(() => + fetch(`${baseUrl}/api/version`, { headers: buildHeaders(apiKey) }), + ); + if (!response.ok) return ""; + const json = (yield* Effect.promise(() => response.json())) as Record; + return typeof json.version === "string" ? json.version : ""; + } catch { + return ""; + } + }); diff --git a/apps/server/src/textGeneration/OllamaTextGeneration.ts b/apps/server/src/textGeneration/OllamaTextGeneration.ts new file mode 100644 index 00000000000..c385c6d59d2 --- /dev/null +++ b/apps/server/src/textGeneration/OllamaTextGeneration.ts @@ -0,0 +1,69 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { TextGenerationError, type ModelSelection, type OllamaSettings } from "@t3tools/contracts"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt } from "./TextGenerationPrompts.ts"; +import { type TextGenerationShape, type CommitMessageGenerationInput, type PrContentGenerationInput, type BranchNameGenerationInput, type ThreadTitleGenerationInput } from "./TextGeneration.ts"; +import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { ollamaChat } from "../provider/ollamaRuntime.js"; + +export const makeOllamaTextGeneration = Effect.fn("makeOllamaTextGeneration")(function* ( + ollamaSettings: OllamaSettings, + processEnv?: Record, +) { + const apiKey = processEnv?.OLLAMA_API_KEY; + const resolveModel = (modelSelection: ModelSelection): string => + modelSelection.model?.trim() || "qwen2.5:7b"; + + const runOllamaJson = (input: { + readonly operation: string; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + }) => + Effect.gen(function* () { + const model = resolveModel(input.modelSelection); + const response = yield* ollamaChat({ baseUrl: ollamaSettings.baseUrl, apiKey, model, messages: [{ role: "user", content: input.prompt }] }); + const rawText = response.message.content.trim(); + if (rawText.length === 0) { + return yield* Effect.fail(new TextGenerationError({ operation: input.operation, detail: "Ollama returned empty output." })); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawText)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail(new TextGenerationError({ operation: input.operation, detail: "Ollama returned invalid structured output.", cause })), + ), + ); + }).pipe( + Effect.mapError((cause) => new TextGenerationError({ operation: input.operation, detail: cause instanceof Error ? cause.message : String(cause), cause })), + ); + + return { + generateCommitMessage: (input: CommitMessageGenerationInput) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildCommitMessagePrompt({ branch: input.branch, stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true }); + const generated = yield* runOllamaJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection }); + return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), ...("branch" in generated && typeof generated.branch === "string" ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}) }; + }), + generatePrContent: (input: PrContentGenerationInput) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildPrContentPrompt({ baseBranch: input.baseBranch, headBranch: input.headBranch, commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch }); + const generated = yield* runOllamaJson({ operation: "generatePrContent", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }), + generateBranchName: (input: BranchNameGenerationInput) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments }); + const generated = yield* runOllamaJson({ operation: "generateBranchName", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }), + generateThreadTitle: (input: ThreadTitleGenerationInput) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, attachments: input.attachments }); + const generated = yield* runOllamaJson({ operation: "generateThreadTitle", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection }); + return { title: sanitizeThreadTitle(generated.title) }; + }), + } satisfies TextGenerationShape; +}); diff --git a/apps/web/public/ollama-icon.png b/apps/web/public/ollama-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c3d50acb4853d69f140a844120c990541f17626d GIT binary patch literal 1974 zcmV;n2TAyeP)ai00001b5ch_0olnc ze*gdg1ZP1_K>z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;wH)0002_L%V+f000SaNLh0L04^f{04^f|c%?sf00007 zbV*G`2kHe74>$r5KhQw{00#O=L_t(|+U?!Za_cG#g<+$~`@gc!&F-Acp6oVtuq}!2 zzwV@fmY0RVux$VU00000000000000000000Kt@K!M(z8)|Jd*`hVY3)dQrbKhy*SM zB}9F6Ac~&)hLX6Gg zOC5*c)@#OKbRSH@UM~jH4@f^MIs~^~D+ZhzoVuz*tnAjS#h`>!k3aArF=!#`=YgCt zsM%brf=kbiL1|I1CLrX{MO1NV6$~+GUH$lUP*-__eA>^d1`-Ao-Hb(@8tsUxE+7TD z|4o_oZ5pZ`=vD=ZmGe6E9M>?Q=oYW=`qM8HMNYwaR}-~KBJS2pa0zsa+e1hk+0~6l z7pFKXu6^J4-wNr>6zlKbzOrpy9w<(oe6H~Pf4MkOx1015c(I9MrXcNq$o{}(YGbDO zaq8mb$gehY?vq4awMN~cwqCgpM<(k_ER%C8<7e*&OcVj428bFUYJjM*sZ(P+twd(u zIZ?N(^S=6fLMQ5W3l`76N8m)=ZsqOk{}4A(_q&pqxHr|0ru!n?rogG&Zt~&W3pw@7 zAXjk3+3oA}kVroYZ^4v~Ty*8!(I@_$0G;h7l|_706n3*oaJ6Y*(WvW?yF zSy8Xoxbc@$x64cA*^7&s0En8P^^K@O*hDSh`k7JGtF6Q#Up(9-Ks1}11PTk9ORpj9 z;LzN z6sC##%*GQlanI7nxVyo`Ox&}z63($eH=`Qv-$o_Jen&flpixcSKWQi|f}mU6$CalZ z2`|UKLQjLB(M{MP{l8K4vnq*hqv}eqk0+=TpE>y{mO)6`d0@8 z4Tsr)TPGGZm&TT$k`g9}a6UDchMXEW-xQxSjo{K~>D2ORbjq!zAK1E8K|<;Q6Gb3- z3KH^ZaB8Usq#poLLtZ_I8rc3X>pcuZ%~Oc*ZywdfbB6%Vt{6T4ey09adDzC$0A1C= zciVS%doq+hv-I(b*ncwKX+@o-T^Z~6S^9XU20j^gId#Dcf4eCECcRB2(`))wX4cC=k*xpkIt`f5jX za>f+|olV#o$0@qxRx|rhW;1-VKF@mVN7lQie>2Gc`r9Mzs`h{%Y440LTkfa?wOA&9 zXW&+J=sU;YEHTflPCY6t%U*Iz>Pa_6y|b7yYqI82*T0;)Tg~$7ve$S2Z-2thy7Su- zc8B$uB(1p5<5j=os#h>jdZJiK*xk~uO8q(O?G$GX%ALaYwpcqxL0lZR+rtzq4{+BS zcjowAu~#Uh0{4n3^U7S+u@0}p`!@9(V_%a?eel-U#mWQUb@spd1f5fZsDaX=uDTyV zzS=uBKj|{8syRz<8K+)#*BI)%wVe8Fm^?D4VdyRPJNk$z6-6cMo10ws}SIC`zS^cPf+->KtwY#qy zq&&=~LcxFLMVi^~=U#as?6KNP*3A1R0Y|KWu~Sb2*T$zC_(d+{8@OZNyE?-<0%LDdlx3{~7b|$A(j1cuzPX zOFx#l9-4rOT3(CIVKyLYfT#hY28bFUY7jL*)BsTfL=6x%K-3^=U@Ld-hYtm?H97l( zQ$zXz5H(Oy)Xq#sCLM8V^mS?#3~Wul6%EJZqTBf@(hry@0_Co8?tGaM`d?zisUiI^ zVMGPJj1}+fL+`sSr61@&QRLRBeuHnguMpi1vJWncZbvAG+hx)1x{;6*quW9E;Vy7= zyA&!^FwpHf5_M{9OD>ahYgD>4D&)lImXnV=SAIa4Wvd@RioG9P8dcqzQ)Bay0GCE` zU4)7Ux}`9|rP0l;<N$BxJL=6x%K-2(H19ynp zSLn#1pRmy%TeGjxr5})fNI{VOCR|R9{!T5gM)lL3I5pbwA4s6O>Oo>Eo&W#<07*qo IM6N<$f+*0qo&W#< literal 0 HcmV?d00001 diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b3211e17753..56214c5410f 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -651,6 +651,16 @@ export const OpenCodeIcon: Icon = (props) => ( ); +export const OllamaIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( > = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("ollama")]: OllamaIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 8d3d7482f62..827ef3b4512 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,11 +2,12 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + OllamaSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon, OllamaIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -59,6 +60,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("ollama"), + label: "Ollama", + icon: OllamaIcon, + settingsSchema: OllamaSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79..5a4bd856108 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OLLAMA_DRIVER_KIND = ProviderDriverKind.make("ollama"); export const DEFAULT_MODEL = "gpt-5.4"; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; @@ -140,6 +141,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OLLAMA_DRIVER_KIND]: "Ollama", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..e1752678ce2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,42 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const OllamaSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + baseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://localhost:11434")), + Schema.annotateKey({ + title: "Ollama server URL", + description: "URL of the Ollama server. Local (http://localhost:11434) or cloud (https://ollama.com). API key via OLLAMA_API_KEY env variable.", + providerSettingsForm: { + placeholder: "http://localhost:11434", + clearWhenEmpty: "omit", + }, + }), + ), + model: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Default model", + description: "Default model slug. Overridden by per-session model selection.", + providerSettingsForm: { placeholder: "qwen2.5:7b", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["baseUrl", "model"], + }, +); +export type OllamaSettings = typeof OllamaSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -370,6 +406,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + ollama: OllamaSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -445,6 +482,13 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OllamaSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + baseUrl: Schema.optionalKey(TrimmedString), + model: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -464,6 +508,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + ollama: Schema.optionalKey(OllamaSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 21acf51d032101f28e792a3991ceb5da0578afa5 Mon Sep 17 00:00:00 2001 From: 3L0935 Date: Sat, 16 May 2026 22:38:49 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(ollama):=20all=207=20bot=20review=20fix?= =?UTF-8?q?es=20=E2=80=94=20Effect=20isolation,=20fiber=20lifecycle,=20rol?= =?UTF-8?q?lback=20indices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - **ollamaRuntime.ts**: `Effect.promise` → `Effect.tryPromise` (4 call sites). Network errors are now typed `OllamaRuntimeError` failures instead of unrecoverable defects. `TextDecoder` now flushes on the final chunk to prevent UTF-8 truncation at multi-byte boundaries. - **OllamaProvider.ts**: `checkOllamaProviderStatus` receives `processEnv` per-instance instead of reading `process.env.OLLAMA_API_KEY` globally. - **OllamaDriver.ts**: passes `processEnv` from `mergeProviderInstanceEnvironment` to `checkOllamaProviderStatus` (was missing before — provider status check was broken when OLLAMA_API_KEY came from instance env rather than global env). - **OllamaAdapter.ts**: tracks `Fiber` from `runFork` in `context.activeFiber`. Finalizer and `stopSession` now interrupt the fiber — no more leaks on session shutdown. `rollbackThread` uses per-turn message indices instead of `numTurns*2`, correctly handling tool-call turns. - **OllamaTextGeneration.ts**: default fallback model aligned to `llama3.2` (matches `contracts/DEFAULT_MODEL_BY_PROVIDER`). ## Why The Ollama provider runtime was built on `Effect.promise` which converts errors to `die()` defects (unrecoverable). Network flakiness, timeouts, or DNS failures would crash the Effect fiber instead of being catchable `fail()` errors. Each call site has been migrated to `Effect.tryPromise` so all network-layer errors flow through the standard error channel. The session lifecycle had two leaks: (1) fibers not interrupted on `stopSession` or finalizer (would keep running after session teardown), and (2) `rollbackThread` using `numTurns*2` which breaks when turns contain tool-call exchanges (odd number of messages). Both fixed. The `processEnv` isolation bug meant `checkOllamaProviderStatus` read the global environment, so instances configured via workspace env (not global) would fail provider health checks. ## Checklist - [x] This PR is small and focused - [x] I explained what changed and why - [N/A] I included before/after screenshots for any UI changes - [N/A] I included a video for animation/interaction changes --- .../src/provider/Drivers/OllamaDriver.ts | 2 +- .../src/provider/Layers/OllamaAdapter.ts | 22 ++++- .../src/provider/Layers/OllamaProvider.ts | 7 +- apps/server/src/provider/ollamaRuntime.ts | 92 ++++++++++--------- 4 files changed, 75 insertions(+), 48 deletions(-) diff --git a/apps/server/src/provider/Drivers/OllamaDriver.ts b/apps/server/src/provider/Drivers/OllamaDriver.ts index 1d9abe39948..da72fbe03e5 100644 --- a/apps/server/src/provider/Drivers/OllamaDriver.ts +++ b/apps/server/src/provider/Drivers/OllamaDriver.ts @@ -45,7 +45,7 @@ export const OllamaDriver: ProviderDriver = { const adapter = yield* makeOllamaAdapter(effectiveConfig, processEnv, { instanceId }); const textGeneration = yield* makeOllamaTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkOllamaProviderStatus(effectiveConfig).pipe(Effect.map(stampIdentity)); + const checkProvider = checkOllamaProviderStatus(effectiveConfig, processEnv).pipe(Effect.map(stampIdentity)); const snapshot = yield* makeManagedServerProvider({ maintenanceCapabilities: { provider: DRIVER_KIND, packageName: null, update: null }, getSettings: Effect.succeed(effectiveConfig), diff --git a/apps/server/src/provider/Layers/OllamaAdapter.ts b/apps/server/src/provider/Layers/OllamaAdapter.ts index 5e833a78908..75908ad40c4 100644 --- a/apps/server/src/provider/Layers/OllamaAdapter.ts +++ b/apps/server/src/provider/Layers/OllamaAdapter.ts @@ -46,6 +46,9 @@ interface OllamaSessionContext { readonly pendingApprovals: Map; activeModel: string; activeTurnId: TurnId | undefined; + activeFiber: Fiber.RuntimeFiber | undefined; + /** message count at the start of each turn (indexed by turn number, 0 = before first user message) */ + readonly turnMessageIndices: number[]; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -81,6 +84,12 @@ export const makeOllamaAdapter = ( yield* Effect.addFinalizer(() => Effect.gen(function* () { + for (const [, context] of sessions) { + if (context.activeFiber) { + yield* Ref.set(context.stopped, true); + yield* Fiber.interrupt(context.activeFiber); + } + } sessions.clear(); yield* Queue.shutdown(runtimeEvents); }), @@ -118,6 +127,8 @@ export const makeOllamaAdapter = ( pendingApprovals: new Map(), activeModel: effectiveModel, activeTurnId: undefined, + activeFiber: undefined, + turnMessageIndices: [0], }); return session; }, @@ -291,7 +302,8 @@ export const makeOllamaAdapter = ( }); }); - runFork(runTurnLoop); + const fiber = runFork(runTurnLoop); + context.activeFiber = fiber; return { threadId: input.threadId, turnId } satisfies ProviderTurnStartResult; }, ); @@ -369,7 +381,13 @@ export const makeOllamaAdapter = ( if (!context) { return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); } - context.messages.splice(context.messages.length - numTurns * 2, numTurns * 2); + // Use per-turn message indices to correctly handle turns with tool calls + const indices = context.turnMessageIndices; + if (indices.length <= 1 || numTurns <= 0) return { threadId, turns: [] }; + const targetTurn = Math.max(0, indices.length - 1 - numTurns); + const rollbackIndex = indices[targetTurn]!; + context.messages.splice(rollbackIndex); + context.turnMessageIndices.splice(targetTurn + 1); return { threadId, turns: [] }; }, ); diff --git a/apps/server/src/provider/Layers/OllamaProvider.ts b/apps/server/src/provider/Layers/OllamaProvider.ts index 3b09f9fa851..17da4678a82 100644 --- a/apps/server/src/provider/Layers/OllamaProvider.ts +++ b/apps/server/src/provider/Layers/OllamaProvider.ts @@ -24,12 +24,11 @@ export const makePendingOllamaProvider = ( return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: true, checkedAt, models, probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Ollama status has not been checked yet." } }); }); -export const checkOllamaProviderStatus = Effect.fn("checkOllamaProviderStatus")(function* ( - ollamaSettings: OllamaSettings, -) { +export const checkOllamaProviderStatus = (ollamaSettings: OllamaSettings, processEnv: Record) => + Effect.gen(function* () { const checkedAt = DateTime.formatIso(yield* DateTime.now); const baseUrl = ollamaSettings.baseUrl; - const apiKey = process.env.OLLAMA_API_KEY; + const apiKey = processEnv.OLLAMA_API_KEY; const customModels = ollamaSettings.customModels; if (!ollamaSettings.enabled) { diff --git a/apps/server/src/provider/ollamaRuntime.ts b/apps/server/src/provider/ollamaRuntime.ts index c944bb1a1cc..d25e6aeff00 100644 --- a/apps/server/src/provider/ollamaRuntime.ts +++ b/apps/server/src/provider/ollamaRuntime.ts @@ -108,23 +108,27 @@ export const ollamaChat = (input: { ...(input.options ? { options: input.options } : {}), }; let response: Response; - try { - response = yield* Effect.promise(() => + response = yield* Effect.tryPromise({ + try: () => fetch(`${input.baseUrl}/api/chat`, { method: "POST", headers: buildHeaders(input.apiKey), body: JSON.stringify(body), signal: input.signal ?? null, }), - ); - } catch (cause) { - return yield* fail("ollamaChat", String(cause), cause); - } + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaChat", detail: String(cause), cause }), + }); if (!response.ok) { - const text = yield* Effect.promise(() => response.text()); + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaChat.text", detail: String(cause), cause }), + }); return yield* fail("ollamaChat", `Ollama /api/chat returned status ${response.status}: ${text}`); } - const json = (yield* Effect.promise(() => response.json())) as Record; + const json = (yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaChat.json", detail: String(cause), cause }), + })) as Record; if ( typeof json.model !== "string" || typeof json.message !== "object" || !json.message || @@ -172,26 +176,27 @@ export const ollamaChatStream = (input: { ...(input.options ? { options: input.options } : {}), }; let responseBody: ReadableStream; - try { - const response = yield* Effect.promise(() => + const response = yield* Effect.tryPromise({ + try: () => fetch(`${input.baseUrl}/api/chat`, { method: "POST", headers: buildHeaders(input.apiKey), body: JSON.stringify(body), signal: input.signal ?? null, }), - ); - if (!response.ok) { - const text = yield* Effect.promise(() => response.text()); - return yield* fail("ollamaChatStream", `Ollama /api/chat stream returned status ${response.status}: ${text}`); - } - if (!response.body) { - return yield* fail("ollamaChatStream", "Ollama response body is null."); - } - responseBody = response.body; - } catch (cause) { - return yield* fail("ollamaChatStream", String(cause), cause); + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaChatStream", detail: String(cause), cause }), + }); + if (!response.ok) { + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaChatStream.text", detail: String(cause), cause }), + }); + return yield* fail("ollamaChatStream", `Ollama /api/chat stream returned status ${response.status}: ${text}`); } + if (!response.body) { + return yield* fail("ollamaChatStream", "Ollama response body is null."); + } + responseBody = response.body; return Stream.fromAsyncIterable( lineByLine(responseBody), (cause) => @@ -225,6 +230,9 @@ async function* lineByLine(stream: ReadableStream): AsyncGenerator 0) buffer += flush; if (buffer.length > 0) yield buffer; return; } @@ -244,19 +252,21 @@ async function* lineByLine(stream: ReadableStream): AsyncGenerator Effect.gen(function* () { - let response: Response; - try { - response = yield* Effect.promise(() => - fetch(`${baseUrl}/api/tags`, { headers: buildHeaders(apiKey) }), - ); - } catch (cause) { - return yield* fail("ollamaListModels", String(cause), cause); - } + const response = yield* Effect.tryPromise({ + try: () => fetch(`${baseUrl}/api/tags`, { headers: buildHeaders(apiKey) }), + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaListModels", detail: String(cause), cause }), + }); if (!response.ok) { - const text = yield* Effect.promise(() => response.text()); + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaListModels.text", detail: String(cause), cause }), + }); return yield* fail("ollamaListModels", `Ollama /api/tags returned status ${response.status}: ${text}`); } - const json = (yield* Effect.promise(() => response.json())) as Record; + const json = (yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new OllamaRuntimeError({ operation: "ollamaListModels.json", detail: String(cause), cause }), + })) as Record; if (!json || typeof json !== "object" || !Array.isArray(json.models)) { return yield* fail("ollamaListModels", "Ollama /api/tags returned unexpected response shape.", json); } @@ -272,14 +282,14 @@ export const ollamaListModels = (baseUrl: string, apiKey?: string) => export const ollamaVersion = (baseUrl: string, apiKey?: string) => Effect.gen(function* () { - try { - const response = yield* Effect.promise(() => - fetch(`${baseUrl}/api/version`, { headers: buildHeaders(apiKey) }), - ); - if (!response.ok) return ""; - const json = (yield* Effect.promise(() => response.json())) as Record; - return typeof json.version === "string" ? json.version : ""; - } catch { - return ""; - } + const response = yield* Effect.tryPromise({ + try: () => fetch(`${baseUrl}/api/version`, { headers: buildHeaders(apiKey) }), + catch: () => new OllamaRuntimeError({ operation: "ollamaVersion", detail: "Failed to reach Ollama /api/version" }), + }); + if (!response.ok) return ""; + const json = (yield* Effect.tryPromise({ + try: () => response.json(), + catch: () => new OllamaRuntimeError({ operation: "ollamaVersion.json", detail: "Failed to parse /api/version response" }), + })) as Record; + return typeof json.version === "string" ? json.version : ""; }); From 6c74fed6d79a82a0b947f13174bd877ede9fbb05 Mon Sep 17 00:00:00 2001 From: 3L0935 Date: Sat, 16 May 2026 23:06:31 +0200 Subject: [PATCH 3/6] fix(ollama): resolve 6 bot-review issues - interruptTurn no longer permanently closes the session: sendTurn now clears the stopped flag instead of rejecting with SessionClosedError - rollbackThread works: turnMessageIndices is populated at each turn start, so whole turns (incl. tool messages) splice correctly - default model unified to qwen2.5:7b across contracts, adapter and text generation (was llama3.2 in contracts only) - ollamaVersion probe wrapped in Effect.exit so an unreachable server no longer crashes the whole status check - ollamaListModels reads modified_at (snake_case) from /api/tags - OllamaTextGeneration catches OllamaRuntimeError by tag instead of a blanket mapError, avoiding double-wrapped TextGenerationError Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/provider/Layers/OllamaAdapter.ts | 34 +++++++++++++------ .../src/provider/Layers/OllamaProvider.ts | 5 ++- apps/server/src/provider/ollamaRuntime.ts | 2 +- .../textGeneration/OllamaTextGeneration.ts | 7 +++- packages/contracts/src/model.ts | 4 +-- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/OllamaAdapter.ts b/apps/server/src/provider/Layers/OllamaAdapter.ts index 75908ad40c4..01147a9b53e 100644 --- a/apps/server/src/provider/Layers/OllamaAdapter.ts +++ b/apps/server/src/provider/Layers/OllamaAdapter.ts @@ -25,7 +25,7 @@ import { } from "@t3tools/contracts"; import type { OllamaAdapterShape } from "../Services/OllamaAdapter.ts"; -import { ProviderAdapterRequestError, ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError } from "../Errors.ts"; +import { ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError } from "../Errors.ts"; import { ollamaChat, type OllamaChatMessage, type OllamaRuntimeError } from "../ollamaRuntime.js"; import { OLLAMA_TOOL_DEFINITIONS, executeOllamaTool, classifyOllamaToolItemType, classifyOllamaRequestType, summarizeOllamaToolCall } from "../OllamaTools.js"; @@ -46,7 +46,7 @@ interface OllamaSessionContext { readonly pendingApprovals: Map; activeModel: string; activeTurnId: TurnId | undefined; - activeFiber: Fiber.RuntimeFiber | undefined; + activeFiber: Fiber.Fiber | undefined; /** message count at the start of each turn (indexed by turn number, 0 = before first user message) */ readonly turnMessageIndices: number[]; } @@ -140,9 +140,10 @@ export const makeOllamaAdapter = ( if (!context) { return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId: input.threadId }); } - if (yield* Ref.get(context.stopped)) { - return yield* new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId: input.threadId }); - } + // Clear any interrupt flag left by a previous interruptTurn so the + // session stays usable. stopSession deletes the session entirely, + // so a closed session is already caught by the not-found check above. + yield* Ref.set(context.stopped, false); const text = input.input?.trim(); if (!text || text.length === 0) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", issue: "Ollama turns require text input." }); @@ -151,6 +152,13 @@ export const makeOllamaAdapter = ( context.activeModel = model; const turnId = TurnId.make(`ollama-turn-${yield* Random.nextUUIDv4}`); context.activeTurnId = turnId; + // Record this turn's start boundary in the message array so + // rollbackThread can splice whole turns (incl. tool messages), + // regardless of whether the turn later succeeds or fails. + const lastIndex = context.turnMessageIndices[context.turnMessageIndices.length - 1]; + if (lastIndex !== context.messages.length) { + context.turnMessageIndices.push(context.messages.length); + } context.messages.push({ role: "user", content: text }); context.session = { ...context.session, status: "running", activeTurnId: turnId } as ProviderSession; const cwd = context.session.cwd ?? process.cwd(); @@ -294,6 +302,7 @@ export const makeOllamaAdapter = ( const isStopped = yield* Ref.get(context.stopped); context.activeTurnId = undefined; + context.activeFiber = undefined; context.session = { ...context.session, status: "ready" } as ProviderSession; yield* emit({ ...(yield* buildEventBase({ threadId: input.threadId, turnId })), @@ -353,6 +362,10 @@ export const makeOllamaAdapter = ( yield* Deferred.succeed(pending.decision, "cancel"); } context.pendingApprovals.clear(); + if (context.activeFiber) { + yield* Fiber.interrupt(context.activeFiber); + context.activeFiber = undefined; + } sessions.delete(threadId); } }, @@ -381,13 +394,14 @@ export const makeOllamaAdapter = ( if (!context) { return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); } - // Use per-turn message indices to correctly handle turns with tool calls + // turnMessageIndices holds the message-array offset at the start of + // each turn, so a rollback splices whole turns (incl. tool messages). const indices = context.turnMessageIndices; - if (indices.length <= 1 || numTurns <= 0) return { threadId, turns: [] }; - const targetTurn = Math.max(0, indices.length - 1 - numTurns); - const rollbackIndex = indices[targetTurn]!; + if (numTurns <= 0 || indices.length === 0) return { threadId, turns: [] }; + const keepTurns = Math.max(0, indices.length - numTurns); + const rollbackIndex = indices[keepTurns] ?? indices[0] ?? 0; context.messages.splice(rollbackIndex); - context.turnMessageIndices.splice(targetTurn + 1); + context.turnMessageIndices.splice(keepTurns); return { threadId, turns: [] }; }, ); diff --git a/apps/server/src/provider/Layers/OllamaProvider.ts b/apps/server/src/provider/Layers/OllamaProvider.ts index 17da4678a82..8d65d0ee489 100644 --- a/apps/server/src/provider/Layers/OllamaProvider.ts +++ b/apps/server/src/provider/Layers/OllamaProvider.ts @@ -35,7 +35,10 @@ export const checkOllamaProviderStatus = (ollamaSettings: OllamaSettings, proces return buildServerProvider({ presentation: OLLAMA_PRESENTATION, enabled: false, checkedAt, models: providerModelsFromSettings([], PROVIDER, customModels, DEFAULT_CAPABILITIES), probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Ollama is disabled." } }); } - const version = yield* ollamaVersion(baseUrl, apiKey); + // Version is non-critical: a failed probe must not crash the whole status + // check before ollamaListModels (which carries its own error snapshot). + const versionExit = yield* Effect.exit(ollamaVersion(baseUrl, apiKey)); + const version = Exit.isSuccess(versionExit) ? versionExit.value : ""; const modelsExit = yield* Effect.exit(ollamaListModels(baseUrl, apiKey)); if (Exit.isFailure(modelsExit)) { diff --git a/apps/server/src/provider/ollamaRuntime.ts b/apps/server/src/provider/ollamaRuntime.ts index d25e6aeff00..ebbdba59e43 100644 --- a/apps/server/src/provider/ollamaRuntime.ts +++ b/apps/server/src/provider/ollamaRuntime.ts @@ -272,7 +272,7 @@ export const ollamaListModels = (baseUrl: string, apiKey?: string) => } return (json.models as Array>).map((m) => ({ name: String(m.name ?? ""), - modifiedAt: String(m.modifiedAt ?? ""), + modifiedAt: String(m.modified_at ?? m.modifiedAt ?? ""), size: typeof m.size === "number" ? m.size : 0, digest: String(m.digest ?? ""), })) satisfies ReadonlyArray; diff --git a/apps/server/src/textGeneration/OllamaTextGeneration.ts b/apps/server/src/textGeneration/OllamaTextGeneration.ts index c385c6d59d2..236cd558d2b 100644 --- a/apps/server/src/textGeneration/OllamaTextGeneration.ts +++ b/apps/server/src/textGeneration/OllamaTextGeneration.ts @@ -37,7 +37,12 @@ export const makeOllamaTextGeneration = Effect.fn("makeOllamaTextGeneration")(fu ), ); }).pipe( - Effect.mapError((cause) => new TextGenerationError({ operation: input.operation, detail: cause instanceof Error ? cause.message : String(cause), cause })), + // Only the ollamaChat call produces a foreign error; the empty-output and + // schema-decode paths already fail with TextGenerationError, so catching + // by tag avoids double-wrapping them. + Effect.catchTag("OllamaRuntimeError", (cause) => + Effect.fail(new TextGenerationError({ operation: input.operation, detail: cause.detail, cause })), + ), ); return { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 5a4bd856108..4e40b2efcd2 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -141,7 +141,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial Date: Sat, 16 May 2026 23:16:15 +0200 Subject: [PATCH 4/6] fix(ollama): hide placeholder "0.0.0" version from Ollama Cloud Ollama Cloud's /api/version returns {"version":"0.0.0"} as a placeholder. Treat it as no version so the provider list shows no version chip instead of a meaningless "v0.0.0". Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/src/provider/Layers/OllamaProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OllamaProvider.ts b/apps/server/src/provider/Layers/OllamaProvider.ts index 8d65d0ee489..1f951831e8b 100644 --- a/apps/server/src/provider/Layers/OllamaProvider.ts +++ b/apps/server/src/provider/Layers/OllamaProvider.ts @@ -37,8 +37,10 @@ export const checkOllamaProviderStatus = (ollamaSettings: OllamaSettings, proces // Version is non-critical: a failed probe must not crash the whole status // check before ollamaListModels (which carries its own error snapshot). + // Ollama Cloud reports a placeholder "0.0.0" — treat it as no version. const versionExit = yield* Effect.exit(ollamaVersion(baseUrl, apiKey)); - const version = Exit.isSuccess(versionExit) ? versionExit.value : ""; + const rawVersion = Exit.isSuccess(versionExit) ? versionExit.value : ""; + const version = rawVersion === "0.0.0" ? "" : rawVersion; const modelsExit = yield* Effect.exit(ollamaListModels(baseUrl, apiKey)); if (Exit.isFailure(modelsExit)) { From 8c8f92dc2ca900c498bc5072801a26f9ccf8d162 Mon Sep 17 00:00:00 2001 From: 3L0935 Date: Sat, 16 May 2026 23:33:15 +0200 Subject: [PATCH 5/6] fix(ollama): eliminate concurrent-fiber race on interrupt + resend interruptTurn only flipped the stopped flag; if the turn fiber was mid-ollamaChat HTTP call it would not observe the flag before the next sendTurn reset it to false and forked a second fiber, so two fibers mutated context.messages concurrently. - sendTurn now interrupts and awaits any prior activeFiber before resetting the flag and forking, guaranteeing a single live fiber. - interruptTurn interrupts the fiber instead of only setting the flag. - turn.completed is emitted from a single Effect.onExit handler, so it fires exactly once whether the turn completes, fails, or is interrupted (previously skipped when the fiber was interrupted). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/provider/Layers/OllamaAdapter.ts | 93 ++++++++++++------- 1 file changed, 59 insertions(+), 34 deletions(-) diff --git a/apps/server/src/provider/Layers/OllamaAdapter.ts b/apps/server/src/provider/Layers/OllamaAdapter.ts index 01147a9b53e..44c711c066c 100644 --- a/apps/server/src/provider/Layers/OllamaAdapter.ts +++ b/apps/server/src/provider/Layers/OllamaAdapter.ts @@ -1,6 +1,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Random from "effect/Random"; @@ -140,14 +141,22 @@ export const makeOllamaAdapter = ( if (!context) { return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId: input.threadId }); } - // Clear any interrupt flag left by a previous interruptTurn so the - // session stays usable. stopSession deletes the session entirely, - // so a closed session is already caught by the not-found check above. - yield* Ref.set(context.stopped, false); const text = input.input?.trim(); if (!text || text.length === 0) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", issue: "Ollama turns require text input." }); } + // A previous turn's fiber must be fully terminated before a new turn + // starts, otherwise two fibers would mutate context.messages + // concurrently. Fiber.interrupt awaits the fiber's onExit, so the + // prior turn is fully closed (turn.completed emitted) once this returns. + if (context.activeFiber) { + yield* Fiber.interrupt(context.activeFiber); + context.activeFiber = undefined; + } + // Clear any interrupt flag from a previous interruptTurn; stopSession + // deletes the session entirely, so a closed session is already caught + // by the not-found check above. + yield* Ref.set(context.stopped, false); const model = input.modelSelection?.model ?? context.activeModel; context.activeModel = model; const turnId = TurnId.make(`ollama-turn-${yield* Random.nextUUIDv4}`); @@ -172,6 +181,10 @@ export const makeOllamaAdapter = ( payload: { model }, }); + // Captured by the ollamaChat catch below and read in onExit to tell a + // real provider error apart from a fiber interruption. + let turnError: OllamaRuntimeError | undefined; + const runTurnLoop = Effect.gen(function* () { let looping = true; while (looping) { @@ -184,23 +197,10 @@ export const makeOllamaAdapter = ( messages: context.messages, tools: OLLAMA_TOOL_DEFINITIONS, }).pipe( - Effect.catch((error: OllamaRuntimeError) => - Effect.gen(function* () { - context.activeTurnId = undefined; - context.session = { ...context.session, status: "error", lastError: error.detail } as ProviderSession; - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.completed", - payload: { state: "failed", errorMessage: error.detail }, - }); - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "runtime.error", - payload: { message: error.detail, class: "provider_error" }, - }); - return yield* Effect.fail(error); - }), - ), + Effect.catch((error: OllamaRuntimeError) => { + turnError = error; + return Effect.fail(error); + }), ); const toolCalls = response.message.tool_calls; @@ -299,17 +299,38 @@ export const makeOllamaAdapter = ( looping = false; } } - - const isStopped = yield* Ref.get(context.stopped); - context.activeTurnId = undefined; - context.activeFiber = undefined; - context.session = { ...context.session, status: "ready" } as ProviderSession; - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.completed", - payload: { state: isStopped ? "interrupted" : "completed" }, - }); - }); + }).pipe( + // onExit runs whether the loop completes, fails, or is interrupted, + // so turn.completed is emitted exactly once on every path. + Effect.onExit((exit) => + Effect.gen(function* () { + context.activeTurnId = undefined; + context.activeFiber = undefined; + if (Exit.isFailure(exit) && turnError) { + context.session = { ...context.session, status: "error", lastError: turnError.detail } as ProviderSession; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { state: "failed", errorMessage: turnError.detail }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "runtime.error", + payload: { message: turnError.detail, class: "provider_error" }, + }); + } else { + // Failure without turnError means the fiber was interrupted. + const isStopped = yield* Ref.get(context.stopped); + context.session = { ...context.session, status: "ready" } as ProviderSession; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { state: isStopped || Exit.isFailure(exit) ? "interrupted" : "completed" }, + }); + } + }), + ), + ); const fiber = runFork(runTurnLoop); context.activeFiber = fiber; @@ -326,8 +347,12 @@ export const makeOllamaAdapter = ( yield* Deferred.succeed(pending.decision, "cancel"); } context.pendingApprovals.clear(); - context.activeTurnId = undefined; - context.session = { ...context.session, status: "ready" } as ProviderSession; + // Interrupt the running fiber and await its termination. The fiber's + // onExit clears activeTurnId/activeFiber and emits turn.completed. + if (context.activeFiber) { + yield* Fiber.interrupt(context.activeFiber); + context.activeFiber = undefined; + } } }, ); From 6f18cef371512bd9bf0e3196d0bb891539b05db0 Mon Sep 17 00:00:00 2001 From: 3L0935 Date: Sat, 16 May 2026 23:35:53 +0200 Subject: [PATCH 6/6] docs(ollama): note the turnError single-fallible-step invariant Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/src/provider/Layers/OllamaAdapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OllamaAdapter.ts b/apps/server/src/provider/Layers/OllamaAdapter.ts index 44c711c066c..efc09d812e2 100644 --- a/apps/server/src/provider/Layers/OllamaAdapter.ts +++ b/apps/server/src/provider/Layers/OllamaAdapter.ts @@ -182,7 +182,9 @@ export const makeOllamaAdapter = ( }); // Captured by the ollamaChat catch below and read in onExit to tell a - // real provider error apart from a fiber interruption. + // real provider error apart from a fiber interruption. This relies on + // ollamaChat being the loop's ONLY fallible step — keep it that way, + // or onExit will misclassify an interruption as a failure. let turnError: OllamaRuntimeError | undefined; const runTurnLoop = Effect.gen(function* () {