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
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export function ModelRowContent(
<Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
</View>
) : null}
{props.option.isGateway ? (
<View className="rounded-md bg-subtle px-1.5 py-0.5">
<Text className="text-3xs font-t3-bold text-foreground-muted">LiteLLM</Text>
</View>
) : null}
{props.option.isHidden ? (
<Text className="text-xs text-foreground-muted">Hidden</Text>
) : null}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export type ModelOption = {
};
readonly isDefault: boolean;
readonly isLegacy: boolean;
/** Served by a LiteLLM gateway rather than the harness's own catalog. */
readonly isGateway?: boolean;
readonly isUnavailable?: boolean;
readonly isHidden?: boolean;
readonly canHide?: boolean;
Expand Down Expand Up @@ -381,6 +383,7 @@ export function buildModelOptions(
canHide: !model.isCustom,
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
...(model.gateway ? { isGateway: true } : {}),
...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
capabilities: model.capabilities,
selection: normalizeSelectionOptions(
Expand Down Expand Up @@ -444,6 +447,7 @@ export function buildModelOptions(
providerBadge: providerBadgeFor(fallbackModelSelection.instanceId),
isDefault: false,
isLegacy: model?.isLegacy === true,
...(model?.gateway ? { isGateway: true } : {}),
...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) ||
(provider !== undefined && !isProviderEnabledForProject(config, provider, projectId)) ||
provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration-v2/ProviderAuthRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as ThreadManagementService from "./ThreadManagementService.ts";
const RETRY_MESSAGE_PREFIX = "message:auth-retry:";

const AUTH_REJECTION_CODES = new Set(["api_error_401", "api_error_403"]);
// Codex, OpenCode and Pi only pass the gateway's text through. LiteLLM answers an
// Codex and OpenCode only pass the gateway's text through. LiteLLM answers an
// expired or revoked key with "Authentication Error, ..." and HTTP 401.
// A bare 401 or "unauthorized" is too common in tool output to count.
const AUTH_REJECTION_TEXT =
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/builtInDrivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
type LiteLLMLauncherEnv,
} from "@t3tools/provider-core/server/litellm";

import { LITELLM_CLAUDE, LITELLM_CODEX, LITELLM_OPENCODE, LITELLM_PI } from "./liteLLMLaunchers.ts";
import { LITELLM_CLAUDE, LITELLM_CODEX, LITELLM_OPENCODE } from "./liteLLMLaunchers.ts";

export type BuiltInDriversEnv =
| AcpRegistryDriverEnv
Expand All @@ -46,7 +46,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
withLiteLLMLauncher(OpenCodeDriver, LITELLM_OPENCODE),
AntigravityDriver,
KiroDriver,
withLiteLLMLauncher(PiDriver, LITELLM_PI),
PiDriver,
MuseDriver,
AcpRegistryDriver,
];
Expand Down
14 changes: 7 additions & 7 deletions apps/server/src/provider/liteLLMLaunchers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { ClaudeSettings, CodexSettings } from "@t3tools/contracts";
import type { LiteLLMLauncherSpec } from "@t3tools/provider-core/server/litellm";
import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings";
import type { PiSettings } from "@t3tools/provider-pi/settings";
import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
import { codexModelFamily } from "@t3tools/shared/model";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -41,16 +40,17 @@ export const LITELLM_CLAUDE: LiteLLMLauncherSpec<ClaudeSettings> = {
gatewayModelSlug: claudeGatewayModelSlug,
};

const OPENCODE_GATEWAY_PREFIX = "litellm/";

export const LITELLM_OPENCODE: LiteLLMLauncherSpec<OpenCodeSettings> = {
harness: "opencode",
configure: (config, launch) =>
config.serverUrl
? Effect.fail("Clear Server URL: LiteLLM can only route an OpenCode server T3 starts.")
: Effect.succeed({ ...config, binaryPath: launch.binaryPath }),
};

export const LITELLM_PI: LiteLLMLauncherSpec<PiSettings> = {
harness: "pi",
configure: (config, launch) => Effect.succeed({ ...config, binaryPath: launch.binaryPath }),
homeEnvironment: (homeDir) => ({ PI_CODING_AGENT_DIR: homeDir }),
// `lite opencode` adds the gateway as the `litellm` provider; other providers stay direct.
listedModelGatewayId: (slug) =>
slug.startsWith(OPENCODE_GATEWAY_PREFIX)
? slug.slice(OPENCODE_GATEWAY_PREFIX.length)
: undefined,
};
7 changes: 7 additions & 0 deletions apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const ModelListRow = memo(function ModelListRow(props: {
/** Marks this instance apart from another of the same provider. */
showInstanceBadge: boolean;
showNewBadge?: boolean;
/** Served by a LiteLLM gateway rather than the harness's own catalog. */
showGatewayBadge?: boolean;
unavailable?: boolean;
jumpLabel?: string | null;
disabledReason?: string | null;
Expand Down Expand Up @@ -93,6 +95,11 @@ export const ModelListRow = memo(function ModelListRow(props: {
New
</span>
) : null}
{props.showGatewayBadge ? (
<Badge variant="outline" size="sm" aria-label="Served by the LiteLLM gateway">
LiteLLM
</Badge>
) : null}
{props.unavailable ? (
<Badge variant="outline" size="sm">
Unavailable
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type ModelPickerItem = {
acpRegistryIconUrl?: string | undefined;
continuationGroupKey?: string | undefined;
isLegacy?: boolean | undefined;
gateway?: "litellm" | undefined;
isUnavailable?: boolean | undefined;
};

Expand Down Expand Up @@ -567,6 +568,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
...(model.subProvider ? { subProvider: model.subProvider } : {}),
...(model.badge ? { badge: model.badge } : {}),
...(model.isLegacy ? { isLegacy: true } : {}),
...(model.gateway ? { gateway: model.gateway } : {}),
...(model.isUnavailable ? { isUnavailable: true } : {}),
instanceId,
driverKind: entry.driverKind,
Expand Down Expand Up @@ -1056,6 +1058,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
favoriteStarOnHover={selectedInstanceId === "favorites" && !isSearching}
showInstanceBadge={badgedInstanceIds.has(model.instanceId)}
showNewBadge={model.badge === "new"}
showGatewayBadge={model.gateway === "litellm"}
unavailable={model.isUnavailable === true}
jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
disabledReason={disabledReason}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/providerIconUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ModelEsque = {
isDefault?: boolean | undefined;
badge?: "new" | undefined;
isLegacy?: boolean | undefined;
gateway?: "litellm" | undefined;
isUnavailable?: boolean | undefined;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const LITELLM_LAUNCHER_DRIVERS: ReadonlySet<string> = new Set([
"claudeAgent",
"codex",
"opencode",
"pi",
]);

/** The suggested label follows the launcher until the user types their own. */
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/ProviderInstanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1287,7 +1287,7 @@ export function ProviderInstanceCard({
</div>
}
/>
{LITELLM_LAUNCHER_DRIVERS.has(instance.driver) ? (
{LITELLM_LAUNCHER_DRIVERS.has(instance.driver) || instance.launcher === "litellm" ? (
<SettingsRow
title={
<label htmlFor={`provider-instance-${instanceId}-litellm`}>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/ProviderModelsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,9 @@ function ProviderModelRow({
{model.isCustom ? (
<span className="shrink-0 text-2xs text-muted-foreground/70">custom</span>
) : null}
{model.gateway === "litellm" ? (
<span className="shrink-0 text-2xs text-muted-foreground/70">LiteLLM</span>
) : null}
</span>
<span className="text-2xs text-muted-foreground/70">
{capLabels.length > 0 ? (
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface AppModelOption {
isCustom: boolean;
isDefault?: boolean;
isLegacy?: boolean;
gateway?: "litellm";
isUnavailable?: boolean;
}

Expand All @@ -80,7 +81,7 @@ const LITELLM_REQUEST_TAG_DRIVERS: ReadonlySet<string> = new Set([
"opencode",
]);

/** LiteLLM tags reach the gateway through Claude, Codex and OpenCode; Pi has no tag channel. */
/** LiteLLM tags reach the gateway through Claude, Codex and OpenCode. */
export function supportsLiteLLMRequestTags(
settings: Pick<UnifiedSettings, "providerInstances">,
instanceId: ProviderInstanceId,
Expand Down Expand Up @@ -150,6 +151,7 @@ function toAppModelOption(
if (model.badge) option.badge = model.badge;
if (model.isDefault) option.isDefault = true;
if (model.isLegacy) option.isLegacy = true;
if (model.gateway) option.gateway = model.gateway;
return option;
}

Expand Down
20 changes: 11 additions & 9 deletions docs/user/providers-litellm.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# LiteLLM

T3 Code can run Claude, Codex, OpenCode, and Pi through a [LiteLLM](https://docs.litellm.ai)
T3 Code can run Claude, Codex, and OpenCode through a [LiteLLM](https://docs.litellm.ai)
gateway with the LiteLLM CLI (`lite`). Each harness keeps working as usual, but it signs in
with your gateway's SSO and uses the gateway's models. This lets you, for example, use a
GPT model on the Claude harness.
Expand All @@ -17,7 +17,7 @@ GPT model on the Claude harness.
2. Set `LITELLM_PROXY_URL` to your gateway URL in your shell profile, for example
`export LITELLM_PROXY_URL=https://litellm.example.com`. The desktop app reads it from your
login shell. You can also set it in a provider instance's **Environment variables**.
3. In **Settings > Providers**, choose **Add provider**, pick Claude, Codex, OpenCode, or Pi,
3. In **Settings > Providers**, choose **Add provider**, pick Claude, Codex, or OpenCode,
and turn on **Launch through LiteLLM**. The suggested label is, for example,
**LiteLLM Claude**, and that is the name the model picker shows. To route an existing
provider instead, such as the built-in Codex or Claude, open it and turn on
Expand All @@ -42,8 +42,12 @@ full URL and paste it in T3 Code when it asks.
## Models

- **LiteLLM Claude** lists every model the gateway offers.
- **LiteLLM Codex**, **LiteLLM OpenCode**, and **LiteLLM Pi** list the models that `lite`
syncs into those harnesses.
- **LiteLLM Codex** and **LiteLLM OpenCode** list the models that `lite` syncs into those
harnesses.

In the model picker and in **Settings > Providers**, a **LiteLLM** label marks each model the
gateway serves. A model without the label comes from the harness's own list. LiteLLM OpenCode
sends those models to their usual provider, not to the gateway.

T3 Code checks the gateway's model list at most once a minute, and **Refresh** in
**Settings > Providers** checks it now. If the gateway removes the model a thread uses, the
Expand Down Expand Up @@ -71,13 +75,11 @@ commit messages, and other text generation do not carry tags.
- Threads that a thread starts, such as delegated tasks, forks, and threads an agent creates,
copy its tags. A scheduled task keeps the tags you set on it.

LiteLLM Pi does not send request tags yet.

## Limits

- LiteLLM Codex and LiteLLM Pi use their own config directory, so they do not read your
usual Codex or Pi config, skills, or extensions. A Codex provider you switch to LiteLLM
might not resume threads it started before the switch.
- LiteLLM Codex uses its own config directory, so it does not read your usual Codex config or
skills. A Codex provider you switch to LiteLLM might not resume threads it started before
the switch.
- When the selected text generation model is not on the gateway, commit messages, titles,
and branch names use the first gateway model.
- LiteLLM Codex does not accept `-c` or `--config` in **Launch arguments**, because Codex
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export const ServerProviderModel = Schema.Struct({
isCustom: Schema.Boolean,
isDefault: Schema.optional(Schema.Boolean),
isLegacy: Schema.optional(Schema.Boolean),
/** Set when a LiteLLM gateway serves the model, as opposed to the harness's own catalog. */
gateway: Schema.optional(Schema.Literal("litellm")),
capabilities: Schema.NullOr(ModelCapabilities),
});
export type ServerProviderModel = typeof ServerProviderModel.Type;
Expand Down
44 changes: 31 additions & 13 deletions packages/provider-core/src/server/litellm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
liteLLMSignedOutMessage,
parseLiteLLMModelIds,
type LiteLLMHarness,
type LiteLLMStatus,
withLiteLLMLauncher,
} from "./litellm.ts";
import type {
Expand Down Expand Up @@ -92,20 +93,44 @@ describe("applyLiteLLMStatus", () => {
(id) => id.replace(/^bedrock\//, ""),
);
assert.deepEqual(
result.models.map((model) => [model.slug, model.name, model.isCustom]),
result.models.map((model) => [model.slug, model.name, model.isCustom, model.gateway]),
[
["gpt-5", "gpt-5", false],
["claude-sonnet-4-5", "Claude Sonnet 4.5", false],
["bedrock/sonnet-4.5", "bedrock/sonnet-4.5", false],
["claude sonnet 4.5", "claude sonnet 4.5", false],
["my-alias", "My alias", true],
["gpt-5", "gpt-5", false, "litellm"],
["claude-sonnet-4-5", "Claude Sonnet 4.5", false, "litellm"],
["bedrock/sonnet-4.5", "bedrock/sonnet-4.5", false, "litellm"],
["claude sonnet 4.5", "claude sonnet 4.5", false, "litellm"],
["my-alias", "My alias", true, undefined],
],
);
assert.strictEqual(result.models[0]?.capabilities, capabilities);
assert.strictEqual(result.models[2]?.capabilities, sonnetCapabilities);
assert.strictEqual(result.models[3]?.capabilities, sonnetCapabilities);
});

it("marks only the harness-listed models the gateway serves", () => {
const listed = (slug: string) => ({ slug, name: slug, isCustom: false, capabilities: null });
const harnessSnapshot = snapshot({
models: [listed("litellm/gpt-5"), listed("openai/gpt-5"), listed("litellm/retired")],
});
const gatewayId = (slug: string) =>
slug.startsWith("litellm/") ? slug.slice("litellm/".length) : undefined;
const marks = (status: LiteLLMStatus) =>
applyLiteLLMStatus(harnessSnapshot, status, undefined, undefined, gatewayId).models.map(
(model) => [model.slug, model.gateway],
);
assert.deepEqual(marks({ auth: { status: "authenticated" }, models: ["gpt-5"] }), [
["litellm/gpt-5", "litellm"],
["openai/gpt-5", undefined],
["litellm/retired", undefined],
]);
// Without the gateway's list, no model can claim to be served by it.
assert.deepEqual(marks({ auth: { status: "unauthenticated", message: "Signed out." } }), [
["litellm/gpt-5", undefined],
["openai/gpt-5", undefined],
["litellm/retired", undefined],
]);
});

it("reports the gateway sign-in instead of the harness's own account", () => {
const signedOut = applyLiteLLMStatus(
snapshot(),
Expand Down Expand Up @@ -326,7 +351,6 @@ const createLiteLLMInstance = (
harness: options.harness ?? "claude",
configure: (config, launch) =>
Effect.succeed({ ...config, binaryPath: launch.binaryPath, homePath: launch.homeDir }),
homeEnvironment: (homeDir) => ({ FAKE_HOME: homeDir }),
gatewayModelCapabilities: capabilities,
servesModel: (model, requested) => model.slug === `my-${requested}`,
});
Expand Down Expand Up @@ -387,10 +411,6 @@ describe.skipIf(windowsHost)("withLiteLLMLauncher", () => {
shim,
NodePath.join(fixture.dir, "state", "litellm", instanceId, "bin", "claude"),
);
assert.deepEqual(
input.environment.find((variable) => variable.name === "FAKE_HOME")?.value,
input.config.homePath,
);

const run = (args: ReadonlyArray<string>) =>
spawnAndCollect(shim, ChildProcess.make(shim, [...args], { env: fixture.environment }));
Expand Down Expand Up @@ -603,8 +623,6 @@ describe.skipIf(windowsHost)("withLiteLLMLauncher", () => {
"tool:company,project:billing",
]);
assert.deepEqual(yield* tagsWith({ mode: "off", custom: "" }), ["", "project:billing"]);
const pi = yield* createLiteLLMInstance(fixture, [], { harness: "pi" });
assert.isUndefined(pi.orchestrationAdapter.launchTags);
}).pipe(Effect.scoped, Effect.provide(testServices)),
);

Expand Down
Loading
Loading