From 2a02e0c769880e41852ae21a252a74aa3d3402ee Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sat, 14 Mar 2026 15:31:37 +0800 Subject: [PATCH 1/2] feat: add GLM (Zhipu AI) provider support Add GLM as a second provider alongside Codex, enabling users to use GLM models (glm-4.7, glm-4.7-flash, glm-5) via the OpenAI-compatible chat completions API at open.bigmodel.cn. Changes span contracts, shared utilities, server adapter/registry/health, and the web UI (settings, model picker, provider selector, store). Authentication is via GLM_API_KEY or ZAI_API_KEY environment variables. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Layers/ProviderCommandReactor.ts | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 2 +- apps/server/src/provider/Layers/GlmAdapter.ts | 909 ++++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 28 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../src/provider/Layers/ProviderHealth.ts | 33 +- .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/GlmAdapter.ts | 26 + apps/server/src/serverLayers.ts | 5 + apps/web/src/appSettings.ts | 4 + apps/web/src/components/ChatView.logic.ts | 2 + apps/web/src/components/Icons.tsx | 13 + .../components/chat/ProviderHealthBanner.tsx | 2 +- .../components/chat/ProviderModelPicker.tsx | 3 +- apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/routes/_chat.settings.tsx | 14 + apps/web/src/session-logic.ts | 1 + apps/web/src/store.ts | 15 +- packages/contracts/src/model.ts | 17 + packages/contracts/src/orchestration.ts | 2 +- packages/shared/src/model.ts | 19 + 21 files changed, 1082 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/provider/Layers/GlmAdapter.ts create mode 100644 apps/server/src/provider/Services/GlmAdapter.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..819984f83 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -206,7 +206,7 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName === "codex" || thread.session?.providerName === "glm" ? thread.session.providerName : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7ed..87882fafa 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "glm"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts new file mode 100644 index 000000000..e4fd0752a --- /dev/null +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -0,0 +1,909 @@ +/** + * GlmAdapterLive - HTTP-based live implementation for the GLM (z.ai) provider adapter. + * + * Implements an agent loop via GLM's OpenAI-compatible chat completions API + * with SSE streaming and local tool execution. + * + * @module GlmAdapterLive + */ +import { + type CanonicalItemType, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + EventId, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { + ProviderAdapterSessionNotFoundError, +} from "../Errors.ts"; +import { GlmAdapter, type GlmAdapterShape } from "../Services/GlmAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import type { + ProviderAdapterCapabilities, + ProviderThreadSnapshot, +} from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "glm" as const; +const DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; +const MAX_AGENT_LOOP_ITERATIONS = 32; + +// ── Types ───────────────────────────────────────────────────────── + +interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content?: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; + name?: string; +} + +interface ToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +interface GlmSession { + threadId: ThreadId; + model: string; + cwd: string; + messages: ChatMessage[]; + activeTurnAbort: AbortController | null; + pendingApproval: { + resolve: (decision: string) => void; + requestId: string; + } | null; + status: "ready" | "running" | "stopped"; + createdAt: string; + updatedAt: string; + runtimeMode: "full-access" | "approval-required"; +} + +// ── Tool definitions ────────────────────────────────────────────── + +const TOOL_DEFINITIONS = [ + { + type: "function" as const, + function: { + name: "read_file", + description: "Read the contents of a file at the given path.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute or relative file path to read." }, + }, + required: ["path"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "write_file", + description: "Write content to a file, creating it if it doesn't exist.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to write to." }, + content: { type: "string", description: "Content to write." }, + }, + required: ["path", "content"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "edit_file", + description: "Edit a file by replacing old_text with new_text.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to edit." }, + old_text: { type: "string", description: "Text to find and replace." }, + new_text: { type: "string", description: "Replacement text." }, + }, + required: ["path", "old_text", "new_text"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "run_command", + description: "Execute a shell command and return the output.", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Shell command to execute." }, + cwd: { type: "string", description: "Working directory (optional, defaults to session cwd)." }, + }, + required: ["command"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "list_directory", + description: "List files and directories in a path.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Directory path to list." }, + }, + required: ["path"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "search_files", + description: "Search for a pattern in files using grep.", + parameters: { + type: "object", + properties: { + pattern: { type: "string", description: "Search pattern (regex)." }, + path: { type: "string", description: "Directory to search in (optional)." }, + }, + required: ["pattern"], + }, + }, + }, +]; + +// ── Helpers ─────────────────────────────────────────────────────── + +let eventCounter = 0; +function nextEventId(): string { + return `glm-evt-${Date.now()}-${++eventCounter}`; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function makeEventBase( + threadId: ThreadId, + turnId?: TurnId, + itemId?: string, +): Omit { + const base: Record = { + eventId: EventId.makeUnsafe(nextEventId()), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + }; + if (turnId) base.turnId = turnId; + if (itemId) base.itemId = RuntimeItemId.makeUnsafe(itemId); + return base as Omit; +} + +function toolCallToCanonicalItemType(toolName: string): CanonicalItemType { + switch (toolName) { + case "read_file": + case "write_file": + case "edit_file": + return "file_change"; + case "run_command": + return "command_execution"; + case "list_directory": + case "search_files": + return "file_change"; + default: + return "unknown"; + } +} + +function resolveApiKey(): string | undefined { + return process.env.GLM_API_KEY ?? process.env.ZAI_API_KEY; +} + +function resolveBaseUrl(overrideUrl?: string): string { + return overrideUrl ?? process.env.GLM_BASE_URL ?? DEFAULT_BASE_URL; +} + +// ── Tool execution ──────────────────────────────────────────────── + +async function executeToolCall( + toolName: string, + args: Record, + cwd: string, +): Promise { + const { execSync } = await import("node:child_process"); + const fs = await import("node:fs"); + const path = await import("node:path"); + + const resolvePath = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p)); + + switch (toolName) { + case "read_file": { + const filePath = resolvePath(String(args.path ?? "")); + return fs.readFileSync(filePath, "utf-8"); + } + case "write_file": { + const filePath = resolvePath(String(args.path ?? "")); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, String(args.content ?? ""), "utf-8"); + return `File written: ${filePath}`; + } + case "edit_file": { + const filePath = resolvePath(String(args.path ?? "")); + const existing = fs.readFileSync(filePath, "utf-8"); + const oldText = String(args.old_text ?? ""); + const newText = String(args.new_text ?? ""); + if (!existing.includes(oldText)) { + return `Error: old_text not found in ${filePath}`; + } + fs.writeFileSync(filePath, existing.replace(oldText, newText), "utf-8"); + return `File edited: ${filePath}`; + } + case "run_command": { + const command = String(args.command ?? ""); + const cmdCwd = args.cwd ? resolvePath(String(args.cwd)) : cwd; + const result = execSync(command, { + cwd: cmdCwd, + timeout: 60_000, + maxBuffer: 1024 * 1024, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return String(result); + } + case "list_directory": { + const dirPath = resolvePath(String(args.path ?? ".")); + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries + .map((e) => `${e.isDirectory() ? "[dir]" : "[file]"} ${e.name}`) + .join("\n"); + } + case "search_files": { + const pattern = String(args.pattern ?? ""); + const searchPath = args.path ? resolvePath(String(args.path)) : cwd; + const result = execSync(`grep -rn --include='*' ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)} || true`, { + cwd, + timeout: 30_000, + maxBuffer: 1024 * 1024, + encoding: "utf-8", + }); + return typeof result === "string" ? result.slice(0, 10_000) : ""; + } + default: + return `Unknown tool: ${toolName}`; + } +} + +// ── SSE parser ──────────────────────────────────────────────────── + +interface SSEChunk { + choices?: Array<{ + delta?: { + content?: string | null; + tool_calls?: Array<{ + index: number; + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; + }>; + }; + finish_reason?: string | null; + }>; +} + +async function* parseSseStream( + response: Response, + signal: AbortSignal, +): AsyncGenerator { + const reader = response.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") return; + try { + yield JSON.parse(data) as SSEChunk; + } catch { + // Skip malformed chunks + } + } + } + } finally { + reader.releaseLock(); + } +} + +// ── Adapter implementation ──────────────────────────────────────── + +export interface GlmAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; +} + +export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { + return Layer.effect( + GlmAdapter, + Effect.gen(function* () { + const sessions = new Map(); + const eventQueue = yield* Queue.unbounded(); + + const emit = (event: ProviderRuntimeEvent) => + Effect.runSync(Queue.offer(eventQueue, event)); + + const streamEvents: GlmAdapterShape["streamEvents"] = + Stream.fromQueue(eventQueue); + + const getSession = (threadId: ThreadId): GlmSession => { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return session; + }; + + const capabilities: ProviderAdapterCapabilities = { + sessionModelSwitch: "in-session", + }; + + const startSession: GlmAdapterShape["startSession"] = (input) => + Effect.sync(() => { + const now = nowIso(); + const threadId = input.threadId; + const model = input.model ?? "glm-4.7"; + const cwd = input.cwd ?? process.cwd(); + + const session: GlmSession = { + threadId, + model, + cwd, + messages: [ + { + role: "system", + content: + `You are a helpful coding assistant. The working directory is: ${cwd}\n\n` + + `You have access to tools to read, write, and edit files, run commands, list directories, and search files.\n\n` + + `When facing complex tasks, plan your approach step by step and use the available tools to accomplish the task.`, + }, + ], + activeTurnAbort: null, + pendingApproval: null, + status: "ready", + createdAt: now, + updatedAt: now, + runtimeMode: input.runtimeMode ?? "full-access", + }; + sessions.set(threadId, session); + + emit({ + ...makeEventBase(threadId), + type: "session.started", + payload: { message: `GLM session started with model ${model}` }, + } as ProviderRuntimeEvent); + + emit({ + ...makeEventBase(threadId), + type: "session.state.changed", + payload: { state: "ready" }, + } as ProviderRuntimeEvent); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: session.runtimeMode, + cwd, + model, + threadId, + createdAt: now, + updatedAt: now, + } as ProviderSession; + }); + + const runAgentLoop = async ( + session: GlmSession, + turnId: TurnId, + signal: AbortSignal, + ) => { + const apiKey = resolveApiKey(); + if (!apiKey) { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: "GLM API key not configured. Set GLM_API_KEY or ZAI_API_KEY environment variable.", + class: "provider_error", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed", errorMessage: "API key not configured" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + const baseUrl = resolveBaseUrl(); + + for (let iteration = 0; iteration < MAX_AGENT_LOOP_ITERATIONS; iteration++) { + if (signal.aborted) { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "interrupted" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + // Each iteration gets a unique itemId so plan text (iteration 0) and + // final response become separate messages in the timeline. + const iterationItemId = `msg-iter-${iteration}-${turnId}`; + + let response: Response; + try { + response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: session.model, + messages: session.messages, + tools: TOOL_DEFINITIONS, + stream: true, + }), + signal, + }); + } catch (error) { + if (signal.aborted) { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "interrupted" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: `GLM API request failed: ${error instanceof Error ? error.message : String(error)}`, + class: "transport_error", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed", errorMessage: "API request failed" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: `GLM API error ${response.status}: ${errorBody.slice(0, 500)}`, + class: "provider_error", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed", errorMessage: `API error ${response.status}` }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + // Parse streaming response + let assistantContent = ""; + const toolCalls: Map = new Map(); + + for await (const chunk of parseSseStream(response, signal)) { + const choice = chunk.choices?.[0]; + if (!choice?.delta) continue; + + // Text content + if (choice.delta.content) { + assistantContent += choice.delta.content; + emit({ + ...makeEventBase(session.threadId, turnId, iterationItemId), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: choice.delta.content, + }, + } as ProviderRuntimeEvent); + } + + // Tool calls + if (choice.delta.tool_calls) { + for (const tc of choice.delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.function.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? `call-${tc.index}`, + type: "function", + function: { + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }, + }); + } + } + } + } + + // Build assistant message + const assistantMsg: ChatMessage = { + role: "assistant", + content: assistantContent || null, + }; + const resolvedToolCalls = Array.from(toolCalls.values()); + if (resolvedToolCalls.length > 0) { + assistantMsg.tool_calls = resolvedToolCalls; + } + session.messages.push(assistantMsg); + + // If no tool calls, turn is complete + if (resolvedToolCalls.length === 0) { + if (assistantContent) { + emit({ + ...makeEventBase(session.threadId, turnId, iterationItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + } as ProviderRuntimeEvent); + } + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "completed" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + session.updatedAt = nowIso(); + return; + } + + // If the model produced plan/explanation text alongside tool calls, + // emit it as a completed assistant message so the UI renders it + // before the tool calls start executing. + if (assistantContent) { + emit({ + ...makeEventBase(session.threadId, turnId, iterationItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + } as ProviderRuntimeEvent); + } + + // Execute tool calls sequentially + for (const tc of resolvedToolCalls) { + if (signal.aborted) break; + + let parsedArgs: Record = {}; + try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; } + const toolItemId = `tool-${tc.id}`; + const canonicalType = toolCallToCanonicalItemType(tc.function.name); + const toolDetail = + tc.function.name === "run_command" + ? String(parsedArgs.command ?? "") + : tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file" + ? String(parsedArgs.path ?? "") + : tc.function.name; + + // Approval flow for write/execute operations in approval-required mode + if ( + session.runtimeMode === "approval-required" && + (tc.function.name === "write_file" || + tc.function.name === "edit_file" || + tc.function.name === "run_command") + ) { + const requestId = `req-${nextEventId()}`; + const requestType = + tc.function.name === "run_command" + ? "exec_command_approval" + : "file_change_approval"; + + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "request.opened", + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + detail: toolDetail, + args: parsedArgs, + }, + } as ProviderRuntimeEvent); + + const decision = await new Promise((resolve) => { + session.pendingApproval = { resolve, requestId }; + }); + session.pendingApproval = null; + + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "request.resolved", + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + decision, + }, + } as ProviderRuntimeEvent); + + if (decision === "decline" || decision === "cancel") { + session.messages.push({ + role: "tool", + tool_call_id: tc.id, + content: "Operation declined by user.", + }); + continue; + } + } + + // Emit item.started + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "item.started", + payload: { + itemType: canonicalType, + title: tc.function.name, + detail: toolDetail, + }, + } as ProviderRuntimeEvent); + + // Execute + let toolResult: string; + try { + toolResult = await executeToolCall(tc.function.name, parsedArgs, session.cwd); + } catch (error) { + toolResult = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + + // Emit item.completed + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "item.completed", + payload: { + itemType: canonicalType, + status: "completed", + title: tc.function.name, + detail: toolDetail, + }, + } as ProviderRuntimeEvent); + + session.messages.push({ + role: "tool", + tool_call_id: tc.id, + content: toolResult.slice(0, 50_000), + }); + } + + session.updatedAt = nowIso(); + // Loop continues: send tool results back to model + } + + // If we hit the max iterations + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "completed", stopReason: "max_iterations" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + }; + + const sendTurn: GlmAdapterShape["sendTurn"] = (input) => + Effect.sync(() => { + const session = getSession(input.threadId); + const turnId = TurnId.makeUnsafe(`turn-${nextEventId()}`); + + if (input.model) { + session.model = input.model; + } + + if (input.input) { + session.messages.push({ role: "user", content: input.input }); + } + + session.status = "running"; + const abortController = new AbortController(); + session.activeTurnAbort = abortController; + + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.started", + payload: { model: session.model }, + } as ProviderRuntimeEvent); + + // Run agent loop in the background + void runAgentLoop(session, turnId, abortController.signal).catch((error) => { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: `Agent loop failed: ${error instanceof Error ? error.message : String(error)}`, + class: "unknown", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + session.activeTurnAbort = null; + }); + + return { + threadId: session.threadId, + turnId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: GlmAdapterShape["interruptTurn"] = (threadId) => + Effect.sync(() => { + const session = getSession(threadId); + if (session.activeTurnAbort) { + session.activeTurnAbort.abort(); + session.activeTurnAbort = null; + } + if (session.pendingApproval) { + session.pendingApproval.resolve("cancel"); + session.pendingApproval = null; + } + session.status = "ready"; + }); + + const respondToRequest: GlmAdapterShape["respondToRequest"] = ( + threadId, + _requestId, + decision, + ) => + Effect.sync(() => { + const session = getSession(threadId); + if (session.pendingApproval) { + session.pendingApproval.resolve(decision); + } + }); + + const respondToUserInput: GlmAdapterShape["respondToUserInput"] = () => + Effect.void; + + const stopSession: GlmAdapterShape["stopSession"] = (threadId) => + Effect.sync(() => { + const session = sessions.get(threadId); + if (session) { + if (session.activeTurnAbort) { + session.activeTurnAbort.abort(); + } + if (session.pendingApproval) { + session.pendingApproval.resolve("cancel"); + } + session.status = "stopped"; + sessions.delete(threadId); + + emit({ + ...makeEventBase(threadId), + type: "session.exited", + payload: { reason: "stopped", exitKind: "graceful" }, + } as ProviderRuntimeEvent); + } + }); + + const listSessions: GlmAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()).map( + (s) => + ({ + provider: PROVIDER, + status: s.status === "running" ? "running" : "ready", + runtimeMode: s.runtimeMode, + cwd: s.cwd, + model: s.model, + threadId: s.threadId, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + }) as ProviderSession, + ), + ); + + const hasSession: GlmAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: GlmAdapterShape["readThread"] = (threadId) => + Effect.sync(() => { + getSession(threadId); + return { + threadId, + turns: [], + } satisfies ProviderThreadSnapshot; + }); + + const rollbackThread: GlmAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.sync(() => { + const session = getSession(threadId); + // Simple rollback: remove last N user+assistant message pairs + for (let i = 0; i < numTurns; i++) { + while (session.messages.length > 1) { + const last = session.messages[session.messages.length - 1]; + if (!last) break; + session.messages.pop(); + if (last.role === "user") break; + } + } + return { + threadId, + turns: [], + } satisfies ProviderThreadSnapshot; + }); + + const stopAll: GlmAdapterShape["stopAll"] = () => + Effect.gen(function* () { + for (const threadId of sessions.keys()) { + yield* stopSession(ThreadId.makeUnsafe(threadId)); + } + }); + + return { + provider: PROVIDER, + capabilities, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents, + } satisfies GlmAdapterShape; + }), + ); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c6f4a3c08..f3841b92a 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { GlmAdapter, type GlmAdapterShape } from "../Services/GlmAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -27,9 +28,32 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeGlmAdapter: GlmAdapterShape = { + provider: "glm", + capabilities: { sessionModelSwitch: "restart-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.provide( + ProviderAdapterRegistryLive, + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(GlmAdapter, fakeGlmAdapter), + ), + ), NodeServices.layer, ), ); @@ -42,7 +66,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(codex, fakeCodexAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "glm"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3062ed790..bc773ce3b 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -16,6 +16,7 @@ import { type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { GlmAdapter } from "../Services/GlmAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -23,7 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions { const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => Effect.gen(function* () { - const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; + const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter, yield* GlmAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 1fed0597a..97c03556e 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -390,6 +390,34 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +// ── GLM Health check ──────────────────────────────────────────────── + +const GLM_PROVIDER = "glm" as const; + +export const checkGlmProviderStatus: Effect.Effect = Effect.sync(() => { + const checkedAt = new Date().toISOString(); + const apiKey = process.env.GLM_API_KEY ?? process.env.ZAI_API_KEY; + + if (!apiKey) { + return { + provider: GLM_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + checkedAt, + message: "GLM API key not configured. Set GLM_API_KEY or ZAI_API_KEY environment variable.", + }; + } + + return { + provider: GLM_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + checkedAt, + }; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( @@ -399,9 +427,12 @@ export const ProviderHealthLive = Layer.effect( Effect.map(Array.of), Effect.forkScoped, ); + const glmStatus = yield* checkGlmProviderStatus; return { - getStatuses: Fiber.join(codexStatusFiber), + getStatuses: Fiber.join(codexStatusFiber).pipe( + Effect.map((statuses) => Array.prepend(statuses, glmStatus)), + ), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 38e097e1c..564c0b9e3 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "glm") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/GlmAdapter.ts b/apps/server/src/provider/Services/GlmAdapter.ts new file mode 100644 index 000000000..db56dad58 --- /dev/null +++ b/apps/server/src/provider/Services/GlmAdapter.ts @@ -0,0 +1,26 @@ +/** + * GlmAdapter - GLM implementation of the generic provider adapter contract. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "glm"` context. + * + * @module GlmAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * GlmAdapterShape - Service API for the GLM provider adapter. + */ +export interface GlmAdapterShape extends ProviderAdapterShape { + readonly provider: "glm"; +} + +/** + * GlmAdapter - Service tag for GLM provider adapter operations. + */ +export class GlmAdapter extends ServiceMap.Service()( + "t3/provider/Services/GlmAdapter", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96..b373f11a2 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,6 +20,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeGlmAdapterLive } from "./provider/Layers/GlmAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -58,8 +59,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const glmAdapterLayer = makeGlmAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(glmAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..d1d78dbc2 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -12,6 +12,7 @@ export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + glm: new Set(getModelOptions("glm").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -34,6 +35,9 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customGlmModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..01bc99a46 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -118,8 +118,10 @@ export function cloneComposerImageForRetry( export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customGlmModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + glm: getAppModelOptions("glm", settings.customGlmModels), }; } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 9a3ddbaa0..d5e6cbd11 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -296,6 +296,19 @@ export const AntigravityIcon: Icon = (props) => ( ); +export const GlmIcon: Icon = (props) => ( + + + + +); + export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx index 12c7f6054..75cc3c103 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -22,7 +22,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} + {status.provider === "codex" ? "Codex provider status" : status.provider === "glm" ? "GLM provider status" : `${status.provider} status`} {status.message ?? defaultMessage} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 9bc034991..46bb6d2db 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -17,7 +17,7 @@ import { MenuSubTrigger, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Gemini, GlmIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { @@ -63,6 +63,7 @@ function resolveModelForProviderPicker( const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, + glm: GlmIcon, claudeCode: ClaudeAI, cursor: CursorIcon, }; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..28cdc0ae3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -246,7 +246,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "glm" ? value : null; } function revokeObjectPreviewUrl(previewUrl: string): void { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..c19636211 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "glm", + title: "GLM (z.ai)", + description: "Save additional GLM model slugs for the picker and `/model` command.", + placeholder: "your-glm-model-slug", + example: "glm-5-plus", + }, ] as const; const TIMESTAMP_FORMAT_LABELS = { @@ -67,6 +74,8 @@ function getCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "glm": + return settings.customGlmModels; case "codex": default: return settings.customCodexModels; @@ -78,6 +87,8 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "glm": + return defaults.customGlmModels; case "codex": default: return defaults.customCodexModels; @@ -86,6 +97,8 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "glm": + return { customGlmModels: models }; case "codex": default: return { customCodexModels: models }; @@ -102,6 +115,7 @@ function SettingsRouteView() { Record >({ codex: "", + glm: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..f10540ac7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -26,6 +26,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, + { value: "glm", label: "GLM (z.ai)", available: true }, { value: "claudeCode", label: "Claude Code", available: false }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..574bc7fde 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -7,8 +7,7 @@ import { type OrchestrationSessionStatus, } from "@t3tools/contracts"; import { - getModelOptions, - normalizeModelSlug, + inferProviderFromModel, resolveModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; @@ -189,26 +188,20 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "glm") { return providerName; } return "codex"; } -const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); - function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "glm") { return input.sessionProviderName; } - const normalizedCodex = normalizeModelSlug(input.model, "codex"); - if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { - return "codex"; - } - return "codex"; + return inferProviderFromModel(input.model); } function resolveWsHttpOrigin(): string { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..fbfff9864 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -10,8 +10,12 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const GlmModelOptions = Schema.Struct({}); +export type GlmModelOptions = typeof GlmModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + glm: Schema.optional(GlmModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +32,11 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + glm: [ + { slug: "glm-4.7", name: "GLM 4.7" }, + { slug: "glm-4.7-flash", name: "GLM 4.7 Flash" }, + { slug: "glm-5", name: "GLM 5" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +45,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + glm: "glm-4.7", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +56,19 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + glm: { + "4.7-flash": "glm-4.7-flash", + "4.7": "glm-4.7", + "5": "glm-5", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + glm: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + glm: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c5eb21d..21d74bb1b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Literals(["codex", "glm"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..983781110 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,7 @@ type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + glm: new Set(MODEL_OPTIONS_BY_PROVIDER.glm.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { @@ -75,4 +76,22 @@ export function getDefaultReasoningEffort( return provider === "codex" ? "high" : null; } +const PROVIDER_KINDS: readonly ProviderKind[] = ["codex", "glm"] as const; + +/** + * Infer the provider kind from a model slug. Returns the first provider + * whose catalog contains a matching slug (after alias resolution), or + * falls back to `"codex"`. + */ +export function inferProviderFromModel(model: string | null | undefined): ProviderKind { + if (!model) return "codex"; + for (const provider of PROVIDER_KINDS) { + const normalized = normalizeModelSlug(model, provider); + if (normalized && MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)) { + return provider; + } + } + return "codex"; +} + export { CODEX_REASONING_EFFORT_OPTIONS }; From e6899afa3955f1d16a0b3166f4a0d6965a09f036 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sat, 14 Mar 2026 16:03:43 +0800 Subject: [PATCH 2/2] fix: use typed Effect.fail instead of throw in GlmAdapter.getSession getSession was throwing ProviderAdapterSessionNotFoundError inside Effect.sync blocks, which converts to a Cause.Die defect. Changed to return Effect.fail for a typed Cause.Fail that callers can handle. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/server/src/provider/Layers/GlmAdapter.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index e4fd0752a..28623b7b8 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -358,15 +358,15 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { const streamEvents: GlmAdapterShape["streamEvents"] = Stream.fromQueue(eventQueue); - const getSession = (threadId: ThreadId): GlmSession => { + const getSession = (threadId: ThreadId): Effect.Effect => { const session = sessions.get(threadId); if (!session) { - throw new ProviderAdapterSessionNotFoundError({ + return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId, - }); + })); } - return session; + return Effect.succeed(session); }; const capabilities: ProviderAdapterCapabilities = { @@ -737,8 +737,8 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { }; const sendTurn: GlmAdapterShape["sendTurn"] = (input) => - Effect.sync(() => { - const session = getSession(input.threadId); + Effect.gen(function* () { + const session = yield* getSession(input.threadId); const turnId = TurnId.makeUnsafe(`turn-${nextEventId()}`); if (input.model) { @@ -785,8 +785,8 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { }); const interruptTurn: GlmAdapterShape["interruptTurn"] = (threadId) => - Effect.sync(() => { - const session = getSession(threadId); + Effect.gen(function* () { + const session = yield* getSession(threadId); if (session.activeTurnAbort) { session.activeTurnAbort.abort(); session.activeTurnAbort = null; @@ -803,8 +803,8 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { _requestId, decision, ) => - Effect.sync(() => { - const session = getSession(threadId); + Effect.gen(function* () { + const session = yield* getSession(threadId); if (session.pendingApproval) { session.pendingApproval.resolve(decision); } @@ -855,8 +855,8 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { Effect.sync(() => sessions.has(threadId)); const readThread: GlmAdapterShape["readThread"] = (threadId) => - Effect.sync(() => { - getSession(threadId); + Effect.gen(function* () { + yield* getSession(threadId); return { threadId, turns: [], @@ -864,8 +864,8 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { }); const rollbackThread: GlmAdapterShape["rollbackThread"] = (threadId, numTurns) => - Effect.sync(() => { - const session = getSession(threadId); + Effect.gen(function* () { + const session = yield* getSession(threadId); // Simple rollback: remove last N user+assistant message pairs for (let i = 0; i < numTurns; i++) { while (session.messages.length > 1) {