From 2def4a32b9a89846c9bf47a654f15a5f9883aef9 Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:59:58 -0500 Subject: [PATCH 1/7] feat(providers): add first-class OpenRouter provider Register openrouter as a built-in driver with OpenRouterSettings, models API probe, and Claude Agent runtime owned by the driver. --- .../src/provider/Drivers/OpenRouterDriver.ts | 173 ++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 144 ++++--- .../src/provider/Layers/OpenRouterAdapter.ts | 33 ++ .../Layers/OpenRouterProvider.test.ts | 75 ++++ .../src/provider/Layers/OpenRouterProvider.ts | 372 ++++++++++++++++++ .../ProviderInstanceRegistryLive.test.ts | 56 ++- .../provider/Layers/ProviderRegistry.test.ts | 7 + .../provider/Services/OpenRouterAdapter.ts | 16 + apps/server/src/provider/builtInDrivers.ts | 3 + .../openrouter/OpenRouterRuntime.test.ts | 52 +++ .../provider/openrouter/OpenRouterRuntime.ts | 60 +++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/src/components/Icons.tsx | 23 ++ .../src/components/chat/providerIconUtils.ts | 11 +- .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/providerDriverMeta.ts | 17 +- apps/web/src/composerDraftStore.ts | 27 +- apps/web/src/lib/contextWindow.ts | 4 + apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 4 + packages/contracts/src/settings.test.ts | 7 + packages/contracts/src/settings.ts | 81 ++++ 22 files changed, 1105 insertions(+), 75 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OpenRouterDriver.ts create mode 100644 apps/server/src/provider/Layers/OpenRouterAdapter.ts create mode 100644 apps/server/src/provider/Layers/OpenRouterProvider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenRouterProvider.ts create mode 100644 apps/server/src/provider/Services/OpenRouterAdapter.ts create mode 100644 apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts create mode 100644 apps/server/src/provider/openrouter/OpenRouterRuntime.ts diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts new file mode 100644 index 00000000000..6acc7cd9049 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -0,0 +1,173 @@ +/** + * 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 { makeOpenRouterAdapter } from "../Layers/OpenRouterAdapter.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; + 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* makeOpenRouterAdapter(effectiveConfig, { + instanceId, + environment: baseEnv, + ...(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.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..4b198bc25ad 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -91,7 +91,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 +214,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; @@ -941,8 +947,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 +965,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 +977,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 +987,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 +1198,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 +1220,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,6 +1353,7 @@ 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; @@ -1374,7 +1389,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, @@ -1407,7 +1422,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 +1445,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 +1555,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 +1586,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 +1678,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 +1709,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 +1732,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) } : {}), @@ -1750,7 +1765,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( 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 } : {}), @@ -1822,7 +1837,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 +1872,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) } : {}), @@ -1963,7 +1978,7 @@ 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: { @@ -1986,7 +2001,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* offerRuntimeEvent({ type: "item.completed", eventId: toolStamp.eventId, - provider: PROVIDER, + provider: providerKind, createdAt: toolStamp.createdAt, threadId: context.session.threadId, turnId: turnState.turnId, @@ -2037,7 +2052,7 @@ 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, @@ -2126,7 +2141,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 +2204,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 +2241,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 +2303,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 +2381,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 +2409,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 +2433,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 +2503,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 +2587,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 +2792,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 +2906,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 +2918,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 +2975,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 +3005,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 +3034,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 +3055,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (!context) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, + provider: providerKind, threadId, }), ); @@ -3048,7 +3063,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 +3073,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 +3177,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 +3224,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 +3327,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 +3375,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) } : {}), @@ -3481,7 +3496,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 +3528,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 +3537,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 +3583,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 +3594,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 +3613,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 +3676,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 +3693,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 +3729,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 +3742,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 +3766,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 +3794,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 +3812,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 +3867,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/OpenRouterAdapter.ts b/apps/server/src/provider/Layers/OpenRouterAdapter.ts new file mode 100644 index 00000000000..b30cd9a0c60 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterAdapter.ts @@ -0,0 +1,33 @@ +import type { OpenRouterSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { + buildOpenRouterProcessEnv, + OPENROUTER_DRIVER_KIND, + toClaudeSettings, +} from "../openrouter/OpenRouterRuntime.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +export type OpenRouterAdapterLiveOptions = { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +}; + +export const makeOpenRouterAdapter = Effect.fn("makeOpenRouterAdapter")(function* ( + settings: OpenRouterSettings, + options?: OpenRouterAdapterLiveOptions, +) { + const processEnv = buildOpenRouterProcessEnv(settings, options?.environment); + const claudeSettings = toClaudeSettings(settings); + const claudeOptions: ClaudeAdapterLiveOptions = { + ...(options?.instanceId ? { instanceId: options.instanceId } : {}), + environment: processEnv, + provider: OPENROUTER_DRIVER_KIND, + ...(options?.nativeEventLogPath ? { nativeEventLogPath: options.nativeEventLogPath } : {}), + ...(options?.nativeEventLogger ? { nativeEventLogger: options.nativeEventLogger } : {}), + }; + return yield* makeClaudeAdapter(claudeSettings, claudeOptions); +}); 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..ebc285b2ec6 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts @@ -0,0 +1,75 @@ +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 EmptyModelsHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), +); + +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", + (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/); + }), + ); + }, +); diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.ts b/apps/server/src/provider/Layers/OpenRouterProvider.ts new file mode 100644 index 00000000000..55626710507 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.ts @@ -0,0 +1,372 @@ +import { + type ModelCapabilities, + type OpenRouterSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +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 * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + DEFAULT_OPENROUTER_MODEL, + OPENROUTER_DRIVER_KIND, + normalizeOpenRouterBaseUrl, + openRouterModelsUrl, +} from "../openrouter/OpenRouterRuntime.ts"; + +const OPENROUTER_PRESENTATION = { + displayName: "OpenRouter", + showInteractionModeToggle: true, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const FALLBACK_MODELS: ReadonlyArray = [ + { + slug: DEFAULT_OPENROUTER_MODEL, + name: "Claude Sonnet 4", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "anthropic/claude-opus-4", + name: "Claude Opus 4", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "openai/gpt-4o", + name: "GPT-4o", + isCustom: false, + capabilities: EMPTY_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; +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function modelsFromSettings( + builtIn: ReadonlyArray, + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings( + builtIn, + OPENROUTER_DRIVER_KIND, + customModels, + EMPTY_CAPABILITIES, + ); +} + +const runClaudeVersionProbe = Effect.fn("runOpenRouterClaudeVersionProbe")(function* ( + settings: OpenRouterSettings, + environment: NodeJS.ProcessEnv, +) { + const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], { + env: environment, + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }); + return yield* spawnAndCollect(settings.binaryPath, command); +}); + +type OpenRouterModelFetchResult = + | { + readonly ok: true; + readonly models: ReadonlyArray; + } + | { + readonly ok: false; + readonly authFailed: boolean; + readonly message: string; + }; + +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); + const response = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.acceptJson, + httpClient.execute, + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(response)) { + return { + ok: false, + authFailed: false, + message: "Failed to reach OpenRouter models API.", + }; + } + + if (Option.isNone(response.success)) { + return { + ok: false, + authFailed: false, + message: "Timed out while fetching OpenRouter models.", + }; + } + + const httpResponse = response.success.value; + if (httpResponse.status === 401 || httpResponse.status === 403) { + return { + ok: false, + authFailed: true, + message: "OpenRouter API key is missing or invalid.", + }; + } + + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return { + ok: false, + authFailed: false, + message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, + }; + } + + const body = yield* httpResponse.json.pipe(Effect.result); + if (Result.isFailure(body)) { + return { + ok: false, + authFailed: false, + message: "OpenRouter models API returned an unreadable response.", + }; + } + + const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); + if (Result.isFailure(decoded)) { + return { + ok: false, + authFailed: false, + message: "OpenRouter models API returned an unexpected payload.", + }; + } + + 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_CAPABILITIES, + }), + ); + + if (models.length === 0) { + return { + ok: false, + authFailed: false, + message: "OpenRouter returned an empty model catalog.", + }; + } + + return { ok: true, models }; +}); + +export const makePendingOpenRouterProvider = ( + settings: OpenRouterSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = modelsFromSettings(FALLBACK_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 | Path.Path | HttpClient.HttpClient +> { + const resolvedEnvironment = environment ?? process.env; + const checkedAt = yield* nowIso; + const fallbackModels = modelsFromSettings(FALLBACK_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.", + }, + }); + } + + const versionProbe = yield* runClaudeVersionProbe(settings, resolvedEnvironment).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { + status: settings.apiKey.trim().length > 0 ? "unknown" : "unauthenticated", + }, + message: isCommandMissingCause(error) + ? "Claude Agent CLI (`claude`) is not installed or not on PATH. OpenRouter uses Claude Code as its agent runtime." + : "Failed to execute Claude Agent CLI health check for OpenRouter.", + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Claude Agent CLI timed out while running `--version` for OpenRouter.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: "Claude Agent CLI is installed but failed to run for OpenRouter.", + }, + }); + } + + const modelFetch = yield* fetchOpenRouterModels(settings); + if (!modelFetch.ok) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: parsedVersion, + status: modelFetch.authFailed ? "error" : "warning", + auth: { + status: modelFetch.authFailed ? "unauthenticated" : "unknown", + ...(modelFetch.authFailed ? { label: "OpenRouter API key" } : {}), + }, + message: modelFetch.message, + }, + }); + } + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: modelsFromSettings(modelFetch.models, settings.customModels), + probe: { + installed: true, + version: parsedVersion, + status: "ready", + auth: { + status: "authenticated", + type: "api_key", + label: "OpenRouter API key", + }, + message: `Using ${normalizeOpenRouterBaseUrl(settings.baseUrl)} via Claude Code runtime.`, + }, + }); +}); 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/Services/OpenRouterAdapter.ts b/apps/server/src/provider/Services/OpenRouterAdapter.ts new file mode 100644 index 00000000000..12552000445 --- /dev/null +++ b/apps/server/src/provider/Services/OpenRouterAdapter.ts @@ -0,0 +1,16 @@ +/** + * OpenRouterAdapter — shape type for the OpenRouter provider adapter. + * + * The driver model ({@link ../Drivers/OpenRouterDriver}) bundles one adapter + * per instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module OpenRouterAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OpenRouterAdapterShape — per-instance OpenRouter adapter contract. + */ +export interface OpenRouterAdapterShape extends ProviderAdapterShape {} 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 { + 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 + process env owned by the driver", () => { + 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" }); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + expect(env.ANTHROPIC_API_KEY).toBe("sk-or-test"); + 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.PATH).toBe("/usr/bin"); + }); +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts new file mode 100644 index 00000000000..c151e97ed33 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -0,0 +1,60 @@ +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); + +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`; +} + +export function toClaudeSettings(settings: OpenRouterSettings): ClaudeSettings { + return decodeClaudeSettings({ + enabled: settings.enabled, + binaryPath: settings.binaryPath, + homePath: "", + customModels: settings.customModels, + launchArgs: "", + }); +} + +export function buildOpenRouterProcessEnv( + settings: OpenRouterSettings, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...baseEnv }; + const baseUrl = normalizeOpenRouterBaseUrl(settings.baseUrl); + const apiKey = settings.apiKey.trim(); + + next.ANTHROPIC_BASE_URL = baseUrl; + if (apiKey.length > 0) { + next.ANTHROPIC_API_KEY = apiKey; + next.OPENROUTER_API_KEY = apiKey; + } + + const httpReferer = settings.httpReferer.trim(); + if (httpReferer.length > 0) { + next.HTTP_REFERER = httpReferer; + next.OR_SITE_URL = httpReferer; + } + + const appTitle = settings.appTitle.trim(); + if (appTitle.length > 0) { + next.X_TITLE = appTitle; + next.OR_APP_NAME = appTitle; + } + + return next; +} 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..be3baf5d2dc 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -761,7 +761,14 @@ 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 [ + "codex", + "claudeAgent", + "cursor", + "grok", + "openrouter", + "opencode", + ] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -920,7 +927,14 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "openrouter", + "opencode", + ] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2654,7 +2668,14 @@ 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 [ + "codex", + "claudeAgent", + "cursor", + "grok", + "openrouter", + "opencode", + ] as const) { 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..516a93fb0f3 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -36,6 +36,10 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Codex"; case "cursor": return "Cursor"; + case "grok": + return "Grok"; + case "openrouter": + return "OpenRouter"; case "opencode": return "OpenCode"; default: { 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), }), ), From 00ec85bc99410100f2992625f2cf9f8e20337f9c Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:07:35 -0500 Subject: [PATCH 2/7] refactor(providers): address OpenRouter review findings Delete thin adapter wrappers, own Anthropic-compat env per OpenRouter's Claude Code contract, probe CLI health and auth in parallel, and share the CLI version helper with Claude. --- .../src/provider/Drivers/OpenRouterDriver.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.ts | 4 +- .../src/provider/Layers/ClaudeProvider.ts | 58 +-- .../src/provider/Layers/OpenRouterAdapter.ts | 33 -- .../Layers/OpenRouterProvider.test.ts | 119 +++++- .../src/provider/Layers/OpenRouterProvider.ts | 390 ++++++------------ .../provider/Services/OpenRouterAdapter.ts | 16 - .../provider/openrouter/OpenRouterModels.ts | 158 +++++++ .../openrouter/OpenRouterRuntime.test.ts | 39 +- .../provider/openrouter/OpenRouterRuntime.ts | 45 +- apps/server/src/provider/providerSnapshot.ts | 54 +++ apps/web/src/composerDraftStore.ts | 37 +- apps/web/src/lib/contextWindow.ts | 38 +- 13 files changed, 578 insertions(+), 421 deletions(-) delete mode 100644 apps/server/src/provider/Layers/OpenRouterAdapter.ts delete mode 100644 apps/server/src/provider/Services/OpenRouterAdapter.ts create mode 100644 apps/server/src/provider/openrouter/OpenRouterModels.ts diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts index 6acc7cd9049..f0162ca0c1e 100644 --- a/apps/server/src/provider/Drivers/OpenRouterDriver.ts +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -21,7 +21,7 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { makeOpenRouterAdapter } from "../Layers/OpenRouterAdapter.ts"; +import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { checkOpenRouterProviderStatus, makePendingOpenRouterProvider, @@ -103,6 +103,7 @@ export const OpenRouterDriver: ProviderDriver 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, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b..716e3ce3fa6 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,11 +29,8 @@ import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, - DEFAULT_TIMEOUT_MS, - isCommandMissingCause, - parseGenericCliVersion, + probeCliVersion, providerModelsFromSettings, - spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; @@ -637,22 +633,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 +669,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 +682,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 +711,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 +722,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 +730,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/OpenRouterAdapter.ts b/apps/server/src/provider/Layers/OpenRouterAdapter.ts deleted file mode 100644 index b30cd9a0c60..00000000000 --- a/apps/server/src/provider/Layers/OpenRouterAdapter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { OpenRouterSettings, ProviderInstanceId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; - -import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; -import { - buildOpenRouterProcessEnv, - OPENROUTER_DRIVER_KIND, - toClaudeSettings, -} from "../openrouter/OpenRouterRuntime.ts"; -import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -export type OpenRouterAdapterLiveOptions = { - readonly instanceId?: ProviderInstanceId; - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; -}; - -export const makeOpenRouterAdapter = Effect.fn("makeOpenRouterAdapter")(function* ( - settings: OpenRouterSettings, - options?: OpenRouterAdapterLiveOptions, -) { - const processEnv = buildOpenRouterProcessEnv(settings, options?.environment); - const claudeSettings = toClaudeSettings(settings); - const claudeOptions: ClaudeAdapterLiveOptions = { - ...(options?.instanceId ? { instanceId: options.instanceId } : {}), - environment: processEnv, - provider: OPENROUTER_DRIVER_KIND, - ...(options?.nativeEventLogPath ? { nativeEventLogPath: options.nativeEventLogPath } : {}), - ...(options?.nativeEventLogger ? { nativeEventLogger: options.nativeEventLogger } : {}), - }; - return yield* makeClaudeAdapter(claudeSettings, claudeOptions); -}); diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.test.ts b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts index ebc285b2ec6..56ff2252949 100644 --- a/apps/server/src/provider/Layers/OpenRouterProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts @@ -13,20 +13,27 @@ import { const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); -const EmptyModelsHttpClientLive = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify({ data: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), +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", () => @@ -54,7 +61,7 @@ describe("makePendingOpenRouterProvider", () => { }); it.layer(NodeServices.layer.pipe(Layer.provideMerge(EmptyModelsHttpClientLive)))( - "checkOpenRouterProviderStatus", + "checkOpenRouterProviderStatus (missing binary)", (it) => { it.effect("reports a missing Claude runtime binary without throwing", () => Effect.gen(function* () { @@ -73,3 +80,89 @@ it.layer(NodeServices.layer.pipe(Layer.provideMerge(EmptyModelsHttpClientLive))) ); }, ); + +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 index 55626710507..e449cf8bd22 100644 --- a/apps/server/src/provider/Layers/OpenRouterProvider.ts +++ b/apps/server/src/provider/Layers/OpenRouterProvider.ts @@ -1,33 +1,25 @@ -import { - type ModelCapabilities, - type OpenRouterSettings, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import type { OpenRouterSettings, ServerProviderModel } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; 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 * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, - DEFAULT_TIMEOUT_MS, - isCommandMissingCause, - parseGenericCliVersion, + probeCliVersion, providerModelsFromSettings, - spawnAndCollect, + type CliVersionProbeResult, type ServerProviderDraft, } from "../providerSnapshot.ts"; import { - DEFAULT_OPENROUTER_MODEL, + FALLBACK_OPENROUTER_MODELS, + EMPTY_OPENROUTER_CAPABILITIES, + fetchOpenRouterModels, + type OpenRouterModelFetchResult, +} from "../openrouter/OpenRouterModels.ts"; +import { OPENROUTER_DRIVER_KIND, normalizeOpenRouterBaseUrl, - openRouterModelsUrl, } from "../openrouter/OpenRouterRuntime.ts"; const OPENROUTER_PRESENTATION = { @@ -35,42 +27,6 @@ const OPENROUTER_PRESENTATION = { showInteractionModeToggle: true, } as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -const FALLBACK_MODELS: ReadonlyArray = [ - { - slug: DEFAULT_OPENROUTER_MODEL, - name: "Claude Sonnet 4", - isCustom: false, - capabilities: EMPTY_CAPABILITIES, - }, - { - slug: "anthropic/claude-opus-4", - name: "Claude Opus 4", - isCustom: false, - capabilities: EMPTY_CAPABILITIES, - }, - { - slug: "openai/gpt-4o", - name: "GPT-4o", - isCustom: false, - capabilities: EMPTY_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; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function modelsFromSettings( @@ -81,137 +37,133 @@ function modelsFromSettings( builtIn, OPENROUTER_DRIVER_KIND, customModels, - EMPTY_CAPABILITIES, + EMPTY_OPENROUTER_CAPABILITIES, ); } -const runClaudeVersionProbe = Effect.fn("runOpenRouterClaudeVersionProbe")(function* ( - settings: OpenRouterSettings, - environment: NodeJS.ProcessEnv, -) { - const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], { - env: environment, - }); - const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: environment, - shell: spawnCommand.shell, - }); - return yield* spawnAndCollect(settings.binaryPath, command); -}); - -type OpenRouterModelFetchResult = - | { - readonly ok: true; - readonly models: ReadonlyArray; +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; } - | { - readonly ok: false; - readonly authFailed: boolean; - readonly message: string; - }; - -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); - const response = yield* HttpClientRequest.get(url).pipe( - HttpClientRequest.bearerToken(apiKey), - HttpClientRequest.acceptJson, - httpClient.execute, - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(response)) { - return { - ok: false, - authFailed: false, - message: "Failed to reach OpenRouter models API.", - }; } +} - if (Option.isNone(response.success)) { - return { - ok: false, - authFailed: false, - message: "Timed out while fetching OpenRouter models.", - }; - } +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 httpResponse = response.success.value; - if (httpResponse.status === 401 || httpResponse.status === 403) { - return { - ok: false, - authFailed: true, - message: "OpenRouter API key is missing or invalid.", - }; - } + const models = authOk + ? modelsFromSettings(modelFetch.models, settings.customModels) + : fallbackModels; - if (httpResponse.status < 200 || httpResponse.status >= 300) { - return { - ok: false, - authFailed: false, - message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, - }; + 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 body = yield* httpResponse.json.pipe(Effect.result); - if (Result.isFailure(body)) { - return { - ok: false, - authFailed: false, - message: "OpenRouter models API returned an unreadable response.", - }; + const messages: string[] = []; + if (!cliFields.cliOk) { + messages.push(cliFields.cliMessage); } - - const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); - if (Result.isFailure(decoded)) { - return { - ok: false, - authFailed: false, - message: "OpenRouter models API returned an unexpected payload.", - }; + if (!authOk) { + messages.push(modelFetch.message); } - 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_CAPABILITIES, - }), - ); + const status = + !cliFields.cliOk || (modelFetch.ok === false && modelFetch.authFailed) ? "error" : "warning"; - if (models.length === 0) { - return { - ok: false, - authFailed: false, - message: "OpenRouter returned an empty model catalog.", - }; - } - - return { ok: true, models }; -}); + 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_MODELS, settings.customModels); + const models = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); if (!settings.enabled) { return buildServerProvider({ @@ -250,11 +202,11 @@ export const checkOpenRouterProviderStatus = Effect.fn("checkOpenRouterProviderS ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Path.Path | HttpClient.HttpClient + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient > { const resolvedEnvironment = environment ?? process.env; const checkedAt = yield* nowIso; - const fallbackModels = modelsFromSettings(FALLBACK_MODELS, settings.customModels); + const fallbackModels = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); if (!settings.enabled) { return buildServerProvider({ @@ -272,101 +224,17 @@ export const checkOpenRouterProviderStatus = Effect.fn("checkOpenRouterProviderS }); } - const versionProbe = yield* runClaudeVersionProbe(settings, resolvedEnvironment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, + // 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 }, ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return buildServerProvider({ - presentation: OPENROUTER_PRESENTATION, - enabled: settings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { - status: settings.apiKey.trim().length > 0 ? "unknown" : "unauthenticated", - }, - message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH. OpenRouter uses Claude Code as its agent runtime." - : "Failed to execute Claude Agent CLI health check for OpenRouter.", - }, - }); - } - - if (Option.isNone(versionProbe.success)) { - return buildServerProvider({ - presentation: OPENROUTER_PRESENTATION, - enabled: settings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Claude Agent CLI timed out while running `--version` for OpenRouter.", - }, - }); - } - - const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - return buildServerProvider({ - presentation: OPENROUTER_PRESENTATION, - enabled: settings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: parsedVersion, - status: "error", - auth: { status: "unknown" }, - message: "Claude Agent CLI is installed but failed to run for OpenRouter.", - }, - }); - } - - const modelFetch = yield* fetchOpenRouterModels(settings); - if (!modelFetch.ok) { - return buildServerProvider({ - presentation: OPENROUTER_PRESENTATION, - enabled: settings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: parsedVersion, - status: modelFetch.authFailed ? "error" : "warning", - auth: { - status: modelFetch.authFailed ? "unauthenticated" : "unknown", - ...(modelFetch.authFailed ? { label: "OpenRouter API key" } : {}), - }, - message: modelFetch.message, - }, - }); - } - - return buildServerProvider({ - presentation: OPENROUTER_PRESENTATION, - enabled: settings.enabled, + return mergeOpenRouterProbe({ + settings, checkedAt, - models: modelsFromSettings(modelFetch.models, settings.customModels), - probe: { - installed: true, - version: parsedVersion, - status: "ready", - auth: { - status: "authenticated", - type: "api_key", - label: "OpenRouter API key", - }, - message: `Using ${normalizeOpenRouterBaseUrl(settings.baseUrl)} via Claude Code runtime.`, - }, + fallbackModels, + cli, + modelFetch, }); }); diff --git a/apps/server/src/provider/Services/OpenRouterAdapter.ts b/apps/server/src/provider/Services/OpenRouterAdapter.ts deleted file mode 100644 index 12552000445..00000000000 --- a/apps/server/src/provider/Services/OpenRouterAdapter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * OpenRouterAdapter — shape type for the OpenRouter provider adapter. - * - * The driver model ({@link ../Drivers/OpenRouterDriver}) bundles one adapter - * per instance as a captured closure, so this module only retains the shape - * interface as a naming anchor for the driver bundle. - * - * @module OpenRouterAdapter - */ -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -/** - * OpenRouterAdapterShape — per-instance OpenRouter adapter contract. - */ -export interface OpenRouterAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/openrouter/OpenRouterModels.ts b/apps/server/src/provider/openrouter/OpenRouterModels.ts new file mode 100644 index 00000000000..2da58324603 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterModels.ts @@ -0,0 +1,158 @@ +import { + type ModelCapabilities, + type OpenRouterSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { DEFAULT_TIMEOUT_MS } from "../providerSnapshot.ts"; +import { DEFAULT_OPENROUTER_MODEL, openRouterModelsUrl } from "./OpenRouterRuntime.ts"; + +export const EMPTY_OPENROUTER_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +export const FALLBACK_OPENROUTER_MODELS: 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); + const response = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.acceptJson, + httpClient.execute, + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(response)) { + return { + ok: false, + authFailed: false, + message: "Failed to reach OpenRouter models API.", + }; + } + + if (Option.isNone(response.success)) { + return { + ok: false, + authFailed: false, + message: "Timed out while fetching OpenRouter models.", + }; + } + + const httpResponse = response.success.value; + if (httpResponse.status === 401 || httpResponse.status === 403) { + return { + ok: false, + authFailed: true, + message: "OpenRouter API key is missing or invalid.", + }; + } + + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return { + ok: false, + authFailed: false, + message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, + }; + } + + const body = yield* httpResponse.json.pipe(Effect.result); + if (Result.isFailure(body)) { + return { + ok: false, + authFailed: false, + message: "OpenRouter models API returned an unreadable response.", + }; + } + + const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); + if (Result.isFailure(decoded)) { + return { + ok: false, + authFailed: false, + message: "OpenRouter models API returned an unexpected payload.", + }; + } + + 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, + authFailed: false, + message: "OpenRouter returned an empty model catalog.", + }; + } + + return { ok: true, models }; +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts index 7e3921cfa19..53ec23b78fa 100644 --- a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts @@ -25,7 +25,7 @@ describe("OpenRouterRuntime", () => { ); }); - it("maps settings into Claude settings + process env owned by the driver", () => { + it("maps settings into Claude settings + OpenRouter-owned process env", () => { const settings = decodeOpenRouterSettings({ apiKey: "sk-or-test", baseUrl: "https://openrouter.ai/api/", @@ -41,12 +41,45 @@ describe("OpenRouterRuntime", () => { launchArgs: "", }); - const env = buildOpenRouterProcessEnv(settings, { PATH: "/usr/bin" }); + 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"); - expect(env.ANTHROPIC_API_KEY).toBe("sk-or-test"); + // 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 index c151e97ed33..8bf81ff9298 100644 --- a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -7,6 +7,22 @@ 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( @@ -20,6 +36,10 @@ 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, @@ -30,30 +50,49 @@ export function toClaudeSettings(settings: OpenRouterSettings): ClaudeSettings { }); } +/** + * 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_API_KEY = apiKey; + 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; - next.OR_SITE_URL = httpReferer; } const appTitle = settings.appTitle.trim(); if (appTitle.length > 0) { next.X_TITLE = appTitle; - next.OR_APP_NAME = 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/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index be3baf5d2dc..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,14 +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", - "grok", - "openrouter", - "opencode", - ] as const) { + for (const providerKey of COMPOSER_PROVIDER_OPTION_KEYS) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -927,14 +930,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of [ - "codex", - "claudeAgent", - "cursor", - "grok", - "openrouter", - "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); @@ -2668,14 +2664,7 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of [ - "codex", - "claudeAgent", - "cursor", - "grok", - "openrouter", - "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 516a93fb0f3..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,27 +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 "grok": - return "Grok"; - case "openrouter": - return "OpenRouter"; - 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( From 51b50dac76afbc844cbb011a8f60f03006c1af47 Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:14:58 -0500 Subject: [PATCH 3/7] fix(providers): timeout OpenRouter models body parse (#4125) Cover httpResponse.json under the same timeout as the HTTP execute so a stalling models response cannot hang provider status refresh. --- .../provider/openrouter/OpenRouterModels.ts | 130 +++++++++--------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/apps/server/src/provider/openrouter/OpenRouterModels.ts b/apps/server/src/provider/openrouter/OpenRouterModels.ts index 2da58324603..438026f6c3e 100644 --- a/apps/server/src/provider/openrouter/OpenRouterModels.ts +++ b/apps/server/src/provider/openrouter/OpenRouterModels.ts @@ -75,84 +75,88 @@ export const fetchOpenRouterModels = Effect.fn("fetchOpenRouterModels")(function const httpClient = yield* HttpClient.HttpClient; const url = openRouterModelsUrl(settings.baseUrl); - const response = yield* HttpClientRequest.get(url).pipe( - HttpClientRequest.bearerToken(apiKey), - HttpClientRequest.acceptJson, - httpClient.execute, - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(response)) { - return { - ok: false, - authFailed: false, - message: "Failed to reach OpenRouter models API.", - }; - } - if (Option.isNone(response.success)) { - return { - ok: false, - authFailed: false, - message: "Timed out while fetching OpenRouter models.", - }; - } + // 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, + ); - const httpResponse = response.success.value; - if (httpResponse.status === 401 || httpResponse.status === 403) { - return { - ok: false, - authFailed: true, - message: "OpenRouter API key is missing or invalid.", - }; - } + 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, - authFailed: false, - message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, - }; - } + 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, - authFailed: false, - message: "OpenRouter models API returned an unreadable response.", - }; - } + 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; + } - const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); - if (Result.isFailure(decoded)) { + 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: "OpenRouter models API returned an unexpected payload.", + message: "Failed to reach OpenRouter models API.", }; } - 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) { + if (Option.isNone(fetchResult.success)) { return { ok: false, authFailed: false, - message: "OpenRouter returned an empty model catalog.", + message: "Timed out while fetching OpenRouter models.", }; } - return { ok: true, models }; + return fetchResult.success.value; }); From 57e4c59ce21f2dce57ab3961ee5e4129fd40df5f Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:36:19 -0500 Subject: [PATCH 4/7] fix(providers): resolve Claude Code npm shim for Agent SDK OpenRouter (and Claude) pass binaryPath to pathToClaudeCodeExecutable. On Windows, npm i -g @anthropic-ai/claude-code puts a .cmd/shell shim on PATH; the Agent SDK needs the native claude.exe under node_modules/@anthropic-ai/claude-code/bin. Without unwrapping, sessions fail with ProviderAdapterSessionNotFoundError wrapping "Claude Code native binary not found at claude". Resolve the shim once in makeClaudeAdapter / capability probes via resolveClaudeCodeExecutable. --- .../src/provider/Drivers/ClaudeHome.test.ts | 146 ++++++++++++------ .../server/src/provider/Drivers/ClaudeHome.ts | 55 +++++++ .../src/provider/Layers/ClaudeAdapter.ts | 20 ++- .../src/provider/Layers/ClaudeProvider.ts | 8 +- 4 files changed, 174 insertions(+), 55 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index a7ff41fc89e..779a0a42de1 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -1,7 +1,10 @@ +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 +12,101 @@ 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("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..c6c490bb2a8 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,11 +1,66 @@ 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.catchTag("CommandResolutionError", () => Effect.succeed(configured))); + + // npm global prefix layout: /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/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3ab4318ff1e..6bfe19b7f39 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -70,7 +70,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, @@ -1359,9 +1359,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( 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 @@ -3422,8 +3432,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); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 716e3ce3fa6..d1383497c74 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -33,7 +33,7 @@ import { providerModelsFromSettings, 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: [], @@ -584,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. @@ -594,7 +598,7 @@ const probeClaudeCapabilities = ( })(), options: { persistSession: false, - pathToClaudeCodeExecutable: claudeSettings.binaryPath, + pathToClaudeCodeExecutable, abortController: abort, settingSources: ["user", "project", "local"], allowedTools: [], From 25eab54dcd4a076a6d3f62d4439775491e40080c Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:05:03 -0500 Subject: [PATCH 5/7] fix(providers): clear Working before getContextUsage on Claude turns OpenRouter (and Claude) kept session.status running after assistant text finished because completeTurn awaited getContextUsage with no timeout before emitting turn.completed. Emit turn.completed from result/last-known usage first, then refresh context usage inline with a 1s timeout so Working clears promptly. Residual hang remains if the SDK never emits result. --- .../src/provider/Drivers/ClaudeHome.test.ts | 1 + .../server/src/provider/Drivers/ClaudeHome.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 179 +++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 213 +++++++++++------- 4 files changed, 314 insertions(+), 80 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 779a0a42de1..03b19e96d65 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index c6c490bb2a8..edc07378647 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import * as NodePath from "node:path"; 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 6bfe19b7f39..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"; @@ -492,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, @@ -1762,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) { @@ -1772,7 +1873,7 @@ 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", @@ -1780,7 +1881,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: providerKind, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(turnState ? { turnId: turnState.turnId } : {}), + ...(turnId ? { turnId: asCanonicalTurnId(turnId) } : {}), payload: { usage, }, @@ -1920,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) { @@ -1993,21 +2046,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( 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({ @@ -2068,16 +2114,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( 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), }); @@ -2091,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* ( From b5ab02d0e785e8955032688ca9ef5a34416d9341 Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:08:07 -0500 Subject: [PATCH 6/7] fix(providers): only unwrap Claude npm shims, not custom exes Keep an explicit native/custom binaryPath when a sibling npm package layout exists. --- .../src/provider/Drivers/ClaudeHome.test.ts | 28 +++++++++++++++++++ .../server/src/provider/Drivers/ClaudeHome.ts | 26 +++++++++++------ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 03b19e96d65..d5335fe2665 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -66,6 +66,34 @@ nodeServicesIt("ClaudeHome", (it) => { }), ); + 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; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index edc07378647..7e6d412d19f 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -40,14 +40,24 @@ export const resolveClaudeCodeExecutable = Effect.fn("resolveClaudeCodeExecutabl environment ? { env: environment } : {}, ).pipe(Effect.catchTag("CommandResolutionError", () => Effect.succeed(configured))); - // npm global prefix layout: /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; + // 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)); From 8be08452a9ef3d1483e16e58acc1180649d0e4b0 Mon Sep 17 00:00:00 2001 From: Joey Lamping <35737718+jaylamping@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:09:56 -0500 Subject: [PATCH 7/7] style(providers): use catchTags for CommandResolutionError in ClaudeHome --- apps/server/src/provider/Drivers/ClaudeHome.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 7e6d412d19f..34c50b100e1 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -38,7 +38,7 @@ export const resolveClaudeCodeExecutable = Effect.fn("resolveClaudeCodeExecutabl const resolved = yield* resolveCommandPath( configured, environment ? { env: environment } : {}, - ).pipe(Effect.catchTag("CommandResolutionError", () => Effect.succeed(configured))); + ).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.