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
35 changes: 35 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
import {
BUNDLED_CLAUDE_MODEL_CATALOG,
withClaudeReportedModels,
} from "../../provider/ClaudeModelCatalog.ts";
import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
Expand Down Expand Up @@ -198,6 +202,37 @@ describe("ClaudeAdapterV2 runtime query policy", () => {
assert.include(options.settings, { alwaysThinkingEnabled: false });
});

it("sends the effort chosen for a model Claude Code reported", () => {
const modelSelection = {
...CLAUDE_TEST_MODEL_SELECTION,
model: "anthropic/claude-gateway",
options: [{ id: "effort", value: "max" }],
};
const modelCatalog = withClaudeReportedModels(
BUNDLED_CLAUDE_MODEL_CATALOG,
[],
[
{
value: "anthropic/claude-gateway",
displayName: "Gateway Claude",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "high", "max"],
},
],
);
const base = { nativeThreadId: "gateway-thread", resume: false, cwd: "/workspace" };

const options = ClaudeAdapterV2.makeClaudeQueryOptions({
...base,
modelSelection,
modelCatalog,
});
assert.equal(options.model, "anthropic/claude-gateway");
assert.equal(options.effort, "max");
assert.isUndefined(ClaudeAdapterV2.makeClaudeQueryOptions({ ...base, modelSelection }).effort);
});

it.each([
["--permission-mode acceptEdits", "acceptEdits"],
["--dangerously-skip-permissions", "bypassPermissions"],
Expand Down
24 changes: 19 additions & 5 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
} from "../../provider/Drivers/ClaudeHome.ts";
import {
BUNDLED_CLAUDE_MODEL_CATALOG,
type ClaudeModelCatalog,
resolveClaudeCatalogContextWindowTokens,
} from "../../provider/ClaudeModelCatalog.ts";
import {
Expand Down Expand Up @@ -807,6 +808,8 @@ export const layerQueryRunner: Layer.Layer<

export function makeClaudeQueryOptions(input: {
readonly modelSelection: ModelSelection;
/** Resolves model options; defaults to the bundled catalog. */
readonly modelCatalog?: ClaudeModelCatalog;
readonly nativeThreadId: string;
readonly resume: boolean;
readonly resumeSessionAt?: string;
Expand All @@ -831,7 +834,7 @@ export function makeClaudeQueryOptions(input: {
readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"];
readonly allowDangerouslySkipPermissions?: boolean;
}): ClaudeAgentSdkQueryOptions {
const compiledSelection = compileClaudeModelSelection(input.modelSelection);
const compiledSelection = compileClaudeModelSelection(input.modelSelection, input.modelCatalog);
const {
"permission-mode": launchArgPermissionMode,
"dangerously-skip-permissions": launchArgSkipPermissions,
Expand Down Expand Up @@ -3019,6 +3022,12 @@ export interface ClaudeAdapterV2Options {
readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;
readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"];
/**
* The instance's model catalog, read per query so models Claude Code
* reports (such as gateway-discovered ones) resolve their options.
* Defaults to the bundled catalog.
*/
readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
/** Sink for wake-turn continuation requests; defaults to dropping them. */
readonly continuationRequests?: {
readonly offer: (
Expand All @@ -3035,6 +3044,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const continuationRequests = adapterOptions.continuationRequests ?? {
offer: () => Effect.void,
};
const modelCatalog = adapterOptions.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG);

// Re-scan on every send: skills are added and switched off mid-session, and
// the scan is a few directory reads. A skill switched off via skillOverrides,
Expand Down Expand Up @@ -7271,7 +7281,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
: { allowedTools: queryPolicy.allowedTools }),
});
const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides);
const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection);
const catalog = yield* modelCatalog;
const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog);
const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread);
const existing = yield* Ref.get(queryContext);
// A continuation prompts nothing: it drains output the live process
Expand Down Expand Up @@ -7341,6 +7352,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;
const queryOptions = makeClaudeQueryOptions({
modelSelection: turnInput.modelSelection,
modelCatalog: catalog,
nativeThreadId,
resume: shouldResume,
...(resumeSessionAt === undefined ? {} : { resumeSessionAt }),
Expand Down Expand Up @@ -7566,7 +7578,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
: yield* makeClaudeUserMessageWithAttachments({
text: applyClaudePromptEffortPrefix(
turnInput.message.text,
compileClaudeModelSelection(turnInput.modelSelection).promptEffort,
compileClaudeModelSelection(turnInput.modelSelection, yield* modelCatalog)
.promptEffort,
),
attachments: turnInput.message.attachments,
attachmentsDir,
Expand Down Expand Up @@ -7775,7 +7788,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const userMessage = yield* makeClaudeUserMessageWithAttachments({
text: applyClaudePromptEffortPrefix(
turnInput.message.text,
compileClaudeModelSelection(currentTurn.input.modelSelection).promptEffort,
compileClaudeModelSelection(currentTurn.input.modelSelection, yield* modelCatalog)
.promptEffort,
),
attachments: turnInput.message.attachments,
priority: "now",
Expand Down Expand Up @@ -8172,7 +8186,7 @@ export type ClaudeAdapterV2DriverEnv =
export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
function* (
input: ProviderAdapterDriverCreateInput<ClaudeSettings>,
hooks: Pick<ClaudeAdapterV2Options, "scopedLimitNames" | "onUsageLimits"> = {},
hooks: Pick<ClaudeAdapterV2Options, "scopedLimitNames" | "onUsageLimits" | "modelCatalog"> = {},
) {
const { instanceId, environment, enabled, config } = input;
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,36 @@ it.effect("preserves initialized capabilities when optional usage times out", ()
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () =>
Effect.gen(function* () {
let abortSignal: AbortSignal | undefined;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
abortSignal = options?.abortController?.signal;
return {
initializationResult: async () => ({
account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () =>
new Promise((_, reject) => reject(new Error("usage unavailable"))),
} as ReturnType<typeof ClaudeSdk.query>;
});
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const capabilities = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: "claude" }),
{
...process.env,
ANTHROPIC_BASE_URL: "https://gateway.example",
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
},
);
assert.isDefined(capabilities);
assert.equal(abortSignal?.aborted, false);
yield* TestClock.adjust("3 seconds");
assert.equal(abortSignal?.aborted, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("asks for usage without the local transcript scan", () =>
Effect.gen(function* () {
let usageOptions: unknown;
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/provider/ClaudeModelCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveClaudeModelSlug,
resolveClaudeUpdateRequiredModels,
scopeClaudeModelCatalog,
withClaudeReportedModels,
} from "./ClaudeModelCatalog.ts";

/**
Expand Down Expand Up @@ -253,4 +254,62 @@ describe("Claude model catalog", () => {
["claude-synthetic-next", "claude-custom-tuned"],
);
});

it("resolves effort for models Claude Code reports from its own capabilities", () => {
const catalog = withClaudeReportedModels(
catalogFromManifest(manifest()),
[],
[
{ value: "synthetic", displayName: "Synthetic", description: "", supportsEffort: true },
{
value: "anthropic/claude-gateway",
displayName: "Gateway Claude",
description: "From gateway",
supportsEffort: true,
supportedEffortLevels: ["low", "high", "max"],
supportsFastMode: true,
},
{ value: "claude-gateway-plain", displayName: "Plain", description: "From gateway" },
],
);

assert.deepStrictEqual(
resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
["claude-synthetic-next", "anthropic/claude-gateway", "claude-gateway-plain"],
);
assert.strictEqual(
resolveClaudeCatalogEffort(catalog, "anthropic/claude-gateway", "max"),
"max",
);
assert.strictEqual(
resolveClaudeCatalogEffort(catalog, "anthropic/claude-gateway", undefined),
"high",
);
assert.strictEqual(
resolveClaudeCatalogApiModelId(catalog, {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "anthropic/claude-gateway",
options: [{ id: "effort", value: "max" }],
}),
"anthropic/claude-gateway",
);
assert.strictEqual(
resolveClaudeCatalogEffort(catalog, "claude-gateway-plain", "max"),
undefined,
);
});

it("lists a reported model once regardless of context-window suffix or order", () => {
const reported = (value: string) => ({ value, displayName: "Gateway", description: "" });
const catalog = withClaudeReportedModels(
catalogFromManifest(manifest()),
[],
[reported("gateway/model[1m]"), reported("gateway/model"), reported("gateway/model[1m]")],
);

assert.deepStrictEqual(
resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
["claude-synthetic-next", "gateway/model[1m]"],
);
});
});
85 changes: 85 additions & 0 deletions apps/server/src/provider/ClaudeModelCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ServerProviderModel,
type ServerProviderUpdateRequiredModel,
} from "@t3tools/contracts";
import type { ModelInfo as ClaudeModelInfo } from "@anthropic-ai/claude-agent-sdk";
import * as Option from "effect/Option";
import {
getModelSelectionStringOptionValue,
Expand Down Expand Up @@ -128,6 +129,90 @@ export function scopeClaudeModelCatalog(
return { models: [...builtInModels, ...customCatalogModels] };
}

const CLAUDE_EFFORT_LABELS: Record<string, string> = {
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra High",
max: "Max",
};

/** Strip Claude Code's context-window suffix, e.g. `opus[1m]` -> `opus`. */
function stripClaudeModelSuffix(value: string): string {
return value.replace(/\[[^\]]*\]$/, "").trim();
}

/** Option descriptors for a model from what Claude Code reports it supports. */
function claudeReportedModelCapabilities(info: ClaudeModelInfo): ModelCapabilities {
const efforts = info.supportsEffort ? (info.supportedEffortLevels ?? []) : [];
const defaultEffort = efforts.includes("high") ? "high" : undefined;
return {
optionDescriptors: [
...(efforts.length
? [
{
id: "effort",
label: "Reasoning",
type: "select" as const,
options: efforts.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id] ?? id,
...(id === defaultEffort ? { isDefault: true } : {}),
})),
},
]
: []),
...(info.supportsFastMode
? [{ id: "fastMode", label: "Fast Mode", type: "boolean" as const }]
: []),
],
};
}

/**
* Append models Claude Code reports that the catalog does not know, such as
* ids from an Anthropic-compatible gateway with model discovery on. Catalog
* models, including ones the installed version cannot run, are never
* duplicated and keep T3's descriptors. A custom model with the same slug keeps
* its settings-owned row. Discovered models take their options from what
* Claude Code reports and are passed to it verbatim at runtime.
*/
export function withClaudeReportedModels(
catalog: ClaudeModelCatalog,
customModels: ReadonlyArray<CustomModelSetting>,
reportedModels: ReadonlyArray<ClaudeModelInfo> | undefined,
): ClaudeModelCatalog {
if (!reportedModels?.length) return catalog;
const known = new Set<string>();
for (const { model } of catalog.models) {
known.add(model.slug.toLowerCase());
for (const alias of model.aliases ?? []) known.add(alias.toLowerCase());
}
for (const entry of readCustomModelEntries(customModels)) known.add(entry.slug.toLowerCase());
const discovered: Array<ClaudeCatalogModel> = [];
for (const info of reportedModels) {
const slug = info.value.trim();
// `default` is Claude Code's pointer at its own default model, not a model.
if (!slug || slug === "default") continue;
const ids = [slug, info.resolvedModel ?? ""].map((id) =>
stripClaudeModelSuffix(id).toLowerCase(),
);
if (ids.some((id) => id && known.has(id))) continue;
for (const id of ids) if (id) known.add(id);
discovered.push({
model: {
slug,
name: info.displayName.trim() || slug,
isCustom: false,
capabilities: claudeReportedModelCapabilities(info),
},
runtime: {},
compatibility: {},
});
}
return discovered.length ? { models: [...catalog.models, ...discovered] } : catalog;
}

function resolveClaudeCatalogModel(
catalog: ClaudeModelCatalog,
slugOrAlias: string | null | undefined,
Expand Down
Loading
Loading