diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 892fb06e79e..fb97bd2e79a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,17 @@ permissions: contents: read id-token: none +# T3 Turbo: the fork's product mainline is `turbo`; `main` only tracks upstream. +# Scheduled and dispatched releases resolve from the default branch, so every +# published fork installer was built from upstream code with none of the fork's +# work in it (chat panes, OpenRouter, relay changes) and carried upstream's +# version instead of the fork's. Non-tag releases here build and finalize from +# `turbo`. Tag pushes still build exactly the pushed tag, and the repository +# condition keeps upstream's behavior unchanged. +env: + TURBO_RELEASE_BRANCH: ${{ github.repository == 'gfsaaser24/t3code' && 'turbo' || 'main' }} + TURBO_RELEASE_REF: ${{ (github.event_name != 'push' && github.repository == 'gfsaaser24/t3code') && 'turbo' || github.sha }} + jobs: check_changes: name: Check for changes since last nightly @@ -37,6 +48,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: + ref: ${{ env.TURBO_RELEASE_REF }} fetch-depth: 0 sparse-checkout: | /* @@ -57,10 +69,10 @@ jobs: head_sha=$(git rev-parse HEAD) if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." + echo "No changes on ${TURBO_RELEASE_REF} since last nightly release ($last_nightly_tag). Skipping." echo "has_changes=false" >> "$GITHUB_OUTPUT" else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." + echo "Changes detected on ${TURBO_RELEASE_REF} since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." echo "has_changes=true" >> "$GITHUB_OUTPUT" fi @@ -82,18 +94,25 @@ jobs: cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} - ref: ${{ github.sha }} + ref: ${{ steps.release_ref.outputs.sha }} connect_enabled: ${{ steps.connect_config.outputs.enabled }} steps: - name: Checkout uses: actions/checkout@v6 with: + ref: ${{ env.TURBO_RELEASE_REF }} fetch-depth: 0 sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false + # Every build/release job checks out this exact commit, so the whole run + # is pinned to one tree even if `turbo` moves mid-release. + - id: release_ref + name: Resolve release commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -964,7 +983,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: main + ref: ${{ env.TURBO_RELEASE_BRANCH }} fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} persist-credentials: true @@ -1023,7 +1042,7 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main + git push origin "HEAD:${TURBO_RELEASE_BRANCH}" announce_discord: name: Announce release on Discord diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 9452d5277a1..770f430f519 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -1290,6 +1290,74 @@ ] } ] + }, + { + "id": "openrouter-first-party", + "status": "implemented", + "summary": "First-class OpenRouter provider, adapted from upstream PR pingdotgg/t3code#4125 (closed upstream in favor of the docs recipe and pending orchestrator v2). Rides the Claude Agent CLI as its runtime. Designed to survive v2: the transport core (env ownership, model catalog, settings bridge) lives in provider/openrouter and touches no V1 adapter contract; the Claude adapter is decorated (withOpenRouterAdapterIdentity), never modified. Only the V1 ProviderDriver shim (Drivers/OpenRouterDriver.ts) retires at the v2 cutover, to be rewritten as a small instance flavor feeding the same env into ClaudeAdapterV2, which already imports the identical env plumbing.", + "checks": [ + { + "path": "apps/server/src/provider/openrouter/OpenRouterRuntime.ts", + "markers": [ + "OPENROUTER_OWNED_ENV_KEYS", + "buildOpenRouterProcessEnv", + "withOpenRouterAdapterIdentity" + ] + }, + { + "path": "apps/server/src/provider/openrouter/OpenRouterModels.ts", + "markers": ["fetchOpenRouterModels", "FALLBACK_OPENROUTER_MODELS"] + }, + { + "path": "apps/server/src/provider/Drivers/OpenRouterDriver.ts", + "markers": [ + "withOpenRouterAdapterIdentity(", + "buildOpenRouterProcessEnv(effectiveConfig, baseEnv)" + ] + }, + { + "path": "apps/server/src/provider/Layers/OpenRouterProvider.ts", + "markers": ["checkOpenRouterProviderStatus", "makePendingOpenRouterProvider"] + }, + { + "path": "apps/server/src/provider/builtInDrivers.ts", + "markers": ["OpenRouterDriver"] + }, + { + "path": "packages/contracts/src/settings.ts", + "markers": ["export const OpenRouterSettings"] + }, + { + "path": "packages/contracts/src/model.ts", + "markers": ["OPENROUTER_DRIVER_KIND"] + }, + { + "path": "apps/web/src/components/settings/providerDriverMeta.ts", + "markers": ["label: \"OpenRouter\""] + }, + { + "path": "apps/web/src/session-logic.ts", + "markers": ["label: \"OpenRouter\""] + } + ] + }, + { + "id": "release-from-turbo-branch", + "status": "policy", + "summary": "Scheduled and dispatched releases build, tag, and finalize from the fork's turbo mainline instead of the main branch that only tracks upstream, so published installers carry the fork's code and version.", + "checks": [ + { + "path": ".github/workflows/release.yml", + "markers": [ + "TURBO_RELEASE_BRANCH: ${{ github.repository == 'gfsaaser24/t3code' && 'turbo' || 'main' }}", + "TURBO_RELEASE_REF: ${{ (github.event_name != 'push' && github.repository == 'gfsaaser24/t3code') && 'turbo' || github.sha }}", + "ref: ${{ env.TURBO_RELEASE_REF }}", + "ref: ${{ steps.release_ref.outputs.sha }}", + "ref: ${{ env.TURBO_RELEASE_BRANCH }}", + "git push origin \"HEAD:${TURBO_RELEASE_BRANCH}\"" + ] + } + ] } ] } diff --git a/SEAM.md b/SEAM.md index 1bf45fbbcdc..abd138ec6cc 100644 --- a/SEAM.md +++ b/SEAM.md @@ -386,6 +386,57 @@ Still carried (upstream has no equivalent yet, see pingdotgg/t3code#5575): On a nightly-sync conflict in decider.ts, prefer upstream wholesale if upstream lands a sticky un-settle; otherwise reapply only the pin behavior above. +## OpenRouter first-party provider (fork feature) + +Adapted from upstream PR pingdotgg/t3code#4125 (closed upstream; archived at +`archive/upstream-pr-4125-openrouter-provider`). OpenRouter rides the Claude Agent CLI as its +runtime and ships as a built-in driver with live model-catalog fetching. + +Built to survive orchestrator v2 (pingdotgg/t3code#2829): + +- **Additive, v2-safe** `apps/server/src/provider/openrouter/` — env ownership + (`buildOpenRouterProcessEnv` clears and re-stamps every Anthropic/OpenRouter credential key), + base-URL normalization, model catalog fetch with fallbacks, the Claude-settings bridge, and + the `withOpenRouterAdapterIdentity` decorator. No V1 adapter imports besides the shape type. +- **Additive, v2-safe** `Layers/OpenRouterProvider.ts` — status snapshot (CLI probe + API-key + validation via the catalog). +- **Additive, v2-safe** contracts — `OpenRouterSettings` in `packages/contracts/src/settings.ts` + and the driver-kind default/display maps in `packages/contracts/src/model.ts`. +- **Additive, v2-safe** web wiring (six files) — `components/Icons.tsx` (OpenRouterIcon), + `components/chat/providerIconUtils.ts`, `components/settings/providerDriverMeta.ts`, + `session-logic.ts` (picker option), `composerDraftStore.ts` (provider option keys), and + `lib/contextWindow.ts` (display name). +- **Additive, V1-shim (retires at v2 cutover)** `Drivers/OpenRouterDriver.ts` — the + `ProviderDriver` registration. v2's `ClaudeAdapterV2` already imports the same + `makeClaudeEnvironment`/`mergeProviderInstanceEnvironment` plumbing and accepts per-instance + env, so the rewrite is a small instance flavor feeding `buildOpenRouterProcessEnv` into it. +- Deliberately NOT modified: `ClaudeAdapter.ts`. The upstream PR parameterized its provider + constant across ~50 sites; the fork instead decorates the finished adapter to re-stamp the + driver identity on events and sessions, keeping the churn-heavy file merge-clean. + +On a nightly-sync conflict: everything here is additive except `builtInDrivers.ts`, +`settings.ts`/`model.ts` map entries, and the six web wiring files above — re-add the fork lines +after upstream's. If upstream ships its own OpenRouter or the ACP registry (#6071) covers it, +prefer upstream and retire the shim first. + +## Releases build from `turbo` (fork policy) + +Upstream's `release.yml` resolves scheduled and dispatched releases from whatever ref the run was +started on, which on this fork is the default branch `main`. `main` only tracks upstream, so every +installer the fork published was upstream code at upstream's version — none of the fork's work +(chat panes, OpenRouter, relay changes) ever reached a published release, and the fork's own +version line never advanced there. + +- `TURBO_RELEASE_REF` pins non-tag runs to `turbo`; tag pushes still build the pushed tag. +- `preflight` resolves that ref to a commit sha (`steps.release_ref`) and every build, release, and + deploy job checks out that one sha, so a mid-run push to `turbo` cannot split the release. +- `TURBO_RELEASE_BRANCH` sends the finalize job's version-bump commit to `turbo`, not `main`. +- Both are guarded by `github.repository == 'gfsaaser24/t3code'`, so upstream behavior is unchanged + and the file stays merge-clean. + +On a nightly-sync conflict: keep upstream's job graph and re-add the two `env` entries plus the +five `ref:`/push lines. If upstream ever gains its own release-branch input, prefer it. + ## Nightly sync conflicts Resolve against the new upstream file first, then reapply only the behavior above; never take the diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts new file mode 100644 index 00000000000..9e2c3dd4869 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -0,0 +1,179 @@ +/** + * 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 * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { + checkOpenRouterProviderStatus, + makePendingOpenRouterProvider, +} from "../Layers/OpenRouterProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { + buildOpenRouterProcessEnv, + OPENROUTER_DRIVER_KIND, + toClaudeSettings, + withOpenRouterAdapterIdentity, +} 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 = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: OPENROUTER_DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenRouterDriver: ProviderDriver = { + driverKind: OPENROUTER_DRIVER_KIND, + metadata: { + displayName: "OpenRouter", + supportsMultipleInstances: true, + }, + configSchema: OpenRouterSettings, + defaultConfig: (): OpenRouterSettings => decodeOpenRouterSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const baseEnv = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies OpenRouterSettings; + // Build OpenRouter-owned process env once; pass through to adapter + probes. + const processEnv = buildOpenRouterProcessEnv(effectiveConfig, baseEnv); + const claudeSettings = toClaudeSettings(effectiveConfig); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: OPENROUTER_DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = withOpenRouterAdapterIdentity( + yield* makeClaudeAdapter(claudeSettings, { + instanceId, + environment: processEnv, + ...(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/OpenRouterProvider.test.ts b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts new file mode 100644 index 00000000000..9d8d591d86a --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts @@ -0,0 +1,182 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { OpenRouterSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { + checkOpenRouterProviderStatus, + makePendingOpenRouterProvider, +} from "./OpenRouterProvider.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); + +const makeModelsHttpClient = (status: number, body: unknown) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), + ); + +const EmptyModelsHttpClientLive = makeModelsHttpClient(200, { data: [] }); +const ValidModelsHttpClientLive = makeModelsHttpClient(200, { + data: [{ id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet 4.5" }], +}); +const UnauthorizedModelsHttpClientLive = makeModelsHttpClient(401, { error: "unauthorized" }); + +describe("makePendingOpenRouterProvider", () => { + it.effect("builds a disabled snapshot with fallback models", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider( + decodeOpenRouterSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.displayName).toBe("OpenRouter"); + expect(snapshot.models.some((model) => model.slug === "anthropic/claude-sonnet-4.5")).toBe( + true, + ); + }), + ); + + it.effect("builds a pending snapshot for a configured OpenRouter", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider( + decodeOpenRouterSettings({ apiKey: "sk-or-test" }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toMatch(/checking openrouter/i); + }), + ); + + it.effect("asks for a key instead of pending when none is configured", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider(decodeOpenRouterSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/Add an OpenRouter API key/i); + }), + ); +}); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(EmptyModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (missing binary)", + (it) => { + it.effect("reports a missing Claude runtime binary without throwing", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + binaryPath: "/definitely/not/installed/claude-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(ValidModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (auth independent of CLI)", + (it) => { + it.effect("reports authenticated when CLI is missing but API key is valid", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + binaryPath: "/definitely/not/installed/claude-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.some((model) => model.slug === "anthropic/claude-sonnet-4.5")).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, + }), + ); + + // No key means no usable provider, so the CLI probe is skipped + // entirely and `installed` stays false — startup pays nothing for + // an OpenRouter nobody configured. + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toMatch(/Add an OpenRouter API key/i); + }), + ); + }, +); diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.ts b/apps/server/src/provider/Layers/OpenRouterProvider.ts new file mode 100644 index 00000000000..1a033296629 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.ts @@ -0,0 +1,292 @@ +/** + * OpenRouterProvider — snapshot/status checks for the OpenRouter driver. + * + * OpenRouter rides the Claude Agent CLI as its runtime, so readiness is the + * conjunction of two probes: the `claude` binary answers `--version` with the + * OpenRouter-owned environment applied, and the OpenRouter API accepts the + * configured key (verified by fetching the model catalog). Either failing + * degrades the snapshot rather than erroring the driver. + * + * Turbo seam: v2-safe by design — everything here consumes only the + * `provider/openrouter` transport module and generic snapshot helpers, none + * of the V1 adapter contract. + * + * @module provider/Layers/OpenRouterProvider + */ +import type { OpenRouterSettings, ServerProviderModel } from "@t3tools/contracts"; +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 Result from "effect/Result"; +import { HttpClient } 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 { + EMPTY_OPENROUTER_CAPABILITIES, + FALLBACK_OPENROUTER_MODELS, + fetchOpenRouterModels, +} from "../openrouter/OpenRouterModels.ts"; +import { + buildOpenRouterProcessEnv, + normalizeOpenRouterBaseUrl, +} from "../openrouter/OpenRouterRuntime.ts"; + +const OPENROUTER_PRESENTATION = { + displayName: "OpenRouter", + showInteractionModeToggle: true, +} as const; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function modelsFromSettings( + builtIn: ReadonlyArray, + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings(builtIn, customModels, EMPTY_OPENROUTER_CAPABILITIES); +} + +interface CliProbeFields { + readonly installed: boolean; + readonly version: string | null; + readonly cliOk: boolean; + readonly cliMessage: string; +} + +/** + * Probe the Claude Agent CLI with the OpenRouter environment applied. The + * probe result never fails the effect; failures fold into snapshot fields. + */ +const probeClaudeCliForOpenRouter = Effect.fn("probeClaudeCliForOpenRouter")(function* ( + settings: OpenRouterSettings, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return { + const run = Effect.gen(function* () { + 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); + }); + + const versionProbe = yield* run.pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + if (isCommandMissingCause(error)) { + 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.", + }; + } + return { + installed: true, + version: null, + cliOk: false, + cliMessage: "Failed to execute Claude Agent CLI health check for OpenRouter.", + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + installed: true, + version: null, + cliOk: false, + cliMessage: "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 { + installed: true, + version: parsedVersion, + cliOk: false, + cliMessage: "Claude Agent CLI is installed but failed to run for OpenRouter.", + }; + } + + return { + installed: true, + version: parsedVersion, + cliOk: true, + cliMessage: "", + }; +}); + +/** Full status check: CLI runtime probe plus API-key-validating model fetch. */ +export const checkOpenRouterProviderStatus = Effect.fn("checkOpenRouterProviderStatus")(function* ( + settings: OpenRouterSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient +> { + const checkedAt = yield* nowIso; + const fallbackModels = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter is disabled in T3 Turbo settings.", + }, + }); + } + + // OpenRouter ships enabled so it appears in settings, but it cannot do + // anything without a key. Short-circuit before the CLI probe so the common + // "never configured OpenRouter" install pays no startup spawn for it. + if (settings.apiKey.trim().length === 0) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: "Add an OpenRouter API key in provider settings.", + }, + }); + } + + const processEnv = environment ?? buildOpenRouterProcessEnv(settings); + const cliFields = yield* probeClaudeCliForOpenRouter(settings, processEnv); + const modelFetch = yield* fetchOpenRouterModels(settings); + + const authOk = modelFetch.ok; + const auth = authOk + ? { status: "authenticated" as const } + : { + status: modelFetch.authFailed ? ("unauthenticated" as const) : ("unknown" as const), + }; + + const models = authOk + ? modelsFromSettings(modelFetch.models, settings.customModels) + : fallbackModels; + + if (cliFields.cliOk && authOk) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: cliFields.version, + status: "ready", + auth, + message: `Using ${normalizeOpenRouterBaseUrl(settings.baseUrl)} via the Claude Code runtime.`, + }, + }); + } + + const messages: Array = []; + if (!cliFields.cliOk) { + messages.push(cliFields.cliMessage); + } + if (!authOk) { + messages.push(modelFetch.message); + } + + // A missing runtime or a rejected key blocks sessions outright; a catalog + // fetch failing for network reasons only degrades the model list. + const status = + !cliFields.cliOk || (modelFetch.ok === false && modelFetch.authFailed) ? "error" : "warning"; + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: cliFields.installed, + version: cliFields.version, + status, + auth, + message: messages.join(" "), + }, + }); +}); + +/** Instant pre-probe snapshot so the instance renders before the first check. */ +export const makePendingOpenRouterProvider = ( + settings: OpenRouterSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter is disabled in T3 Turbo settings.", + }, + }); + } + + if (settings.apiKey.trim().length === 0) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: "Add an OpenRouter API key in provider settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking OpenRouter…", + }, + }); + }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9c88495bf33..2b2d3c197f4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1752,6 +1752,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "openrouter", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..1b40c4187b9 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { OpenRouterDriver, type OpenRouterDriverEnv } from "./Drivers/OpenRouterDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | OpenRouterDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ + { + slug: DEFAULT_OPENROUTER_MODEL, + name: "Claude Sonnet 4.5", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, + { + slug: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, + { + slug: "openai/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, +]; + +const OpenRouterModelsResponse = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + }), + ), +}); + +const decodeOpenRouterModelsResponse = Schema.decodeUnknownEffect(OpenRouterModelsResponse); +const MAX_DISCOVERED_MODELS = 200; + +export type OpenRouterModelFetchResult = + | { + readonly ok: true; + readonly models: ReadonlyArray; + } + | { + readonly ok: false; + readonly authFailed: boolean; + readonly message: string; + }; + +export const fetchOpenRouterModels = Effect.fn("fetchOpenRouterModels")(function* ( + settings: OpenRouterSettings, +): Effect.fn.Return { + const apiKey = settings.apiKey.trim(); + if (apiKey.length === 0) { + return { + ok: false, + authFailed: true, + message: "Add an OpenRouter API key in provider settings.", + }; + } + + const httpClient = yield* HttpClient.HttpClient; + const url = openRouterModelsUrl(settings.baseUrl); + + // Timeout covers both headers and body consumption so a stalling response + // body cannot hang the provider status refresh indefinitely. + const fetchResult = yield* Effect.gen(function* () { + const httpResponse = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.acceptJson, + httpClient.execute, + ); + + if (httpResponse.status === 401 || httpResponse.status === 403) { + return { + ok: false as const, + authFailed: true, + message: "OpenRouter API key is missing or invalid.", + } satisfies OpenRouterModelFetchResult; + } + + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return { + ok: false as const, + authFailed: false, + message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, + } satisfies OpenRouterModelFetchResult; + } + + const body = yield* httpResponse.json.pipe(Effect.result); + if (Result.isFailure(body)) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter models API returned an unreadable response.", + } satisfies OpenRouterModelFetchResult; + } + + const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); + if (Result.isFailure(decoded)) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter models API returned an unexpected payload.", + } satisfies OpenRouterModelFetchResult; + } + + // Trim ids before they become slugs (the contract is TrimmedNonEmptyString) + // and dedupe — OpenRouter has repeated ids across routing variants. + const seenSlugs = new Set(); + const catalog: Array = []; + for (const model of decoded.success.data) { + const slug = model.id.trim(); + if (slug.length === 0 || seenSlugs.has(slug)) { + continue; + } + seenSlugs.add(slug); + catalog.push({ + slug, + name: model.name?.trim() || slug, + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }); + } + // Truncate for the picker, but never truncate away the configured + // default: DEFAULT_MODEL_BY_PROVIDER points at it regardless of where + // the API ordered it. + const models = catalog.slice(0, MAX_DISCOVERED_MODELS); + if (!models.some((model) => model.slug === DEFAULT_OPENROUTER_MODEL)) { + const defaultEntry = + catalog.find((model) => model.slug === DEFAULT_OPENROUTER_MODEL) ?? + FALLBACK_OPENROUTER_MODELS.find((model) => model.slug === DEFAULT_OPENROUTER_MODEL); + if (defaultEntry) { + models.unshift(defaultEntry); + } + } + + if (models.length === 0) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter returned an empty model catalog.", + } satisfies OpenRouterModelFetchResult; + } + + return { ok: true as const, models } satisfies OpenRouterModelFetchResult; + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(fetchResult)) { + return { + ok: false, + authFailed: false, + message: "Failed to reach OpenRouter models API.", + }; + } + + if (Option.isNone(fetchResult.success)) { + return { + ok: false, + authFailed: false, + message: "Timed out while fetching OpenRouter models.", + }; + } + + return fetchResult.success.value; +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts new file mode 100644 index 00000000000..efb76df1b0e --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { OpenRouterSettings, ProviderDriverKind } from "@t3tools/contracts"; + +import { + buildOpenRouterProcessEnv, + normalizeOpenRouterBaseUrl, + openRouterModelsUrl, + toClaudeSettings, + OPENROUTER_DRIVER_KIND, + withOpenRouterAdapterIdentity, +} from "./OpenRouterRuntime.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); + +describe("OpenRouterRuntime", () => { + it("normalizes base URL trailing slashes", () => { + expect(normalizeOpenRouterBaseUrl("https://openrouter.ai/api/")).toBe( + "https://openrouter.ai/api", + ); + expect(normalizeOpenRouterBaseUrl("")).toBe("https://openrouter.ai/api"); + }); + + it("builds the tools-capable models URL", () => { + expect(openRouterModelsUrl("https://openrouter.ai/api")).toBe( + "https://openrouter.ai/api/v1/models?supported_parameters=tools", + ); + }); + + it("maps settings into Claude settings + OpenRouter-owned process env", () => { + const settings = decodeOpenRouterSettings({ + apiKey: "sk-or-test", + baseUrl: "https://openrouter.ai/api/", + binaryPath: "claude", + httpReferer: "https://t3.chat", + appTitle: "T3 Code", + }); + + expect(toClaudeSettings(settings)).toMatchObject({ + enabled: true, + binaryPath: "claude", + homePath: "", + launchArgs: "", + }); + + const env = buildOpenRouterProcessEnv(settings, { + PATH: "/usr/bin", + ANTHROPIC_API_KEY: "host-anthropic-key", + ANTHROPIC_AUTH_TOKEN: "host-token", + OPENROUTER_API_KEY: "host-openrouter", + OR_SITE_URL: "https://leaked.example", + OR_APP_NAME: "Leaked", + }); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + // OpenRouter Claude Code contract: auth token + empty API key. + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-or-test"); + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.OPENROUTER_API_KEY).toBe("sk-or-test"); + expect(env.HTTP_REFERER).toBe("https://t3.chat"); + expect(env.X_TITLE).toBe("T3 Code"); + // Claude Code only forwards headers through ANTHROPIC_CUSTOM_HEADERS. + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("HTTP-Referer: https://t3.chat\nX-Title: 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", + ANTHROPIC_CUSTOM_HEADERS: "X-Host: leaked", + 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_CUSTOM_HEADERS).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + }); +}); + +describe("withOpenRouterAdapterIdentity", () => { + it.effect("restamps the adapter identity, events, and sessions without touching behavior", () => + Effect.gen(function* () { + const claudeKind = ProviderDriverKind.make("claudeAgent"); + const session = { provider: claudeKind, threadId: "thread-1" }; + const event = { provider: claudeKind, type: "session.started" }; + const base = { + provider: claudeKind, + streamEvents: Stream.make(event), + startSession: () => Effect.succeed(session), + listSessions: () => Effect.succeed([session]), + stopSession: () => Effect.void, + } as unknown as ProviderAdapterShape; + + const decorated = withOpenRouterAdapterIdentity(base); + + expect(decorated.provider).toBe(OPENROUTER_DRIVER_KIND); + // Untouched members pass through by reference. + expect(decorated.stopSession).toBe(base.stopSession); + + const events = [ + ...((yield* Stream.runCollect(decorated.streamEvents)) as Iterable<{ + provider: string; + }>), + ]; + expect(events.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); + + const started = yield* decorated.startSession({} as never) as Effect.Effect<{ + provider: string; + }>; + expect(started.provider).toBe(OPENROUTER_DRIVER_KIND); + + const listed = (yield* decorated.listSessions()) as ReadonlyArray<{ provider: string }>; + expect(listed.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); + }), + ); +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts new file mode 100644 index 00000000000..30ffba715e6 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -0,0 +1,155 @@ +import { + ClaudeSettings, + type OpenRouterSettings, + ProviderDriverKind, + type ProviderSession, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +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.5"; + +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", + "ANTHROPIC_CUSTOM_HEADERS", + "OPENROUTER_API_KEY", + "HTTP_REFERER", + "X_TITLE", + // Legacy aliases that must not leak from the host into OpenRouter sessions. + "OR_SITE_URL", + "OR_APP_NAME", +] as const; + +export function normalizeOpenRouterBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + const normalized = (trimmed.length > 0 ? trimmed : DEFAULT_OPENROUTER_BASE_URL).replace( + /\/+$/, + "", + ); + return normalized; +} + +export function openRouterModelsUrl(baseUrl: string): string { + return `${normalizeOpenRouterBaseUrl(baseUrl)}/v1/models?supported_parameters=tools`; +} + +/** + * Narrow Claude runtime config needed by `makeClaudeAdapter` / text generation. + * OpenRouter does not expose Claude homePath / launchArgs in its settings. + */ +export function toClaudeSettings(settings: OpenRouterSettings): ClaudeSettings { + return decodeClaudeSettings({ + enabled: settings.enabled, + binaryPath: settings.binaryPath, + homePath: "", + customModels: settings.customModels, + launchArgs: "", + }); +} + +/** + * Build the process env for OpenRouter-backed Claude Code sessions. + * + * Matches OpenRouter's Claude Code contract: + * - `ANTHROPIC_BASE_URL` → OpenRouter Anthropic skin (`https://openrouter.ai/api`) + * - `ANTHROPIC_AUTH_TOKEN` → OpenRouter API key + * - `ANTHROPIC_API_KEY` → always `""` so Claude Code does not prefer a host Anthropic key + * + * Owned credential/attribution keys are always cleared first so host values cannot leak + * when settings omit them. + */ +export function buildOpenRouterProcessEnv( + settings: OpenRouterSettings, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...baseEnv }; + for (const key of OPENROUTER_OWNED_ENV_KEYS) { + delete next[key]; + } + + const baseUrl = normalizeOpenRouterBaseUrl(settings.baseUrl); + const apiKey = settings.apiKey.trim(); + + next.ANTHROPIC_BASE_URL = baseUrl; + // Critical: empty string (not unset) so Claude Code does not fall back to Anthropic auth. + next.ANTHROPIC_API_KEY = ""; + + if (apiKey.length > 0) { + next.ANTHROPIC_AUTH_TOKEN = apiKey; + next.OPENROUTER_API_KEY = apiKey; + } else { + next.ANTHROPIC_AUTH_TOKEN = ""; + next.OPENROUTER_API_KEY = ""; + } + + const httpReferer = settings.httpReferer.trim(); + if (httpReferer.length > 0) { + next.HTTP_REFERER = httpReferer; + } + + const appTitle = settings.appTitle.trim(); + if (appTitle.length > 0) { + next.X_TITLE = appTitle; + } + + // Claude Code does not read HTTP_REFERER/X_TITLE — it only forwards extra + // request headers through ANTHROPIC_CUSTOM_HEADERS (newline-separated + // "Name: value" pairs). The plain vars above stay for other OpenRouter + // tooling that does read them. + const customHeaders: Array = []; + if (httpReferer.length > 0) { + customHeaders.push(`HTTP-Referer: ${httpReferer}`); + } + if (appTitle.length > 0) { + customHeaders.push(`X-Title: ${appTitle}`); + } + if (customHeaders.length > 0) { + next.ANTHROPIC_CUSTOM_HEADERS = customHeaders.join("\n"); + } + + return next; +} + +/** + * Re-stamp a Claude-runtime adapter with the OpenRouter driver identity. + * + * The Claude adapter hardcodes provider "claudeAgent" on its sessions and + * runtime events. Rather than parameterizing that heavily-churned module (a + * merge-conflict magnet), OpenRouter decorates the finished adapter: the + * identity field and every outbound event/session carry "openrouter", while + * behavior passes through untouched. v2 note: OrchestratorV2 threads + * instance identity through its own registry, so this decorator retires with + * the V1 adapter contract. + */ +export function withOpenRouterAdapterIdentity( + adapter: ProviderAdapterShape, +): ProviderAdapterShape { + const restampSession = (session: ProviderSession): ProviderSession => ({ + ...session, + provider: OPENROUTER_DRIVER_KIND, + }); + return { + ...adapter, + provider: OPENROUTER_DRIVER_KIND, + streamEvents: Stream.map(adapter.streamEvents, (event) => ({ + ...event, + provider: OPENROUTER_DRIVER_KIND, + })), + startSession: (input) => Effect.map(adapter.startSession(input), restampSession), + listSessions: () => + Effect.map(adapter.listSessions(), (sessions) => sessions.map(restampSession)), + }; +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..159073980de 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -696,3 +696,26 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); + +export const OpenRouterIcon: Icon = ({ className, ...props }) => ( + + + + + + +); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1f..92dfcdd6790 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/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..ea114da69a2 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -4,10 +4,19 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, + OpenRouterSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, + OpenRouterIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("openrouter"), + label: "OpenRouter", + icon: OpenRouterIcon, + settingsSchema: OpenRouterSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09e..98eef0e900f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -812,7 +812,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of ["codex", "claudeAgent", "cursor", "openrouter", "opencode"] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -971,7 +971,7 @@ 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", "openrouter", "opencode"] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2773,7 +2773,13 @@ 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", + "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..4ce9f7f63aa 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -34,6 +34,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Claude"; case "codex": return "Codex"; + case "openrouter": + return "OpenRouter"; case "cursor": return "Cursor"; case "opencode": diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1c5815baac..4e07a1e91f7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,6 +35,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, + { value: ProviderDriverKind.make("openrouter"), label: "OpenRouter", available: true }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd..bdc56f2de8f 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OPENROUTER_DRIVER_KIND = ProviderDriverKind.make("openrouter"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OPENROUTER_DRIVER_KIND]: "OpenRouter", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9e73b1fdc02..466f35f3100 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -500,6 +500,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 Turbo")), + Schema.annotateKey({ + title: "App title", + description: "Optional app name sent as X-Title for OpenRouter rankings.", + providerSettingsForm: { + placeholder: "T3 Turbo", + 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( @@ -679,6 +748,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 @@ -775,6 +845,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), @@ -822,6 +902,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), }), ), diff --git a/scripts/turbo-customization-manifest.test.ts b/scripts/turbo-customization-manifest.test.ts index 06e74274a4d..f80162f7c7d 100644 --- a/scripts/turbo-customization-manifest.test.ts +++ b/scripts/turbo-customization-manifest.test.ts @@ -152,12 +152,14 @@ it("verifies the checked-in Turbo manifest and tracks the implemented multi-chat "multi-chat-pane-workspace", "nightly-and-secret-policy", "official-data-import", + "openrouter-first-party", "pooled-subscription-frame", "product-identity-and-updater", "relay-apns-off-publish-skip", "relay-auth-and-link-memos", "relay-policy", "relay-request-budget-and-clerk-client", + "release-from-turbo-branch", "settled-lifecycle-sticky-pin", "shared-sha256-base64url", "sqlite-fast-mode-pragma",