Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
[
"exec",
"--ephemeral",
"--skip-git-repo-check",
"-s",
"read-only",
"--model",
Expand Down
51 changes: 49 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,53 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
modelSelection: {
provider: "claudeAgent",
model: "claude-opus-4-7",
},
runtimeMode: "full-access",
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.effort, "xhigh");
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("forwards xhigh effort for Claude Opus 4.7", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
modelSelection: {
provider: "claudeAgent",
model: "claude-opus-4-7",
options: {
effort: "xhigh",
},
},
runtimeMode: "full-access",
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.effort, "xhigh");
}).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 All @@ -371,7 +418,7 @@ describe("ClaudeAdapterLive", () => {
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.effort, "high");
assert.equal(createInput?.options.effort, "medium");
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
Expand Down Expand Up @@ -538,7 +585,7 @@ describe("ClaudeAdapterLive", () => {
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.effort, "high");
assert.equal(createInput?.options.effort, "medium");
const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));
assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");
}).pipe(
Expand Down
14 changes: 7 additions & 7 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
ThreadId,
TurnId,
type UserInputQuestion,
ClaudeCodeEffort,
ClaudeAgentEffort,
RuntimeMode,
} from "@t3tools/contracts";
import {
Expand Down Expand Up @@ -221,9 +221,9 @@ function isSyntheticClaudeThreadId(value: string): boolean {
return value.startsWith("claude-thread-");
}

function getEffectiveClaudeCodeEffort(
effort: ClaudeCodeEffort | null | undefined,
): Exclude<ClaudeCodeEffort, "ultrathink"> | null {
function getEffectiveClaudeAgentEffort(
effort: ClaudeAgentEffort | null | undefined,
): Exclude<ClaudeAgentEffort, "ultrathink"> | null {
if (!effort) {
return null;
}
Expand Down Expand Up @@ -291,7 +291,7 @@ function maxClaudeContextWindowFromModelUsage(
}

function normalizeClaudeTokenUsage(
value: Record<string, unknown> | undefined,
value: unknown,
contextWindow?: number,
): ThreadTokenUsageSnapshot | undefined {
if (!value || typeof value !== "object") {
Expand Down Expand Up @@ -2928,13 +2928,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const caps = getClaudeModelCapabilities(modelSelection?.model);
const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined;
const effort = (resolveEffort(caps, modelSelection?.options?.effort) ??
null) as ClaudeCodeEffort | null;
null) as ClaudeAgentEffort | null;
const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
const thinking =
typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle
? modelSelection.options.thinking
: undefined;
const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
const effectiveEffort = getEffectiveClaudeAgentEffort(effort);
const runtimeModeToPermission: Record<RuntimeMode, PermissionMode> = {
"approval-required": "default",
"auto-accept-edits": "acceptEdits",
Expand Down
48 changes: 41 additions & 7 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
spawnAndCollect,
type CommandResult,
} from "../providerSnapshot";
import { compareCliVersions } from "../cliVersion";
import { makeManagedServerProvider } from "../makeManagedServerProvider";
import { ClaudeProvider } from "../Services/ClaudeProvider";
import { ServerSettingsService } from "../../serverSettings";
Expand All @@ -43,6 +44,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = {
};

const PROVIDER = "claudeAgent" as const;
const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";
const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-opus-4-7",
Expand All @@ -53,7 +55,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "XHigh", isDefault: true },
{ value: "xhigh", label: "Extra High", isDefault: true },
{ value: "max", label: "Max" },
{ value: "ultrathink", label: "Ultrathink" },
],
Expand Down Expand Up @@ -121,6 +123,24 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
},
];

function supportsClaudeOpus47(version: string | null | undefined): boolean {
return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
}

function getBuiltInClaudeModelsForVersion(
version: string | null | undefined,
): ReadonlyArray<ServerProviderModel> {
if (supportsClaudeOpus47(version)) {
return BUILT_IN_MODELS;
}
return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7");
}

function formatClaudeOpus47UpgradeMessage(version: string | null): string {
const versionLabel = version ? `v${version}` : "the installed version";
return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`;
Comment on lines +126 to +141
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supportsClaudeOpus47() treats null/undefined versions as unsupported. When parseGenericCliVersion() fails (returns null), this will both hide Opus 4.7 and surface a "too old" upgrade message even though the version is unknown. Consider treating an unparseable/unknown version as "unknown support" (e.g., keep all built-in models and omit the upgrade message), or improve version parsing so parsedVersion is only null on truly missing output.

Suggested change
function supportsClaudeOpus47(version: string | null | undefined): boolean {
return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
}
function getBuiltInClaudeModelsForVersion(
version: string | null | undefined,
): ReadonlyArray<ServerProviderModel> {
if (supportsClaudeOpus47(version)) {
return BUILT_IN_MODELS;
}
return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7");
}
function formatClaudeOpus47UpgradeMessage(version: string | null): string {
const versionLabel = version ? `v${version}` : "the installed version";
return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`;
function getClaudeOpus47Support(
version: string | null | undefined,
): "supported" | "unsupported" | "unknown" {
if (!version) {
return "unknown";
}
return compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0
? "supported"
: "unsupported";
}
function getBuiltInClaudeModelsForVersion(
version: string | null | undefined,
): ReadonlyArray<ServerProviderModel> {
const opus47Support = getClaudeOpus47Support(version);
if (opus47Support !== "unsupported") {
return BUILT_IN_MODELS;
}
return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7");
}
function formatClaudeOpus47UpgradeMessage(version: string | null): string | undefined {
if (getClaudeOpus47Support(version) !== "unsupported") {
return undefined;
}
return `Claude Code v${version} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`;

Copilot uses AI. Check for mistakes.
}

export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities {
const slug = model?.trim();
return (
Expand Down Expand Up @@ -509,7 +529,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
Effect.map((settings) => settings.providers.claudeAgent),
);
const checkedAt = new Date().toISOString();
const models = providerModelsFromSettings(
const allModels = providerModelsFromSettings(
BUILT_IN_MODELS,
PROVIDER,
claudeSettings.customModels,
Expand All @@ -524,7 +544,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
provider: PROVIDER,
enabled: false,
checkedAt,
models,
models: allModels,
probe: {
installed: false,
version: null,
Expand All @@ -547,7 +567,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
provider: PROVIDER,
enabled: claudeSettings.enabled,
checkedAt,
models,
models: allModels,
probe: {
installed: !isCommandMissingCause(error),
version: null,
Expand All @@ -565,7 +585,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
provider: PROVIDER,
enabled: claudeSettings.enabled,
checkedAt,
models,
models: allModels,
probe: {
installed: true,
version: null,
Expand All @@ -585,7 +605,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
provider: PROVIDER,
enabled: claudeSettings.enabled,
checkedAt,
models,
models: allModels,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -598,6 +618,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
});
}

const models = providerModelsFromSettings(
getBuiltInClaudeModelsForVersion(parsedVersion),
PROVIDER,
claudeSettings.customModels,
DEFAULT_CLAUDE_MODEL_CAPABILITIES,
);
const opus47UpgradeMessage = supportsClaudeOpus47(parsedVersion)
? undefined
: formatClaudeOpus47UpgradeMessage(parsedVersion);

const slashCommands =
(resolveSlashCommands
? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe(
Expand Down Expand Up @@ -684,7 +714,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
...parsed.auth,
...(authMetadata ? authMetadata : {}),
},
...(parsed.message ? { message: parsed.message } : {}),
...(parsed.message
? { message: parsed.message }
: opus47UpgradeMessage
? { message: opus47UpgradeMessage }
: {}),
},
...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}),
});
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,69 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
),
);

it.effect(
"includes Claude Opus 4.7 with xhigh as the default effort on supported versions",
() =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus();
const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7");
if (!opus47) {
assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111.");
}
if (!opus47.capabilities) {
assert.fail(
"Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.",
);
}
assert.deepStrictEqual(
opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault),
{ value: "xhigh", label: "Extra High", isDefault: true },
);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 };
if (joined === "auth status")
return {
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
stderr: "",
code: 0,
};
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("hides Claude Opus 4.7 on older Claude Code versions", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus();
assert.strictEqual(
status.models.some((model) => model.slug === "claude-opus-4-7"),
false,
);
assert.strictEqual(
status.message,
"Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.",
);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 };
if (joined === "auth status")
return {
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
stderr: "",
code: 0,
};
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("returns a display label for claude subscription types", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan"));
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/cliVersion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { assert, describe, it } from "@effect/vitest";

import { compareCliVersions, normalizeCliVersion } from "./cliVersion";

describe("cliVersion", () => {
it("normalizes versions with a missing patch segment", () => {
assert.strictEqual(normalizeCliVersion("2.1"), "2.1.0");
});

it("compares prerelease versions before stable versions", () => {
assert.isTrue(compareCliVersions("2.1.111-beta.1", "2.1.111") < 0);
});

it("rejects malformed numeric segments", () => {
assert.isTrue(compareCliVersions("1.2.3abc", "1.2.10") > 0);
});
});
Loading
Loading