diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7ed..c97fd413b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { OrchestrationReadModel, ProviderKind, ProviderRuntimeEvent } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..8c1079f5c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -12,6 +12,7 @@ export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -21,6 +22,27 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + claudeCodeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCodeUseBedrock: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + claudeCodeAwsRegion: Schema.String.check(Schema.isMaxLength(64)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCodeAwsProfile: Schema.String.check(Schema.isMaxLength(256)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCodeBedrockArnHaiku: Schema.String.check(Schema.isMaxLength(2048)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCodeBedrockArnSonnet: Schema.String.check(Schema.isMaxLength(2048)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCodeBedrockArnOpus: Schema.String.check(Schema.isMaxLength(2048)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..d62f92a25 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -121,5 +121,6 @@ export function getCustomModelOptionsByProvider(settings: { }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", []), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..767e19d71 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -58,6 +58,8 @@ import { hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, + isAvailableProviderOption, + resolveProviderOptions, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -134,7 +136,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; +import { ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; @@ -548,22 +550,25 @@ export default function ChatView({ threadId }: ChatViewProps) { ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const claudeCodeConfigured = + settings.claudeCodeBinaryPath.trim() !== "" || settings.claudeCodeUseBedrock; const searchableModelOptions = useMemo( () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], + resolveProviderOptions(claudeCodeConfigured) + .filter(isAvailableProviderOption) + .filter((option) => lockedProvider === null || option.value === lockedProvider) + .flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [claudeCodeConfigured, lockedProvider, modelOptionsByProvider], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 9bc034991..6d8b62c8b 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,7 +1,12 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { memo, useState } from "react"; -import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; +import { + type ProviderPickerKind, + isAvailableProviderOption, + resolveProviderOptions, +} from "../../session-logic"; +import { useAppSettings } from "../../appSettings"; import { ChevronDownIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -20,14 +25,6 @@ import { import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available && option.value !== "claudeCode"; -} - function resolveModelForProviderPicker( provider: ProviderKind, value: string, @@ -67,8 +64,7 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { cursor: CursorIcon, }; -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); +// Derived at runtime from settings — see ProviderModelPicker component below. const COMING_SOON_PROVIDER_OPTIONS = [ { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, { id: "gemini", label: "Gemini", icon: Gemini }, @@ -84,6 +80,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); + const { settings } = useAppSettings(); + const claudeCodeConfigured = + settings.claudeCodeBinaryPath.trim() !== "" || settings.claudeCodeUseBedrock; + const providerOptions = resolveProviderOptions(claudeCodeConfigured); + const availableProviderOptions = providerOptions.filter(isAvailableProviderOption); + const unavailableProviderOptions = providerOptions.filter((option) => !option.available); const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; @@ -122,7 +124,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + {availableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const isDisabledByProviderLock = props.lockedProvider !== null && props.lockedProvider !== option.value; @@ -168,8 +170,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + {unavailableProviderOptions.length > 0 && } + {unavailableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( @@ -182,12 +184,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> {option.label} - Coming soon + {option.value === "claudeCode" ? "Configure in Settings" : "Coming soon"} ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {unavailableProviderOptions.length === 0 && } {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return ( diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..60cde6ba7 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -102,6 +102,7 @@ function SettingsRouteView() { Record >({ codex: "", + claudeCode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -369,6 +370,162 @@ function SettingsRouteView() { +
+
+

Claude Code

+

+ Configure Claude Code for use with AWS Bedrock. Once enabled, Claude Code will + appear as an available provider in the chat model picker. +

+
+ +
+ + +
+
+

Use AWS Bedrock

+

+ Route Claude Code requests through your AWS account via{" "} + CLAUDE_CODE_USE_BEDROCK. Requires AWS credentials and Bedrock + model access. +

+
+ + updateSettings({ claudeCodeUseBedrock: Boolean(checked) }) + } + aria-label="Use AWS Bedrock for Claude Code" + /> +
+ + {settings.claudeCodeUseBedrock && ( +
+ + + + +
+

+ Model ARN overrides{" "} + (optional) +

+

+ Override the default Bedrock model IDs with specific inference profile ARNs. + Leave blank to use the default model IDs. +

+
+ + {( + [ + { + id: "claude-code-bedrock-arn-haiku", + label: "Haiku ARN", + settingKey: "claudeCodeBedrockArnHaiku", + placeholder: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-...", + }, + { + id: "claude-code-bedrock-arn-sonnet", + label: "Sonnet ARN", + settingKey: "claudeCodeBedrockArnSonnet", + placeholder: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-...", + }, + { + id: "claude-code-bedrock-arn-opus", + label: "Opus ARN", + settingKey: "claudeCodeBedrockArnOpus", + placeholder: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-...", + }, + ] as const + ).map((field) => ( + + ))} +
+ )} + +
+ +
+
+
+

Models

diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..7a093968d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -18,7 +18,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; +export type ProviderPickerKind = ProviderKind | "cursor"; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -30,6 +30,24 @@ export const PROVIDER_OPTIONS: Array<{ { value: "cursor", label: "Cursor", available: false }, ]; +export function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { + value: ProviderKind; + label: string; + available: true; +} { + return option.available; +} + +export function resolveProviderOptions( + claudeCodeConfigured: boolean, +): typeof PROVIDER_OPTIONS { + return PROVIDER_OPTIONS.map((option) => + option.value === "claudeCode" + ? { ...option, available: claudeCodeConfigured } + : option, + ); +} + export interface WorkLogEntry { id: string; createdAt: string; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..9fd50bffa 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -28,6 +28,11 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + claudeCode: [ + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +41,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + claudeCode: "claude-sonnet-4-6", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +52,19 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + claudeCode: { + sonnet: "claude-sonnet-4-6", + opus: "claude-opus-4-6", + haiku: "claude-haiku-4-5-20251001", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + claudeCode: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + claudeCode: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c5eb21d..cbe1167fb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -47,8 +47,17 @@ const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), homePath: Schema.optional(TrimmedNonEmptyString), }); +const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + awsRegion: Schema.optional(TrimmedNonEmptyString), + awsProfile: Schema.optional(TrimmedNonEmptyString), + bedrockModelOverrideHaiku: Schema.optional(TrimmedNonEmptyString), + bedrockModelOverrideSonnet: Schema.optional(TrimmedNonEmptyString), + bedrockModelOverrideOpus: Schema.optional(TrimmedNonEmptyString), +}); const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), }); export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9d2a198b6..9c3688099 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -51,9 +51,18 @@ const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + awsRegion: Schema.optional(TrimmedNonEmptyStringSchema), + awsProfile: Schema.optional(TrimmedNonEmptyStringSchema), + bedrockModelOverrideHaiku: Schema.optional(TrimmedNonEmptyStringSchema), + bedrockModelOverrideSonnet: Schema.optional(TrimmedNonEmptyStringSchema), + bedrockModelOverrideOpus: Schema.optional(TrimmedNonEmptyStringSchema), +}); export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..ec86e7601 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,7 @@ type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") {