diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index a7ff41fc89e..d5335fe2665 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -1,7 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; @@ -9,54 +13,129 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, + resolveClaudeCodeExecutable, resolveClaudeHomePath, } from "./ClaudeHome.ts"; -it.layer(NodeServices.layer)("ClaudeHome", (it) => { - describe("Claude home resolution", () => { - it.effect("uses the process home when no Claude home override is configured", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); - - expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); - expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); - }), - ); - - it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const homePath = "~/.claude-work"; - const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); - - expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); - expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); - expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); - expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${resolved}\0`, - ); - }), - ); - - it.effect("separates capability probes by cwd", () => - Effect.gen(function* () { - const config = { binaryPath: "claude", homePath: "" }; - const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); - const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); - expect(first).not.toBe(second); - }), - ); - - it.effect("keeps continuation compatible across instances with the same Claude HOME", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); - - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( - `claude:home:${resolved}`, - ); - }), - ); - }); +const nodeServicesIt = it.layer(NodeServices.layer); + +nodeServicesIt("ClaudeHome", (it) => { + it.effect("unwraps Windows npm shims to the package native binary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const prefix = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-npm-")), + ); + const shimPath = path.join(prefix, "claude.cmd"); + const nativeBinary = path.join( + prefix, + "node_modules", + "@anthropic-ai", + "claude-code", + "bin", + "claude.exe", + ); + yield* Effect.sync(() => { + NodeFS.mkdirSync(path.dirname(nativeBinary), { recursive: true }); + NodeFS.writeFileSync(shimPath, "@ECHO off\r\n"); + NodeFS.writeFileSync(nativeBinary, ""); + }); + + const resolved = yield* resolveClaudeCodeExecutable(shimPath).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ); + expect(resolved).toBe(nativeBinary); + }), + ); + + it.effect("keeps an explicit native executable path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const prefix = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-native-")), + ); + const nativeBinary = path.join(prefix, "claude.exe"); + yield* Effect.sync(() => { + NodeFS.writeFileSync(nativeBinary, ""); + }); + + const resolved = yield* resolveClaudeCodeExecutable(nativeBinary).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ); + expect(resolved).toBe(nativeBinary); + }), + ); + + it.effect("does not replace a custom exe when a sibling npm package binary exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const prefix = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-custom-")), + ); + const customBinary = path.join(prefix, "claude.exe"); + const npmNativeBinary = path.join( + prefix, + "node_modules", + "@anthropic-ai", + "claude-code", + "bin", + "claude.exe", + ); + yield* Effect.sync(() => { + NodeFS.mkdirSync(path.dirname(npmNativeBinary), { recursive: true }); + NodeFS.writeFileSync(customBinary, ""); + NodeFS.writeFileSync(npmNativeBinary, ""); + }); + + const resolved = yield* resolveClaudeCodeExecutable(customBinary).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ); + expect(resolved).toBe(customBinary); + }), + ); + + it.effect("uses the process home when no Claude home override is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir()); + + expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); + }), + ); + + it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homePath = "~/.claude-work"; + const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); + + expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); + expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); + expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); + expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( + `claude\0${resolved}\0`, + ); + }), + ); + + it.effect("separates capability probes by cwd", () => + Effect.gen(function* () { + const config = { binaryPath: "claude", homePath: "" }; + const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); + const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); + expect(first).not.toBe(second); + }), + ); + + it.effect("keeps continuation compatible across instances with the same Claude HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir()); + + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( + `claude:home:${resolved}`, + ); + }), + ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index b3ef22b640c..34c50b100e1 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,11 +1,77 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import type { ClaudeSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { expandHomePath } from "../../pathExpansion.ts"; +const NPM_CLAUDE_PACKAGE_BIN = ["node_modules", "@anthropic-ai", "claude-code", "bin"] as const; + +function isWindowsScriptShim(filePath: string): boolean { + const extension = NodePath.win32.extname(filePath).toLowerCase(); + return extension === ".cmd" || extension === ".bat" || extension === ".ps1" || extension === ""; +} + +/** + * Resolve a Claude Code binary path suitable for `pathToClaudeCodeExecutable`. + * + * The Claude Agent SDK expects a native binary, not an npm PATH shim. On Windows, + * `npm i -g @anthropic-ai/claude-code` installs `claude.cmd` / a shell wrapper that + * point at `node_modules/@anthropic-ai/claude-code/bin/claude.exe` — unwrap that. + */ +export const resolveClaudeCodeExecutable = Effect.fn("resolveClaudeCodeExecutable")(function* ( + binaryPath: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const configured = binaryPath.trim() || "claude"; + const nativeBinaryName = platform === "win32" ? "claude.exe" : "claude"; + + const resolved = yield* resolveCommandPath( + configured, + environment ? { env: environment } : {}, + ).pipe(Effect.catchTags({ CommandResolutionError: () => Effect.succeed(configured) })); + + // Only unwrap npm shims — never replace an explicit native/custom executable + // just because a sibling node_modules/@anthropic-ai/claude-code layout exists. + const shouldUnwrapNpmShim = + platform === "win32" + ? isWindowsScriptShim(resolved) + : // Bare `claude` on PATH is typically the npm/posix wrapper script. + configured === "claude" || configured === nativeBinaryName; + + if (shouldUnwrapNpmShim) { + // npm global prefix: /claude(.cmd) → /node_modules/@anthropic-ai/claude-code/bin/claude[.exe] + const npmNativeBinary = path.join( + path.dirname(resolved), + ...NPM_CLAUDE_PACKAGE_BIN, + nativeBinaryName, + ); + if (yield* fileSystem.exists(npmNativeBinary).pipe(Effect.orElseSucceed(() => false))) { + return npmNativeBinary; + } + } + + const resolvedExists = yield* fileSystem.exists(resolved).pipe(Effect.orElseSucceed(() => false)); + if (resolvedExists) { + // Don't hand Windows script shims to the Agent SDK — it needs the .exe. + if (platform === "win32" && isWindowsScriptShim(resolved)) { + return configured; + } + return resolved; + } + + return configured; +}); + export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( config: Pick, ): Effect.fn.Return { diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts new file mode 100644 index 00000000000..f0162ca0c1e --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -0,0 +1,175 @@ +/** + * OpenRouterDriver — first-class `ProviderDriver` for OpenRouter. + * + * Uses the Claude Agent SDK/CLI as the agent runtime while owning OpenRouter + * settings (API key, base URL, attribution) and stamping `driverKind: + * "openrouter"` on snapshots and sessions. + * + * @module provider/Drivers/OpenRouterDriver + */ +import { OpenRouterSettings, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { + checkOpenRouterProviderStatus, + makePendingOpenRouterProvider, +} from "../Layers/OpenRouterProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { + buildOpenRouterProcessEnv, + OPENROUTER_DRIVER_KIND, + toClaudeSettings, +} from "../openrouter/OpenRouterRuntime.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: OPENROUTER_DRIVER_KIND, + packageName: null, + }), +); + +export type OpenRouterDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +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: OPENROUTER_DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenRouterDriver: ProviderDriver = { + driverKind: OPENROUTER_DRIVER_KIND, + metadata: { + displayName: "OpenRouter", + supportsMultipleInstances: true, + }, + configSchema: OpenRouterSettings, + defaultConfig: (): OpenRouterSettings => decodeOpenRouterSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const baseEnv = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies OpenRouterSettings; + // Build OpenRouter-owned process env once; pass through to adapter + probes. + const processEnv = buildOpenRouterProcessEnv(effectiveConfig, baseEnv); + const claudeSettings = toClaudeSettings(effectiveConfig); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: OPENROUTER_DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeClaudeAdapter(claudeSettings, { + instanceId, + environment: processEnv, + provider: OPENROUTER_DRIVER_KIND, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeClaudeTextGeneration(claudeSettings, processEnv); + + const checkProvider = checkOpenRouterProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOpenRouterProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: OPENROUTER_DRIVER_KIND, + instanceId, + detail: `Failed to build OpenRouter snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: OPENROUTER_DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 17aeff2d0e3..d0a1f6681a0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -8,6 +8,7 @@ import type { Options as ClaudeQueryOptions, PermissionMode, PermissionResult, + SDKControlGetContextUsageResponse, SDKMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; @@ -59,6 +60,28 @@ class FakeClaudeQuery implements AsyncIterable { public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; + public getContextUsageCalls = 0; + + /** Attach optional getContextUsage without breaking exactOptionalPropertyTypes. */ + hangContextUsageForever(): void { + Object.assign(this, { + getContextUsage: () => { + this.getContextUsageCalls += 1; + return new Promise(() => { + // never resolves — exercises turn.completed before getContextUsage + }); + }, + }); + } + + resolveContextUsage(response: SDKControlGetContextUsageResponse): void { + Object.assign(this, { + getContextUsage: async () => { + this.getContextUsageCalls += 1; + return response; + }, + }); + } emit(message: SDKMessage): void { if (this.done) { @@ -3789,4 +3812,160 @@ describe("ClaudeAdapterLive", () => { Effect.provide(harness.layer), ); }); + + it.effect("emits turn.completed with turnId before a hanging getContextUsage resolves", () => { + const harness = makeHarness(); + harness.query.hangContextUsageForever(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-hang-context", + uuid: "assistant-hang-context", + parent_tool_use_id: null, + message: { + id: "assistant-message-hang-context", + content: [{ type: "text", text: "done" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-hang-context", + uuid: "result-hang-context", + usage: { + input_tokens: 10, + output_tokens: 5, + }, + } as unknown as SDKMessage); + + // Working clears when ingestion accepts turn.completed with turnId — must + // not wait on getContextUsage (which never resolves in this harness). + const turnCompleted = yield* Fiber.join(turnCompletedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(turnCompleted._tag, "Some"); + if (turnCompleted._tag === "Some") { + assert.equal(turnCompleted.value.type, "turn.completed"); + if (turnCompleted.value.type === "turn.completed") { + assert.equal(String(turnCompleted.value.turnId), String(turn.turnId)); + assert.equal(turnCompleted.value.payload.state, "completed"); + } + } + assert.equal(harness.query.getContextUsageCalls >= 1, true); + + // Adapter session is ready for a new turn (orchestration Working signal + // requires the turnId-bearing turn.completed above to pass ingestion). + const nextTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "follow up", + attachments: [], + }); + assert.notEqual(String(nextTurn.turnId), String(turn.turnId)); + + // Unblock the post-completion refresh timeout so the stream fiber can exit. + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("refreshes token usage from getContextUsage after turn.completed", () => { + const harness = makeHarness(); + harness.query.resolveContextUsage({ + totalTokens: 1_234, + maxTokens: 200_000, + isAutoCompactEnabled: true, + } as SDKControlGetContextUsageResponse); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Single consumer — streamEvents is a shared queue (competing fibers drop events). + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => + event.type === "thread.token-usage.updated" && + event.raw?.method === "claude/getContextUsage", + ).pipe(Stream.runCollect, Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-context-refresh", + uuid: "result-context-refresh", + usage: { + input_tokens: 10, + output_tokens: 5, + }, + } as unknown as SDKMessage); + + const runtimeEvents = Array.from( + yield* Fiber.join(runtimeEventsFiber).pipe(Effect.timeout("2 seconds")), + ); + const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + } + + const refresh = runtimeEvents.find( + (event) => + event.type === "thread.token-usage.updated" && + event.raw?.method === "claude/getContextUsage", + ); + assert.equal(refresh?.type, "thread.token-usage.updated"); + if (refresh?.type === "thread.token-usage.updated") { + assert.equal(String(refresh.turnId), String(turn.turnId)); + assert.equal(refresh.payload.usage.usedTokens, 1_234); + assert.equal(refresh.payload.usage.maxTokens, 200_000); + assert.equal(refresh.payload.usage.compactsAutomatically, true); + } + + const turnCompletedIndex = runtimeEvents.findIndex( + (event) => event.type === "turn.completed", + ); + const refreshIndex = runtimeEvents.findIndex( + (event) => + event.type === "thread.token-usage.updated" && + event.raw?.method === "claude/getContextUsage", + ); + assert.equal(turnCompletedIndex >= 0 && refreshIndex > turnCompletedIndex, true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..e394a3f16a2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -61,6 +61,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -70,7 +71,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeEnvironment, resolveClaudeCodeExecutable } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, isClaudeUltracodeEffort, @@ -91,7 +92,7 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); -const PROVIDER = ProviderDriverKind.make("claudeAgent"); +const DEFAULT_CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, @@ -214,6 +215,12 @@ interface ClaudeQueryRuntime extends AsyncIterable { export interface ClaudeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + /** + * Driver kind stamped on runtime events/errors. Defaults to `claudeAgent`. + * OpenRouter (and similar Claude-runtime drivers) pass their own kind so + * sessions are attributed correctly without forking the adapter. + */ + readonly provider?: ProviderDriverKind; readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; @@ -486,6 +493,104 @@ function normalizeClaudeContextUsageApiSnapshot( }); } +/** Prefer result/last-known usage so turn completion never waits on getContextUsage. */ +function buildTurnUsageSnapshotFromResult(input: { + readonly result: SDKResultMessage | undefined; + readonly maxTokens: number | undefined; + readonly accumulatedTotalProcessedTokens: number | undefined; + readonly lastGoodUsage: ThreadTokenUsageSnapshot | undefined; +}): ThreadTokenUsageSnapshot | undefined { + const resultUsageRecord = + input.result?.usage && + typeof input.result.usage === "object" && + !Array.isArray(input.result.usage) + ? (input.result.usage as Record) + : undefined; + const hasResultUsageIteration = + resultUsageRecord !== undefined && lastClaudeUsageIteration(resultUsageRecord) !== undefined; + const resultHasActiveUsage = + resultUsageRecord !== undefined && + (hasResultUsageIteration || + claudeUsageInputTokens(resultUsageRecord) + claudeUsageOutputTokens(resultUsageRecord) > 0); + const resultTotalOnly = + resultUsageRecord !== undefined && + !resultHasActiveUsage && + claudeTotalProcessedTokens(resultUsageRecord) !== undefined; + const resultIterationSnapshot = resultUsageRecord + ? normalizeClaudeActiveTokenUsage( + resultUsageRecord, + input.maxTokens, + input.accumulatedTotalProcessedTokens, + ) + : undefined; + const lastGoodUsage = input.lastGoodUsage; + + if (resultTotalOnly && lastGoodUsage) { + return { + ...lastGoodUsage, + ...(typeof input.maxTokens === "number" && + Number.isFinite(input.maxTokens) && + input.maxTokens > 0 + ? { maxTokens: input.maxTokens } + : {}), + ...(typeof input.accumulatedTotalProcessedTokens === "number" && + Number.isFinite(input.accumulatedTotalProcessedTokens) && + input.accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { + totalProcessedTokens: input.accumulatedTotalProcessedTokens, + } + : {}), + }; + } + + if (resultIterationSnapshot) { + return resultIterationSnapshot; + } + + if (lastGoodUsage) { + return { + ...lastGoodUsage, + ...(typeof input.maxTokens === "number" && + Number.isFinite(input.maxTokens) && + input.maxTokens > 0 + ? { maxTokens: input.maxTokens } + : {}), + ...(typeof input.accumulatedTotalProcessedTokens === "number" && + Number.isFinite(input.accumulatedTotalProcessedTokens) && + input.accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { + totalProcessedTokens: input.accumulatedTotalProcessedTokens, + } + : {}), + }; + } + + return undefined; +} + +function buildTurnCompletedPayload(input: { + readonly status: ProviderRuntimeTurnStatus; + readonly result: SDKResultMessage | undefined; + readonly errorMessage: string | undefined; +}) { + const result = input.result; + const stopReason = + typeof result?.stop_reason === "string" && result.stop_reason.length > 0 + ? result.stop_reason + : undefined; + return { + state: input.status, + ...(stopReason !== undefined ? { stopReason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" ? { totalCostUsd: result.total_cost_usd } : {}), + ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), + }; +} + +/** Bound post-turn context refresh so a hung control RPC cannot stall Working forever. */ +const CONTEXT_USAGE_REFRESH_TIMEOUT = "1 second"; + function compactBoundaryTokenUsageSnapshot( message: Record, contextWindow?: number, @@ -941,8 +1046,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; readonly boundInstanceId: ProviderInstanceId; + readonly providerKind: ProviderDriverKind; }, ) { + const providerKind = dependencies.providerKind; const text = buildPromptText(input, dependencies.boundInstanceId); const sdkContent: Array> = []; @@ -957,7 +1064,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "turn/start", detail: `Unsupported Claude image attachment type '${attachment.mimeType}'.`, }); @@ -969,7 +1076,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( }); if (!attachmentPath) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "turn/start", detail: `Invalid attachment id '${attachment.id}'.`, }); @@ -979,7 +1086,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( Effect.mapError( (cause) => new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "turn/start", detail: "Failed to read attachment file.", cause, @@ -1190,20 +1297,21 @@ function toolResultBlocksFromUserMessage(message: SDKMessage): Array<{ } function toSessionError( + providerKind: ProviderDriverKind, threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); if (normalized.includes("unknown session") || normalized.includes("not found")) { return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, + provider: providerKind, threadId, cause, }); } if (normalized.includes("closed")) { return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, + provider: providerKind, threadId, cause, }); @@ -1211,13 +1319,18 @@ function toSessionError( return undefined; } -function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); +function toRequestError( + providerKind: ProviderDriverKind, + threadId: ThreadId, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(providerKind, threadId, cause); if (sessionError) { return sessionError; } return new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method, detail: `${method} failed`, cause, @@ -1339,14 +1452,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( claudeSettings: ClaudeSettings, options?: ClaudeAdapterLiveOptions, ) { + const providerKind = options?.provider ?? DEFAULT_CLAUDE_PROVIDER; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( - Effect.provideService(Path.Path, path), + // Agent SDK requires a native binary path; unwrap npm shims (esp. Windows .cmd). + const resolvedBinaryPath = yield* resolveClaudeCodeExecutable( + claudeSettings.binaryPath, + options?.environment, ); + const effectiveClaudeSettings = { + ...claudeSettings, + binaryPath: resolvedBinaryPath, + } satisfies ClaudeSettings; + const claudeEnvironment = yield* makeClaudeEnvironment( + effectiveClaudeSettings, + options?.environment, + ).pipe(Effect.provideService(Path.Path, path)); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1374,7 +1498,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( Effect.mapError( (cause) => new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "crypto/randomUUIDv4", detail: "Failed to generate Claude runtime identifier.", cause, @@ -1384,8 +1508,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + // Always stamp the bound driver kind so OpenRouter (and similar) sessions + // cannot accidentally emit `claudeAgent` events from shared Claude runtime code. const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + Queue.offer(runtimeEventQueue, { ...event, provider: providerKind }).pipe(Effect.asVoid); const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* ( context: ClaudeSessionContext, @@ -1407,7 +1533,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? message.uuid : yield* randomUUIDv4, kind: "notification", - provider: PROVIDER, + provider: providerKind, createdAt: observedAt, method: sdkNativeMethod(message), ...(typeof message.session_id === "string" @@ -1430,7 +1556,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const threadId = context.session.threadId; if (!threadId) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: providerKind, operation: "readThread", issue: "Session thread id is not initialized yet.", }); @@ -1540,7 +1666,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "content.delta", eventId: deltaStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: deltaStamp.createdAt, threadId: context.session.threadId, turnId: turnState.turnId, @@ -1571,7 +1697,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "item.completed", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, itemId: asRuntimeItemId(block.itemId), threadId: context.session.threadId, @@ -1663,7 +1789,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "thread.started", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, payload: { @@ -1694,7 +1820,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "runtime.error", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), @@ -1717,7 +1843,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "runtime.warning", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), @@ -1735,6 +1861,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: { readonly rawMethod?: string; readonly rawPayload?: unknown; + /** Prefer this when emitting after turnState was cleared (post-completion refresh). */ + readonly turnId?: TurnId; }, ) { if (!usage) { @@ -1745,15 +1873,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.lastKnownTotalProcessedTokens = usage.totalProcessedTokens ?? context.lastKnownTotalProcessedTokens; - const turnState = context.turnState; + const turnId = options?.turnId ?? context.turnState?.turnId; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "thread.token-usage.updated", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(turnState ? { turnId: turnState.turnId } : {}), + ...(turnId ? { turnId: asCanonicalTurnId(turnId) } : {}), payload: { usage, }, @@ -1822,7 +1950,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.proposed.completed", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, turnId: turnState.turnId, @@ -1857,7 +1985,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.plan.updated", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -1893,64 +2021,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens; } - const contextUsageSnapshot = yield* queryCurrentContextUsage( - context, - accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, - ); - const resultUsageRecord = - result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage) - ? (result.usage as Record) - : undefined; - const hasResultUsageIteration = - resultUsageRecord !== undefined && lastClaudeUsageIteration(resultUsageRecord) !== undefined; - const resultHasActiveUsage = - resultUsageRecord !== undefined && - (hasResultUsageIteration || - claudeUsageInputTokens(resultUsageRecord) + claudeUsageOutputTokens(resultUsageRecord) > 0); - const resultTotalOnly = - resultUsageRecord !== undefined && - !resultHasActiveUsage && - claudeTotalProcessedTokens(resultUsageRecord) !== undefined; - const resultIterationSnapshot = resultUsageRecord - ? normalizeClaudeActiveTokenUsage( - resultUsageRecord, - maxTokens, - accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, - ) - : undefined; - const lastGoodUsage = context.lastKnownTokenUsage; - const usageSnapshot: ThreadTokenUsageSnapshot | undefined = - contextUsageSnapshot ?? - (resultTotalOnly && lastGoodUsage - ? { - ...lastGoodUsage, - ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 - ? { maxTokens } - : {}), - ...(typeof accumulatedTotalProcessedTokens === "number" && - Number.isFinite(accumulatedTotalProcessedTokens) && - accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { - totalProcessedTokens: accumulatedTotalProcessedTokens, - } - : {}), - } - : resultIterationSnapshot) ?? - (lastGoodUsage - ? { - ...lastGoodUsage, - ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 - ? { maxTokens } - : {}), - ...(typeof accumulatedTotalProcessedTokens === "number" && - Number.isFinite(accumulatedTotalProcessedTokens) && - accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { - totalProcessedTokens: accumulatedTotalProcessedTokens, - } - : {}), - } - : undefined); + // Never await getContextUsage before turn.completed — that RPC can hang over + // OpenRouter and keeps session.status "running" (Working for Xs) after text is done. + const usageSnapshot = buildTurnUsageSnapshotFromResult({ + result, + maxTokens, + accumulatedTotalProcessedTokens: + accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, + lastGoodUsage: context.lastKnownTokenUsage, + }); + const completedPayload = buildTurnCompletedPayload({ status, result, errorMessage }); const turnState = context.turnState; if (!turnState) { @@ -1963,30 +2043,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.completed", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, + payload: completedPayload, providerRefs: {}, }); return; } + const completedTurnId = turnState.turnId; + for (const [index, tool] of context.inFlightTools.entries()) { const toolStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "item.completed", eventId: toolStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: toolStamp.createdAt, threadId: context.session.threadId, turnId: turnState.turnId, @@ -2037,20 +2110,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.completed", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, turnId: turnState.turnId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, + payload: completedPayload, providerRefs: nativeProviderRefs(context), }); @@ -2064,6 +2128,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), }; yield* updateResumeCursor(context); + + // Best-effort richer context meter after Working has already cleared. Inline + // (not forked) so we never stamp the next turn; timeout abandons a hung RPC. + if (status === "completed" && result !== undefined && context.query.getContextUsage) { + const refreshedUsage = yield* queryCurrentContextUsage( + context, + accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, + ).pipe(Effect.timeoutOption(CONTEXT_USAGE_REFRESH_TIMEOUT)); + if (Option.isSome(refreshedUsage) && refreshedUsage.value !== undefined) { + yield* emitThreadTokenUsage(context, refreshedUsage.value, { + rawMethod: "claude/getContextUsage", + rawPayload: { afterTurnId: String(completedTurnId) }, + turnId: completedTurnId, + }); + } + } }); const handleStreamEvent = Effect.fn("handleStreamEvent")(function* ( @@ -2126,7 +2206,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "content.delta", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, turnId: context.turnState.turnId, @@ -2189,7 +2269,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "item.updated", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(context.turnState @@ -2226,7 +2306,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.plan.updated", eventId: planStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: planStamp.createdAt, threadId: context.session.threadId, ...(context.turnState @@ -2288,7 +2368,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "item.started", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -2366,7 +2446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "item.updated", eventId: updatedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: updatedStamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -2394,7 +2474,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "content.delta", eventId: deltaStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: deltaStamp.createdAt, threadId: context.session.threadId, turnId: context.turnState.turnId, @@ -2418,7 +2498,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "item.completed", eventId: completedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: completedStamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -2488,7 +2568,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.started", eventId: turnStartedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, turnId, @@ -2572,7 +2652,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const stamp = yield* makeEventStamp(); const base = { eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -2777,7 +2857,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const stamp = yield* makeEventStamp(); const base = { eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -2891,7 +2971,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.query, (cause) => new ProviderAdapterProcessError({ - provider: PROVIDER, + provider: providerKind, threadId: context.session.threadId, detail: "Claude runtime stream failed.", cause, @@ -2903,7 +2983,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( Effect.mapError( (cause) => new ProviderAdapterProcessError({ - provider: PROVIDER, + provider: providerKind, threadId: context.session.threadId, detail: "Failed to process Claude runtime event.", cause, @@ -2960,7 +3040,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "request.resolved", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -2990,7 +3070,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( try: () => context.query.close(), catch: (cause) => new ProviderAdapterProcessError({ - provider: PROVIDER, + provider: providerKind, threadId: context.session.threadId, detail: "Failed to close Claude runtime query.", cause, @@ -3019,7 +3099,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "session.exited", eventId: stamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, payload: { @@ -3040,7 +3120,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (!context) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, + provider: providerKind, threadId, }), ); @@ -3048,7 +3128,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.stopped || context.session.status === "closed") { return Effect.fail( new ProviderAdapterSessionClosedError({ - provider: PROVIDER, + provider: providerKind, threadId, }), ); @@ -3058,11 +3138,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { - if (input.provider !== undefined && input.provider !== PROVIDER) { + if (input.provider !== undefined && input.provider !== providerKind) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: providerKind, operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + issue: `Expected provider '${providerKind}' but received '${input.provider}'.`, }); } @@ -3162,7 +3242,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "user-input.requested", eventId: requestedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, ...(context.turnState @@ -3209,7 +3289,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "user-input.resolved", eventId: resolvedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, ...(context.turnState @@ -3312,7 +3392,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "request.opened", eventId: requestedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -3360,7 +3440,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "request.resolved", eventId: resolvedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), @@ -3405,8 +3485,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const claudeBinaryPath = claudeSettings.binaryPath; - const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; + const claudeBinaryPath = effectiveClaudeSettings.binaryPath; + const extraArgs = parseCliArgs(effectiveClaudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -3481,7 +3561,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; yield* Effect.annotateCurrentSpan({ - "provider.kind": PROVIDER, + "provider.kind": providerKind, "provider.thread_id": threadId, "provider.runtime_mode": input.runtimeMode, "claude.resume.source": @@ -3513,7 +3593,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }), catch: (cause) => new ProviderAdapterProcessError({ - provider: PROVIDER, + provider: providerKind, threadId, detail: "Failed to start Claude runtime session.", cause, @@ -3522,7 +3602,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const session: ProviderSession = { threadId, - provider: PROVIDER, + provider: providerKind, providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, @@ -3568,7 +3648,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "session.started", eventId: sessionStartedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: sessionStartedStamp.createdAt, threadId, payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, @@ -3579,7 +3659,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "session.configured", eventId: configuredStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: configuredStamp.createdAt, threadId, payload: { @@ -3598,7 +3678,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "session.state.changed", eventId: readyStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: readyStamp.createdAt, threadId, payload: { @@ -3661,7 +3741,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), - catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + catch: (cause) => toRequestError(providerKind, input.threadId, "turn/setModel", cause), }); context.currentApiModelId = apiModelId; } @@ -3678,12 +3758,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (input.interactionMode === "plan") { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode("plan"), - catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + catch: (cause) => + toRequestError(providerKind, input.threadId, "turn/setPermissionMode", cause), }); } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), - catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + catch: (cause) => + toRequestError(providerKind, input.threadId, "turn/setPermissionMode", cause), }); } @@ -3712,7 +3794,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "turn.started", eventId: turnStartedStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, turnId, @@ -3725,12 +3807,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( fileSystem, attachmentsDir: serverConfig.attachmentsDir, boundInstanceId, + providerKind, }); yield* Queue.offer(context.promptQueue, { type: "message", message, - }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); + }).pipe( + Effect.mapError((cause) => toRequestError(providerKind, input.threadId, "turn/start", cause)), + ); return { threadId: context.session.threadId, @@ -3746,7 +3831,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context = yield* requireSession(threadId); yield* Effect.tryPromise({ try: () => context.query.interrupt(), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + catch: (cause) => toRequestError(providerKind, threadId, "turn/interrupt", cause), }); }, ); @@ -3774,7 +3859,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const pending = context.pendingApprovals.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "item/requestApproval/decision", detail: `Unknown pending approval request: ${requestId}`, }); @@ -3792,7 +3877,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const pending = context.pendingUserInputs.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: providerKind, method: "item/tool/respondToUserInput", detail: `Unknown pending user-input request: ${requestId}`, }); @@ -3847,7 +3932,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ); return { - provider: PROVIDER, + provider: providerKind, capabilities: { sessionModelSwitch: "in-session", }, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b..d1383497c74 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -11,14 +11,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -30,14 +29,11 @@ import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, - DEFAULT_TIMEOUT_MS, - isCommandMissingCause, - parseGenericCliVersion, + probeCliVersion, providerModelsFromSettings, - spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeEnvironment, resolveClaudeCodeExecutable } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -588,6 +584,10 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const pathToClaudeCodeExecutable = yield* resolveClaudeCodeExecutable( + claudeSettings.binaryPath, + environment, + ); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -598,7 +598,7 @@ const probeClaudeCapabilities = ( })(), options: { persistSession: false, - pathToClaudeCodeExecutable: claudeSettings.binaryPath, + pathToClaudeCodeExecutable, abortController: abort, settingSources: ["user", "project", "local"], allowedTools: [], @@ -637,22 +637,6 @@ const probeClaudeCapabilities = ( ); }; -const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( - claudeSettings: ClaudeSettings, - args: ReadonlyArray, - environment?: NodeJS.ProcessEnv, -) { - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { - env: claudeEnvironment, - }); - const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: claudeEnvironment, - shell: spawnCommand.shell, - }); - return yield* spawnAndCollect(claudeSettings.binaryPath, command); -}); - export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( claudeSettings: ClaudeSettings, resolveCapabilities?: ( @@ -689,16 +673,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand( - claudeSettings, - ["--version"], - resolvedEnvironment, - ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment); + const versionProbe = yield* probeCliVersion(claudeSettings.binaryPath, claudeEnvironment); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; + if (versionProbe.kind === "missing" || versionProbe.kind === "error") { yield* Effect.logWarning("Claude Agent CLI health check failed.", { - errorTag: error._tag, + probeKind: versionProbe.kind, }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, @@ -706,18 +686,19 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models: allModels, probe: { - installed: !isCommandMissingCause(error), + installed: versionProbe.kind !== "missing", version: null, status: "error", auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : "Failed to execute Claude Agent CLI health check.", + message: + versionProbe.kind === "missing" + ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + : "Failed to execute Claude Agent CLI health check.", }, }); } - if (Option.isNone(versionProbe.success)) { + if (versionProbe.kind === "timeout") { return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -734,13 +715,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { + if (versionProbe.kind === "failed") { yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { - exitCode: version.code, - stdoutLength: version.stdout.length, - stderrLength: version.stderr.length, + exitCode: versionProbe.code, }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, @@ -749,7 +726,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( models: allModels, probe: { installed: true, - version: parsedVersion, + version: versionProbe.version, status: "error", auth: { status: "unknown" }, message: "Claude Agent CLI is installed but failed to run.", @@ -757,6 +734,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const parsedVersion = versionProbe.version; const models = providerModelsFromSettings( getBuiltInClaudeModelsForVersion(parsedVersion), PROVIDER, diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.test.ts b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts new file mode 100644 index 00000000000..56ff2252949 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts @@ -0,0 +1,168 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { OpenRouterSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { + checkOpenRouterProviderStatus, + makePendingOpenRouterProvider, +} from "./OpenRouterProvider.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); + +const makeModelsHttpClient = (status: number, body: unknown) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), + ); + +const EmptyModelsHttpClientLive = makeModelsHttpClient(200, { data: [] }); +const ValidModelsHttpClientLive = makeModelsHttpClient(200, { + data: [{ id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4" }], +}); +const UnauthorizedModelsHttpClientLive = makeModelsHttpClient(401, { error: "unauthorized" }); + +describe("makePendingOpenRouterProvider", () => { + it.effect("builds a disabled snapshot with fallback models", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider( + decodeOpenRouterSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.displayName).toBe("OpenRouter"); + expect(snapshot.models.some((model) => model.slug === "anthropic/claude-sonnet-4")).toBe( + true, + ); + }), + ); + + it.effect("builds a pending snapshot for enabled OpenRouter", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider(decodeOpenRouterSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toMatch(/not been checked/i); + }), + ); +}); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(EmptyModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (missing binary)", + (it) => { + it.effect("reports a missing Claude runtime binary without throwing", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + binaryPath: "/definitely/not/installed/claude-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(ValidModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (auth independent of CLI)", + (it) => { + it.effect("reports authenticated when CLI is missing but API key is valid", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + binaryPath: "/definitely/not/installed/claude-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.some((model) => model.slug === "anthropic/claude-sonnet-4")).toBe( + true, + ); + }), + ); + + it.effect("reports ready when CLI version probe and auth both succeed", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + // `node --version` is a reliable cross-platform success probe. + binaryPath: process.execPath, + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.message).toMatch(/openrouter\.ai\/api/i); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(UnauthorizedModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (401)", + (it) => { + it.effect("reports unauthenticated on 401 even when CLI is healthy", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-bad", + binaryPath: process.execPath, + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toMatch(/API key/i); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(ValidModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (empty key)", + (it) => { + it.effect("reports unauthenticated when settings apiKey is empty", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "", + binaryPath: process.execPath, + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toMatch(/Add an OpenRouter API key/i); + }), + ); + }, +); diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.ts b/apps/server/src/provider/Layers/OpenRouterProvider.ts new file mode 100644 index 00000000000..e449cf8bd22 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.ts @@ -0,0 +1,240 @@ +import type { OpenRouterSettings, ServerProviderModel } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + probeCliVersion, + providerModelsFromSettings, + type CliVersionProbeResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + FALLBACK_OPENROUTER_MODELS, + EMPTY_OPENROUTER_CAPABILITIES, + fetchOpenRouterModels, + type OpenRouterModelFetchResult, +} from "../openrouter/OpenRouterModels.ts"; +import { + OPENROUTER_DRIVER_KIND, + normalizeOpenRouterBaseUrl, +} from "../openrouter/OpenRouterRuntime.ts"; + +const OPENROUTER_PRESENTATION = { + displayName: "OpenRouter", + showInteractionModeToggle: true, +} as const; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function modelsFromSettings( + builtIn: ReadonlyArray, + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings( + builtIn, + OPENROUTER_DRIVER_KIND, + customModels, + EMPTY_OPENROUTER_CAPABILITIES, + ); +} + +function cliProbeFields(cli: CliVersionProbeResult): { + readonly installed: boolean; + readonly version: string | null; + readonly cliOk: boolean; + readonly cliMessage: string; +} { + switch (cli.kind) { + case "missing": + return { + installed: false, + version: null, + cliOk: false, + cliMessage: + "Claude Agent CLI (`claude`) is not installed or not on PATH. OpenRouter uses Claude Code as its agent runtime.", + }; + case "error": + return { + installed: true, + version: null, + cliOk: false, + cliMessage: "Failed to execute Claude Agent CLI health check for OpenRouter.", + }; + case "timeout": + return { + installed: true, + version: null, + cliOk: false, + cliMessage: "Claude Agent CLI timed out while running `--version` for OpenRouter.", + }; + case "failed": + return { + installed: true, + version: cli.version, + cliOk: false, + cliMessage: "Claude Agent CLI is installed but failed to run for OpenRouter.", + }; + case "ok": + return { + installed: true, + version: cli.version, + cliOk: true, + cliMessage: "", + }; + default: { + const _exhaustive: never = cli; + return _exhaustive; + } + } +} + +function mergeOpenRouterProbe(input: { + readonly settings: OpenRouterSettings; + readonly checkedAt: string; + readonly fallbackModels: ReadonlyArray; + readonly cli: CliVersionProbeResult; + readonly modelFetch: OpenRouterModelFetchResult; +}): ServerProviderDraft { + const { settings, checkedAt, fallbackModels, cli, modelFetch } = input; + const cliFields = cliProbeFields(cli); + const authOk = modelFetch.ok; + const auth = authOk + ? { + status: "authenticated" as const, + type: "api_key" as const, + label: "OpenRouter API key", + } + : { + status: modelFetch.authFailed ? ("unauthenticated" as const) : ("unknown" as const), + ...(modelFetch.authFailed ? { label: "OpenRouter API key" as const } : {}), + }; + + const models = authOk + ? modelsFromSettings(modelFetch.models, settings.customModels) + : fallbackModels; + + if (cliFields.cliOk && authOk) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: cliFields.version, + status: "ready", + auth, + message: `Using ${normalizeOpenRouterBaseUrl(settings.baseUrl)} via Claude Code runtime.`, + }, + }); + } + + const messages: string[] = []; + if (!cliFields.cliOk) { + messages.push(cliFields.cliMessage); + } + if (!authOk) { + messages.push(modelFetch.message); + } + + const status = + !cliFields.cliOk || (modelFetch.ok === false && modelFetch.authFailed) ? "error" : "warning"; + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: cliFields.installed, + version: cliFields.version, + status, + auth, + message: messages.join(" "), + }, + }); +} + +export const makePendingOpenRouterProvider = ( + settings: OpenRouterSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter provider status has not been checked in this session yet.", + }, + }); + }); + +export const checkOpenRouterProviderStatus = Effect.fn("checkOpenRouterProviderStatus")(function* ( + settings: OpenRouterSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient +> { + const resolvedEnvironment = environment ?? process.env; + const checkedAt = yield* nowIso; + const fallbackModels = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter is disabled in T3 Code settings.", + }, + }); + } + + // CLI health and OpenRouter auth are independent — probe in parallel and merge. + const [cli, modelFetch] = yield* Effect.all( + [probeCliVersion(settings.binaryPath, resolvedEnvironment), fetchOpenRouterModels(settings)], + { concurrency: 2 }, + ); + + return mergeOpenRouterProbe({ + settings, + checkedAt, + fallbackModels, + cli, + modelFetch, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 73390450efa..4879f72550c 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `openrouter`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,9 +18,9 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` - * binaries. That keeps the assertions focused on registry routing - * behaviour rather than the runtime details of each provider. + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / + * `openrouter` / `opencode` binaries. That keeps the assertions focused on + * registry routing behaviour rather than the runtime details of each provider. */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -30,6 +30,7 @@ import { type CursorSettings, type GrokSettings, type OpenCodeSettings, + type OpenRouterSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, @@ -45,6 +46,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { OpenRouterDriver } from "../Drivers/OpenRouterDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -89,6 +91,17 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeOpenRouterConfig = (overrides: Partial): OpenRouterSettings => ({ + enabled: false, + apiKey: "", + baseUrl: "https://openrouter.ai/api", + binaryPath: "claude", + httpReferer: "", + appTitle: "T3 Code", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -257,12 +270,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); + const openRouterId = ProviderInstanceId.make("openrouter_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); + const openRouterDriverKind = ProviderDriverKind.make("openrouter"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -293,6 +308,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeGrokConfig({}), }, + [openRouterId]: { + driver: openRouterDriverKind, + displayName: "OpenRouter", + enabled: false, + config: makeOpenRouterConfig({}), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -302,7 +323,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [ + CodexDriver, + ClaudeDriver, + CursorDriver, + GrokDriver, + OpenRouterDriver, + OpenCodeDriver, + ], configMap, }); @@ -312,9 +340,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openRouterId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -324,16 +352,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); + const openRouter = yield* registry.getInstance(openRouterId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); + expect(openRouter?.driverKind).toBe(openRouterDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); + expect(openRouter?.displayName).toBe("OpenRouter"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -346,6 +377,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.adapter, cursor!.adapter, grok!.adapter, + openRouter!.adapter, openCode!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); @@ -354,6 +386,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.textGeneration, cursor!.textGeneration, grok!.textGeneration, + openRouter!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); @@ -362,6 +395,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.snapshot, cursor!.snapshot, grok!.snapshot, + openRouter!.snapshot, openCode!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -398,6 +432,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(grokSnapshot.enabled).toBe(false); expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + const openRouterSnapshot = yield* openRouter!.snapshot.getSnapshot; + expect(openRouterSnapshot.instanceId).toBe(openRouterId); + expect(openRouterSnapshot.driver).toBe(openRouterDriverKind); + expect(openRouterSnapshot.enabled).toBe(false); + expect(openRouterSnapshot.continuation?.groupKey).toBe( + `${openRouterDriverKind}:instance:${openRouterId}`, + ); + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf5..692031ba1bc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1076,6 +1076,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + openrouter: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + openrouter: { enabled: false }, opencode: { enabled: false }, }, }), @@ -1300,6 +1302,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + openrouter: { enabled: false }, opencode: { enabled: false }, }, providerInstances: { @@ -1369,6 +1372,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, + openrouter: { + enabled: false, + }, }, }), ), @@ -1438,6 +1444,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "openrouter", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..f8d39e68bf7 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenRouterDriver, type OpenRouterDriverEnv } from "./Drivers/OpenRouterDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | OpenRouterDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ + { + slug: DEFAULT_OPENROUTER_MODEL, + name: "Claude Sonnet 4", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, + { + slug: "anthropic/claude-opus-4", + name: "Claude Opus 4", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, + { + slug: "openai/gpt-4o", + name: "GPT-4o", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, +]; + +const OpenRouterModelsResponse = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + }), + ), +}); + +const decodeOpenRouterModelsResponse = Schema.decodeUnknownEffect(OpenRouterModelsResponse); +const MAX_DISCOVERED_MODELS = 200; + +export type OpenRouterModelFetchResult = + | { + readonly ok: true; + readonly models: ReadonlyArray; + } + | { + readonly ok: false; + readonly authFailed: boolean; + readonly message: string; + }; + +export const fetchOpenRouterModels = Effect.fn("fetchOpenRouterModels")(function* ( + settings: OpenRouterSettings, +): Effect.fn.Return { + const apiKey = settings.apiKey.trim(); + if (apiKey.length === 0) { + return { + ok: false, + authFailed: true, + message: "Add an OpenRouter API key in provider settings.", + }; + } + + const httpClient = yield* HttpClient.HttpClient; + const url = openRouterModelsUrl(settings.baseUrl); + + // Timeout covers both headers and body consumption so a stalling response + // body cannot hang the provider status refresh indefinitely. + const fetchResult = yield* Effect.gen(function* () { + const httpResponse = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.acceptJson, + httpClient.execute, + ); + + if (httpResponse.status === 401 || httpResponse.status === 403) { + return { + ok: false as const, + authFailed: true, + message: "OpenRouter API key is missing or invalid.", + } satisfies OpenRouterModelFetchResult; + } + + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return { + ok: false as const, + authFailed: false, + message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, + } satisfies OpenRouterModelFetchResult; + } + + const body = yield* httpResponse.json.pipe(Effect.result); + if (Result.isFailure(body)) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter models API returned an unreadable response.", + } satisfies OpenRouterModelFetchResult; + } + + const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); + if (Result.isFailure(decoded)) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter models API returned an unexpected payload.", + } satisfies OpenRouterModelFetchResult; + } + + const models = decoded.success.data + .filter((model) => model.id.trim().length > 0) + .slice(0, MAX_DISCOVERED_MODELS) + .map( + (model): ServerProviderModel => ({ + slug: model.id, + name: model.name?.trim() || model.id, + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }), + ); + + if (models.length === 0) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter returned an empty model catalog.", + } satisfies OpenRouterModelFetchResult; + } + + return { ok: true as const, models } satisfies OpenRouterModelFetchResult; + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(fetchResult)) { + return { + ok: false, + authFailed: false, + message: "Failed to reach OpenRouter models API.", + }; + } + + if (Option.isNone(fetchResult.success)) { + return { + ok: false, + authFailed: false, + message: "Timed out while fetching OpenRouter models.", + }; + } + + return fetchResult.success.value; +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts new file mode 100644 index 00000000000..53ec23b78fa --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { OpenRouterSettings } from "@t3tools/contracts"; + +import { + buildOpenRouterProcessEnv, + normalizeOpenRouterBaseUrl, + openRouterModelsUrl, + toClaudeSettings, +} from "./OpenRouterRuntime.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); + +describe("OpenRouterRuntime", () => { + it("normalizes base URL trailing slashes", () => { + expect(normalizeOpenRouterBaseUrl("https://openrouter.ai/api/")).toBe( + "https://openrouter.ai/api", + ); + expect(normalizeOpenRouterBaseUrl("")).toBe("https://openrouter.ai/api"); + }); + + it("builds the tools-capable models URL", () => { + expect(openRouterModelsUrl("https://openrouter.ai/api")).toBe( + "https://openrouter.ai/api/v1/models?supported_parameters=tools", + ); + }); + + it("maps settings into Claude settings + OpenRouter-owned process env", () => { + const settings = decodeOpenRouterSettings({ + apiKey: "sk-or-test", + baseUrl: "https://openrouter.ai/api/", + binaryPath: "claude", + httpReferer: "https://t3.chat", + appTitle: "T3 Code", + }); + + expect(toClaudeSettings(settings)).toMatchObject({ + enabled: true, + binaryPath: "claude", + homePath: "", + launchArgs: "", + }); + + const env = buildOpenRouterProcessEnv(settings, { + PATH: "/usr/bin", + ANTHROPIC_API_KEY: "host-anthropic-key", + ANTHROPIC_AUTH_TOKEN: "host-token", + OPENROUTER_API_KEY: "host-openrouter", + OR_SITE_URL: "https://leaked.example", + OR_APP_NAME: "Leaked", + }); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + // OpenRouter Claude Code contract: auth token + empty API key. + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-or-test"); + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.OPENROUTER_API_KEY).toBe("sk-or-test"); + expect(env.HTTP_REFERER).toBe("https://t3.chat"); + expect(env.X_TITLE).toBe("T3 Code"); + expect(env.OR_SITE_URL).toBeUndefined(); + expect(env.OR_APP_NAME).toBeUndefined(); + expect(env.PATH).toBe("/usr/bin"); + }); + + it("clears inherited Anthropic credentials when settings apiKey is empty", () => { + const settings = decodeOpenRouterSettings({ + apiKey: "", + httpReferer: "", + appTitle: "", + }); + const env = buildOpenRouterProcessEnv(settings, { + ANTHROPIC_API_KEY: "sk-ant-host", + ANTHROPIC_AUTH_TOKEN: "host-token", + OPENROUTER_API_KEY: "sk-or-host", + HTTP_REFERER: "https://host.example", + X_TITLE: "Host App", + }); + + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe(""); + expect(env.OPENROUTER_API_KEY).toBe(""); + expect(env.HTTP_REFERER).toBeUndefined(); + expect(env.X_TITLE).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + }); +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts new file mode 100644 index 00000000000..8bf81ff9298 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -0,0 +1,99 @@ +import { ClaudeSettings, type OpenRouterSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const OPENROUTER_DRIVER_KIND = ProviderDriverKind.make("openrouter"); +export const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api"; +export const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4"; + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +/** + * Anthropic-compat credential env vars that OpenRouter owns for the Claude + * Code runtime. Always overwritten (never inherited from the host process). + */ +const OPENROUTER_OWNED_ENV_KEYS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "OPENROUTER_API_KEY", + "HTTP_REFERER", + "X_TITLE", + // Legacy aliases that must not leak from the host into OpenRouter sessions. + "OR_SITE_URL", + "OR_APP_NAME", +] as const; + +export function normalizeOpenRouterBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + const normalized = (trimmed.length > 0 ? trimmed : DEFAULT_OPENROUTER_BASE_URL).replace( + /\/+$/, + "", + ); + return normalized; +} + +export function openRouterModelsUrl(baseUrl: string): string { + return `${normalizeOpenRouterBaseUrl(baseUrl)}/v1/models?supported_parameters=tools`; +} + +/** + * Narrow Claude runtime config needed by `makeClaudeAdapter` / text generation. + * OpenRouter does not expose Claude homePath / launchArgs in its settings. + */ +export function toClaudeSettings(settings: OpenRouterSettings): ClaudeSettings { + return decodeClaudeSettings({ + enabled: settings.enabled, + binaryPath: settings.binaryPath, + homePath: "", + customModels: settings.customModels, + launchArgs: "", + }); +} + +/** + * Build the process env for OpenRouter-backed Claude Code sessions. + * + * Matches OpenRouter's Claude Code contract: + * - `ANTHROPIC_BASE_URL` → OpenRouter Anthropic skin (`https://openrouter.ai/api`) + * - `ANTHROPIC_AUTH_TOKEN` → OpenRouter API key + * - `ANTHROPIC_API_KEY` → always `""` so Claude Code does not prefer a host Anthropic key + * + * Owned credential/attribution keys are always cleared first so host values cannot leak + * when settings omit them. + */ +export function buildOpenRouterProcessEnv( + settings: OpenRouterSettings, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...baseEnv }; + for (const key of OPENROUTER_OWNED_ENV_KEYS) { + delete next[key]; + } + + const baseUrl = normalizeOpenRouterBaseUrl(settings.baseUrl); + const apiKey = settings.apiKey.trim(); + + next.ANTHROPIC_BASE_URL = baseUrl; + // Critical: empty string (not unset) so Claude Code does not fall back to Anthropic auth. + next.ANTHROPIC_API_KEY = ""; + + if (apiKey.length > 0) { + next.ANTHROPIC_AUTH_TOKEN = apiKey; + next.OPENROUTER_API_KEY = apiKey; + } else { + next.ANTHROPIC_AUTH_TOKEN = ""; + next.OPENROUTER_API_KEY = ""; + } + + const httpReferer = settings.httpReferer.trim(); + if (httpReferer.length > 0) { + next.HTTP_REFERER = httpReferer; + } + + const appTitle = settings.appTitle.trim(); + if (appTitle.length > 0) { + next.X_TITLE = appTitle; + } + + return next; +} diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc44..2ef616785d5 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -8,8 +8,11 @@ import type { ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -97,6 +100,57 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman return result; }).pipe(Effect.scoped); +/** + * Shared `--version` (or custom-args) CLI health probe used by Claude-runtime + * providers (Claude, OpenRouter, …). + */ +export type CliVersionProbeResult = + | { readonly kind: "missing"; readonly cause: unknown } + | { readonly kind: "error"; readonly cause: unknown } + | { readonly kind: "timeout" } + | { readonly kind: "failed"; readonly version: string | null; readonly code: number } + | { readonly kind: "ok"; readonly version: string | null }; + +export const probeCliVersion = Effect.fn("probeCliVersion")(function* ( + binaryPath: string, + environment: NodeJS.ProcessEnv, + options?: { + readonly args?: ReadonlyArray; + readonly timeoutMs?: number; + }, +): Effect.fn.Return { + const args = options?.args ?? ["--version"]; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const spawnCommand = yield* resolveSpawnCommand(binaryPath, args, { + env: environment, + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }); + const probe = yield* spawnAndCollect(binaryPath, command).pipe( + Effect.timeoutOption(timeoutMs), + Effect.result, + ); + + if (Result.isFailure(probe)) { + return { + kind: isCommandMissingCause(probe.failure) ? "missing" : "error", + cause: probe.failure, + } as const; + } + if (Option.isNone(probe.success)) { + return { kind: "timeout" } as const; + } + + const version = probe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + return { kind: "failed", version: parsedVersion, code: version.code } as const; + } + return { kind: "ok", version: parsedVersion } as const; +}); + export function detailFromResult( result: CommandResult & { readonly timedOut?: boolean }, ): string | undefined { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..cca05bc96b9 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,7 +7,13 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "openrouter" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..4a6171687cc 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,29 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const OpenRouterIcon: Icon = ({ className, ...props }) => ( + + + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..674c7d04155 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, + OpenRouterIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("openrouter")]: OpenRouterIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index f1100ab93d2..a37a7e215f1 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -34,6 +34,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial>; @@ -61,6 +70,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("openrouter"), + label: "OpenRouter", + icon: OpenRouterIcon, + settingsSchema: OpenRouterSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..040c30dcbc0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -159,6 +159,16 @@ type ProviderOptionSelectionsByProvider = Partial< Record> >; +/** Canonical provider keys for legacy modelOptions migration / draft sync. */ +const COMPOSER_PROVIDER_OPTION_KEYS = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "openrouter", + "opencode", +] as const; + type LegacyCodexFields = { effort?: unknown; codexFastMode?: unknown; @@ -761,7 +771,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of COMPOSER_PROVIDER_OPTION_KEYS) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -920,7 +930,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of COMPOSER_PROVIDER_OPTION_KEYS) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2654,7 +2664,7 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of COMPOSER_PROVIDER_OPTION_KEYS) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f..4c70bdf459f 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,4 +1,9 @@ -import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; +import { + PROVIDER_DISPLAY_NAMES, + type OrchestrationThreadActivity, + type ProviderDriverKind, + type ThreadTokenUsageSnapshot, +} from "@t3tools/contracts"; function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; @@ -28,23 +33,16 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { /** Map a provider driver kind to a user-facing display name. */ export function formatProviderDisplayName(provider: string | null | undefined): string { if (!provider) return "This agent"; - switch (provider) { - case "claudeAgent": - case "claude": - return "Claude"; - case "codex": - return "Codex"; - case "cursor": - return "Cursor"; - case "opencode": - return "OpenCode"; - default: { - // Title-case unknown driver kinds so they read reasonably. - const trimmed = provider.replace(/Agent$/i, "").trim(); - if (trimmed.length === 0) return provider; - return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); - } - } + + // Legacy alias used in older activity payloads. + const driverKind = (provider === "claude" ? "claudeAgent" : provider) as ProviderDriverKind; + const known = PROVIDER_DISPLAY_NAMES[driverKind]; + if (known) return known; + + // Title-case unknown driver kinds so they read reasonably. + const trimmed = provider.replace(/Agent$/i, "").trim(); + if (trimmed.length === 0) return provider; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); } export function deriveLatestContextWindowSnapshot( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..7ad80affe18 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("openrouter"), + label: "OpenRouter", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..673ee4fc0b1 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 GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const OPENROUTER_DRIVER_KIND = ProviderDriverKind.make("openrouter"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.4"; @@ -141,6 +142,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [OPENROUTER_DRIVER_KIND]: "OpenRouter", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..4a677e92247 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -42,6 +42,13 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { // Legacy `providers` struct is still hydrated with its per-driver defaults // so existing call sites keep working through the migration. expect(decoded.providers.codex.enabled).toBe(true); + expect(decoded.providers.openrouter.enabled).toBe(true); + expect(decoded.providers.openrouter.apiKey).toBe(""); + expect(decoded.providers.openrouter.baseUrl).toBe("https://openrouter.ai/api"); + expect(decoded.providers.openrouter.binaryPath).toBe("claude"); + expect(decoded.providers.openrouter.httpReferer).toBe(""); + expect(decoded.providers.openrouter.appTitle).toBe("T3 Code"); + expect(decoded.providers.openrouter.customModels).toEqual([]); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b05f397bf5c..aa0d94405a7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -305,6 +305,75 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const OpenRouterSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "API key", + description: "OpenRouter API key. Stored in plain text on disk.", + providerSettingsForm: { + control: "password", + placeholder: "sk-or-...", + clearWhenEmpty: "omit", + }, + }), + ), + baseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("https://openrouter.ai/api")), + Schema.annotateKey({ + title: "Base URL", + description: "OpenRouter API base URL (Anthropic-compatible).", + providerSettingsForm: { + placeholder: "https://openrouter.ai/api", + clearWhenEmpty: "omit", + }, + }), + ), + binaryPath: makeBinaryPathSetting("claude").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Claude Agent CLI used as the OpenRouter runtime.", + providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" }, + }), + ), + httpReferer: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "HTTP referer", + description: "Optional site URL sent as HTTP-Referer for OpenRouter rankings.", + providerSettingsForm: { + placeholder: "https://your-app.example", + clearWhenEmpty: "omit", + }, + }), + ), + appTitle: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("T3 Code")), + Schema.annotateKey({ + title: "App title", + description: "Optional app name sent as X-Title for OpenRouter rankings.", + providerSettingsForm: { + placeholder: "T3 Code", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["apiKey", "baseUrl", "binaryPath", "httpReferer", "appTitle"], + }, +); +export type OpenRouterSettings = typeof OpenRouterSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -398,6 +467,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + openrouter: OpenRouterSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -493,6 +563,16 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OpenRouterSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + apiKey: Schema.optionalKey(TrimmedString), + baseUrl: Schema.optionalKey(TrimmedString), + binaryPath: Schema.optionalKey(TrimmedString), + httpReferer: Schema.optionalKey(TrimmedString), + appTitle: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -522,6 +602,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + openrouter: Schema.optionalKey(OpenRouterSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),