Skip to content
Open
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
7 changes: 4 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* closures captured over the per-instance `ClaudeSettings`.
*
* Unlike Codex, the Claude snapshot probe may invoke a secondary probe
* (`probeClaudeCapabilities`) to read Anthropic account + slash-command
* metadata. That probe is per-instance and keyed by binary + resolved HOME so
* two concurrent Claude instances don't cross-contaminate account metadata.
* (`probeClaudeCapabilities`) to read Anthropic account, model, and
* slash-command metadata. That probe is per-instance and keyed by binary +
* resolved HOME so two concurrent Claude instances don't cross-contaminate
* account metadata.
*
* @module provider/Drivers/ClaudeDriver
*/
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,32 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("forwards capabilities selected for an SDK-discovered Claude model", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus[1m]", [
{ id: "effort", value: "xhigh" },
{ id: "fastMode", value: true },
]),
runtimeMode: "full-access",
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.model, "opus[1m]");
assert.equal(createInput?.options.effort, "xhigh");
assert.deepEqual(createInput?.options.settings, {
fastMode: true,
});
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
28 changes: 20 additions & 8 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,10 @@ function maxClaudeContextWindowFromModelUsage(
function selectedClaudeContextWindow(
modelSelection: ModelSelection | undefined,
): number | undefined {
if (modelSelection?.model.endsWith("[1m]")) {
return 1_000_000;
}

switch (modelSelection?.model) {
case "claude-opus-4-8":
case "claude-opus-4-7":
Expand Down Expand Up @@ -899,7 +903,9 @@ function buildPromptText(
input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined;
const caps = getClaudeModelCapabilities(claudeModel);

const promptEffort = resolvePromptInjectedEffort(caps, rawEffort);
const promptEffort =
resolvePromptInjectedEffort(caps, rawEffort) ??
(caps.optionDescriptors?.length === 0 && rawEffort === "ultrathink" ? rawEffort : null);
return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
}

Expand Down Expand Up @@ -3492,13 +3498,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
const initialContextWindow = selectedClaudeContextWindow(modelSelection);
const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort");
const effort = resolveClaudeEffort(caps, rawEffort) ?? null;
const fastModeSupported = descriptors.some(
(descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
);
const thinkingSupported = descriptors.some(
(descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking",
);
const hasCatalogCapabilities = descriptors.length > 0;
const effort =
(hasCatalogCapabilities ? resolveClaudeEffort(caps, rawEffort) : rawEffort) ?? null;
const fastModeSupported =
!hasCatalogCapabilities ||
descriptors.some(
(descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
);
const thinkingSupported =
!hasCatalogCapabilities ||
descriptors.some(
(descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking",
);
const fastMode =
getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true &&
fastModeSupported;
Expand Down
41 changes: 40 additions & 1 deletion apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,16 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
" agents: [],",
' output_style: "default",',
' available_output_styles: ["default"],',
" models: [],",
" models: [{",
' value: "opus[1m]",',
' displayName: "Opus (1M context)",',
' description: "Opus 5 with 1M context",',
" supportsEffort: true,",
' supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],',
" supportsAdaptiveThinking: true,",
" supportsFastMode: true,",
" supportsAutoMode: true,",
" }],",
' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },',
" },",
" },",
Expand All @@ -110,6 +119,36 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
subscriptionType: "pro",
tokenSource: "oauth",
apiProvider: undefined,
models: [
{
slug: "opus[1m]",
name: "Opus (1M context)",
isCustom: false,
capabilities: {
optionDescriptors: [
{
id: "effort",
label: "Reasoning",
type: "select",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
{ id: "ultrathink", label: "Ultrathink" },
],
promptInjectedValues: ["ultrathink"],
},
{
id: "fastMode",
label: "Fast Mode",
type: "boolean",
},
],
},
},
],
slashCommands: [
{
name: "review",
Expand Down
140 changes: 116 additions & 24 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
import { compareSemverVersions } from "@t3tools/shared/semver";
import {
query as claudeQuery,
type ModelInfo as ClaudeModelInfo,
type Options as ClaudeQueryOptions,
type SlashCommand as ClaudeSlashCommand,
type SDKUserMessage,
Expand Down Expand Up @@ -56,7 +57,7 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169";
const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154";
const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";

const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
const FALLBACK_CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-fable-5",
name: "Claude Fable 5",
Expand Down Expand Up @@ -325,10 +326,10 @@ function supportsClaudeOpus47(version: string | null | undefined): boolean {
return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
}

function getBuiltInClaudeModelsForVersion(
function getFallbackClaudeModelsForVersion(
version: string | null | undefined,
): ReadonlyArray<ServerProviderModel> {
return BUILT_IN_MODELS.filter((model) => {
return FALLBACK_CLAUDE_MODELS.filter((model) => {
if (model.slug === "claude-opus-5") {
return supportsClaudeOpus5(version);
}
Expand Down Expand Up @@ -368,7 +369,7 @@ function formatClaudeOpus47UpgradeMessage(version: string | null): string {
export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities {
const slug = model?.trim();
return (
BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ??
FALLBACK_CLAUDE_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ??
DEFAULT_CLAUDE_MODEL_CAPABILITIES
);
}
Expand Down Expand Up @@ -408,10 +409,11 @@ export function normalizeClaudeCliEffort(
}
if (
effort === "xhigh" &&
model !== "claude-fable-5" &&
model !== "claude-opus-5" &&
model !== "claude-opus-4-8" &&
model !== "claude-sonnet-5"
(model === "claude-opus-4-7" ||
model === "claude-opus-4-6" ||
model === "claude-opus-4-5" ||
model === "claude-sonnet-4-6" ||
model === "claude-haiku-4-5")
) {
return "max";
}
Expand Down Expand Up @@ -615,9 +617,95 @@ type ClaudeCapabilitiesProbe = {
* the subscription/token fields are absent and auth is external AWS creds.
*/
readonly apiProvider: string | undefined;
readonly models: ReadonlyArray<ServerProviderModel>;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
};

type ClaudeEffortLevel = NonNullable<ClaudeModelInfo["supportedEffortLevels"]>[number];

function claudeEffortLabel(effort: ClaudeEffortLevel): string {
switch (effort) {
case "low":
return "Low";
case "medium":
return "Medium";
case "high":
return "High";
case "xhigh":
return "Extra High";
case "max":
return "Max";
}
}

function claudeCapabilitiesFromModelInfo(model: ClaudeModelInfo): ModelCapabilities {
const effortLevels = [...new Set(model.supportedEffortLevels ?? [])];
const hasEffortSelect = Boolean(model.supportsEffort) && effortLevels.length > 0;
return createModelCapabilities({
optionDescriptors: [
...(hasEffortSelect
? [
buildSelectOptionDescriptor({
id: "effort",
label: "Reasoning",
options: [
...effortLevels.map((effort) => ({
value: effort,
label: claudeEffortLabel(effort),
})),
{ value: "ultrathink", label: "Ultrathink" },
],
promptInjectedValues: ["ultrathink"],
}),
Comment thread
cursor[bot] marked this conversation as resolved.
]
: []),
// Effort already governs how much a model thinks, so the standalone
// always-thinking toggle is only offered for models without effort
// levels — matching how the static fallback catalog exposes it.
...(!hasEffortSelect && model.supportsAdaptiveThinking
? [
buildBooleanOptionDescriptor({
id: "thinking",
label: "Thinking",
}),
]
: []),
...(model.supportsFastMode
? [
buildBooleanOptionDescriptor({
id: "fastMode",
label: "Fast Mode",
}),
]
: []),
],
});
}

export function parseClaudeInitializationModels(
models: ReadonlyArray<ClaudeModelInfo> | undefined,
): ReadonlyArray<ServerProviderModel> {
const seen = new Set<string>();
return (models ?? []).flatMap((model) => {
const slug = nonEmptyProbeString(model.value);
if (!slug || seen.has(slug)) {
return [];
}
seen.add(slug);

const name = nonEmptyProbeString(model.displayName) ?? slug;
return [
{
slug,
name,
isCustom: false,
...(slug === "default" ? { isDefault: true } : {}),
capabilities: claudeCapabilitiesFromModelInfo(model),
} satisfies ServerProviderModel,
];
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

function parseClaudeInitializationCommands(
commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
): ReadonlyArray<ServerProviderSlashCommand> {
Expand Down Expand Up @@ -697,7 +785,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
* We pass a never-yielding AsyncIterable as the prompt so that no user
* message is ever written to the subprocess stdin. This means the Claude
* Code subprocess completes its local initialization IPC (returning
* account info and slash commands) but never starts an API request to
* account info, models, and slash commands) but never starts an API request to
* Anthropic. We read the init data and then abort the subprocess.
*
* This is used as a fallback when `claude auth status` does not include
Expand Down Expand Up @@ -744,6 +832,7 @@ const probeClaudeCapabilities = (
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
models: parseClaudeInitializationModels(init.models),
slashCommands: parseClaudeInitializationCommands(init.commands),
} satisfies ClaudeCapabilitiesProbe;
});
Expand Down Expand Up @@ -793,7 +882,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
const resolvedEnvironment = environment ?? process.env;
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const allModels = providerModelsFromSettings(
BUILT_IN_MODELS,
FALLBACK_CLAUDE_MODELS,
claudeSettings.customModels,
DEFAULT_CLAUDE_MODEL_CAPABILITIES,
);
Expand Down Expand Up @@ -882,24 +971,27 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
});
}

const capabilities = resolveCapabilities
? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
: undefined;
const discoveredModels = capabilities?.models ?? [];
const hasDiscoveredModels = discoveredModels.length > 0;
const models = providerModelsFromSettings(
getBuiltInClaudeModelsForVersion(parsedVersion),
hasDiscoveredModels ? discoveredModels : getFallbackClaudeModelsForVersion(parsedVersion),
Comment thread
cursor[bot] marked this conversation as resolved.
claudeSettings.customModels,
DEFAULT_CLAUDE_MODEL_CAPABILITIES,
);
const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion)
const versionUpgradeMessage = hasDiscoveredModels
? undefined
: supportsClaudeFable5(parsedVersion)
? formatClaudeOpus5UpgradeMessage(parsedVersion)
: supportsClaudeOpus48(parsedVersion)
? formatClaudeFable5UpgradeMessage(parsedVersion)
: supportsClaudeOpus47(parsedVersion)
? formatClaudeOpus48UpgradeMessage(parsedVersion)
: formatClaudeOpus47UpgradeMessage(parsedVersion);

const capabilities = resolveCapabilities
? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
: undefined;
: supportsClaudeOpus5(parsedVersion)
? undefined
: supportsClaudeFable5(parsedVersion)
? formatClaudeOpus5UpgradeMessage(parsedVersion)
: supportsClaudeOpus48(parsedVersion)
? formatClaudeFable5UpgradeMessage(parsedVersion)
: supportsClaudeOpus47(parsedVersion)
? formatClaudeOpus48UpgradeMessage(parsedVersion)
: formatClaudeOpus47UpgradeMessage(parsedVersion);
const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
const slashCommands = capabilities?.slashCommands ?? [];
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
Expand Down Expand Up @@ -956,7 +1048,7 @@ export const makePendingClaudeProvider = (
Effect.gen(function* () {
const checkedAt = yield* nowIso;
const models = providerModelsFromSettings(
BUILT_IN_MODELS,
FALLBACK_CLAUDE_MODELS,
claudeSettings.customModels,
DEFAULT_CLAUDE_MODEL_CAPABILITIES,
);
Expand Down
Loading
Loading