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
26 changes: 18 additions & 8 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ProviderDriverError } from "../Errors.ts";
import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
import {
checkClaudeProviderStatus,
getClaudeModelCapabilities,
makePendingClaudeProvider,
probeClaudeCapabilities,
} from "../Layers/ClaudeProvider.ts";
Expand Down Expand Up @@ -141,14 +142,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
continuationGroupKey,
});

const adapterOptions = {
instanceId,
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
};
const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv);

// Per-instance capabilities cache: keyed on binary + resolved HOME so
// account-specific probes never share auth metadata across instances.
const capabilitiesProbeCache = yield* Cache.make({
Expand All @@ -160,6 +153,23 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
),
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
const resolveModelCapabilities = (model: string | null | undefined) =>
Cache.get(capabilitiesProbeCache, capabilitiesCacheKey).pipe(
Effect.map((capabilities) => getClaudeModelCapabilities(model, capabilities?.models)),
);

const adapterOptions = {
instanceId,
environment: processEnv,
resolveModelCapabilities,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
};
const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
const textGeneration = yield* makeClaudeTextGeneration(
effectiveConfig,
processEnv,
resolveModelCapabilities,
);

const checkProvider = checkClaudeProviderStatus(
effectiveConfig,
Expand Down
90 changes: 89 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import type {
import {
ApprovalRequestId,
ClaudeSettings,
type ModelCapabilities,
ProviderDriverKind,
ProviderItemId,
ProviderRuntimeEvent,
type RuntimeMode,
ThreadId,
ProviderInstanceId,
} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
import { assert, describe, it } from "@effect/vitest";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -156,6 +157,7 @@ function makeHarness(config?: {
readonly baseDir?: string;
readonly claudeConfig?: Partial<ClaudeSettings>;
readonly instanceId?: ProviderInstanceId;
readonly resolveModelCapabilities?: ClaudeAdapterLiveOptions["resolveModelCapabilities"];
}) {
const query = new FakeClaudeQuery();
let createInput:
Expand All @@ -171,6 +173,9 @@ function makeHarness(config?: {
createInput = input;
return query;
},
...(config?.resolveModelCapabilities
? { resolveModelCapabilities: config.resolveModelCapabilities }
: {}),
...(config?.nativeEventLogger
? {
nativeEventLogger: config.nativeEventLogger,
Expand Down Expand Up @@ -205,6 +210,19 @@ function makeHarness(config?: {
};
}

function customEffortCapabilities(...efforts: ReadonlyArray<string>): ModelCapabilities {
return createModelCapabilities({
optionDescriptors: [
{
id: "effort",
label: "Reasoning",
type: "select",
options: efforts.map((effort) => ({ id: effort, label: effort })),
},
],
});
}

function makeDeterministicRandomService(seed = 0x1234_5678): {
nextIntUnsafe: () => number;
nextDoubleUnsafe: () => number;
Expand Down Expand Up @@ -350,6 +368,7 @@ describe("ClaudeAdapterLive", () => {
assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);
assert.equal(createInput?.options.permissionMode, "bypassPermissions");
assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
assert.equal(createInput?.options.effort, undefined);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
Expand Down Expand Up @@ -418,6 +437,75 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("does not default effort for custom Claude models", () => {
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"), "gpt-5.6-sol"),
runtimeMode: "full-access",
});

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

it.effect("preserves xhigh effort for custom Claude models", () => {
const harness = makeHarness({
resolveModelCapabilities: () => Effect.succeed(customEffortCapabilities("xhigh")),
});
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
modelSelection: createModelSelection(
ProviderInstanceId.make("claudeAgent"),
"gpt-5.6-sol",
[{ id: "effort", value: "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("ignores custom Claude effort levels not advertised by the SDK", () => {
const harness = makeHarness({
resolveModelCapabilities: () => Effect.succeed(customEffortCapabilities("low")),
});
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
modelSelection: createModelSelection(
ProviderInstanceId.make("claudeAgent"),
"gpt-5.6-sol",
[{ id: "effort", value: "xhigh" }],
),
runtimeMode: "full-access",
});

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

it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => {
const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } });
return Effect.gen(function* () {
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type CanonicalRequestType,
type ClaudeSettings,
EventId,
type ModelCapabilities,
type ProviderApprovalDecision,
ProviderDriverKind,
ProviderInstanceId,
Expand Down Expand Up @@ -221,6 +222,9 @@ export interface ClaudeAdapterLiveOptions {
}) => ClaudeQueryRuntime;
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: EventNdjsonLogger;
readonly resolveModelCapabilities?: (
model: string | null | undefined,
) => Effect.Effect<ModelCapabilities>;
}

function isUuid(value: string): boolean {
Expand Down Expand Up @@ -1359,6 +1363,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
stream: "native",
})
: undefined);
const resolveModelCapabilities =
options?.resolveModelCapabilities ??
((model: string | null | undefined) => Effect.succeed(getClaudeModelCapabilities(model)));

const createQuery =
options?.createQuery ??
Expand Down Expand Up @@ -3414,7 +3421,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
const modelSelection =
input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
const caps = getClaudeModelCapabilities(modelSelection?.model);
const caps = yield* resolveModelCapabilities(modelSelection?.model);
const descriptors = getProviderOptionDescriptors({ caps });
const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
const initialContextWindow = selectedClaudeContextWindow(modelSelection);
Expand Down
98 changes: 98 additions & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { ClaudeSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { beforeEach, vi } from "vite-plus/test";

import { probeClaudeCapabilities } from "./ClaudeProvider.ts";

type ClaudeQuery = typeof import("@anthropic-ai/claude-agent-sdk").query;
type ClaudeInitialization = Awaited<ReturnType<ReturnType<ClaudeQuery>["initializationResult"]>>;

const claudeQueryMock = vi.hoisted(() => vi.fn());
const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings);

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: claudeQueryMock,
}));

beforeEach(() => {
claudeQueryMock.mockReset();
});

it.layer(NodeServices.layer)("probeClaudeCapabilities", (it) => {
it.effect("keeps completed probes when another custom model times out", () =>
Effect.gen(function* () {
let activeProbes = 0;
let peakActiveProbes = 0;
const closedModels: Array<string> = [];

claudeQueryMock.mockImplementation((input: Parameters<ClaudeQuery>[0]) => {
assert.ok(input.options);
assert.ok(input.options.abortController);
const model = input.options.model ?? "default";
const abort = input.options.abortController;
activeProbes += 1;
peakActiveProbes = Math.max(peakActiveProbes, activeProbes);
let closed = false;
const close = () => {
if (!closed) {
closed = true;
activeProbes -= 1;
closedModels.push(model);
}
};

const initializationResult = () => {
if (model === "slow") {
return new Promise<ClaudeInitialization>((_resolve, reject) => {
abort.signal.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
});
}
return Promise.resolve({
account: { email: `${model}@example.com` },
commands: [],
models: [
{
value: model,
displayName: model,
description: "Custom model",
supportsEffort: true,
supportedEffortLevels: ["low", "high"],
},
],
} as unknown as ClaudeInitialization);
};

return { close, initializationResult } as ReturnType<ClaudeQuery>;
});

const settings = yield* decodeClaudeSettings({
customModels: ["slow", "fast", "later"],
});
const probe = yield* probeClaudeCapabilities(settings).pipe(
Effect.forkChild({ startImmediately: true }),
);

while (claudeQueryMock.mock.calls.length < 3) {
yield* Effect.yieldNow;
}
yield* TestClock.adjust("25 seconds");

const result = yield* Fiber.join(probe);
assert.deepStrictEqual(result?.models.map((model) => model.value).toSorted(), [
"fast",
"later",
]);
assert.strictEqual(result?.email, "fast@example.com");
assert.strictEqual(peakActiveProbes, 2);
assert.strictEqual(activeProbes, 0);
assert.deepStrictEqual(closedModels.toSorted(), ["fast", "later", "slow"]);
}),
);
});
Loading
Loading