Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +48,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ env.TURBO_RELEASE_REF }}
fetch-depth: 0
sparse-checkout: |
/*
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions .t3-turbo/customizations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\""
]
}
]
}
]
}
51 changes: 51 additions & 0 deletions SEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions apps/server/src/provider/Drivers/OpenRouterDriver.ts
Original file line number Diff line number Diff line change
@@ -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<OpenRouterSettings, OpenRouterDriverEnv> = {
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<OpenRouterSettings>
>({
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;
}),
};
Loading
Loading