Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ export type Metadata = {
name: string,
description?: string,
contextWindowTokens?: number,
extendedContextModelId?: string,
modelOptions?: Array<{
id: string,
name: string,
Expand All @@ -670,6 +671,7 @@ export type Metadata = {
name: string,
description?: string,
contextWindowTokens?: number,
extendedContextModelId?: string,
modelOptions?: Array<{
id: string,
name: string,
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/backends/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ describe('AGENTS', () => {

it('exposes a preflight session-controls probe adapter for claude so model-scoped options can be surfaced without ACP', async () => {
const entry = requireCatalogEntry('claude');
expect(entry.needsAccountSettingsForProbes).toBe(true);
expect(entry.getPreflightSessionControlsProbeAdapter).toBeTypeOf('function');
const adapter = await entry.getPreflightSessionControlsProbeAdapter!();
expect(adapter).toMatchObject({
modelProbeCachePolicy: 'provider-owned',
probeModelsRaw: expect.any(Function),
});
});
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/backends/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { logClaudeRuntimeAuthEnvDiagnostic } from "./spawn/logClaudeRuntimeAuthE
import { ensureClaudeJsRuntimeExecutable } from "./utils/ensureClaudeJsRuntimeExecutable";
import { resolveClaudeCliPath } from "./utils/resolveClaudeCliPath";
import { resolveCliRuntimeAssetPath } from '@/runtime/assets/resolveCliRuntimeAssetPath';
import { buildClaudeEffortCliArgs } from "./utils/claudeEffort";
import { buildClaudeEffortCliArgs, resolveModeEffortLevelsForModel } from "./utils/claudeEffort";
import {
buildClaudeCompactionCompletedEvent,
buildClaudeCompactionLifecycleId,
Expand All @@ -36,6 +36,7 @@ import { isClaudeLegacyRequiredHookObservationFailure } from './remote/runtimeAc
function buildClaudeEffortArgs(params: Readonly<{
modelId: unknown;
effort: unknown;
supportedLevels?: readonly string[];
}>): string[] {
return buildClaudeEffortCliArgs(params);
}
Expand Down Expand Up @@ -227,6 +228,7 @@ export async function claudeRemote(opts: {
const effortArgs = buildClaudeEffortArgs({
modelId: argOverrides.model ?? initial.mode.model,
effort: argOverrides.effort ?? initial.mode.reasoningEffort,
supportedLevels: resolveModeEffortLevelsForModel(initial.mode, argOverrides.model ?? initial.mode.model),
});
const extraArgs = [
...(opts.hookPluginDir ? ['--plugin-dir', opts.hookPluginDir] : []),
Expand Down
8 changes: 7 additions & 1 deletion apps/cli/src/backends/claude/cli/terminalOptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { EnhancedMode } from '@/backends/claude/loop';
import { buildClaudeEffortCliArgs, resolveClaudeUltracodeForModel } from '@/backends/claude/utils/claudeEffort';
import {
buildClaudeEffortCliArgs,
resolveClaudeUltracodeForModel,
resolveModeEffortLevelsForModel,
} from '@/backends/claude/utils/claudeEffort';
import { getClaudeRemoteSystemPrompt } from '@/backends/claude/utils/remoteSystemPrompt';
import { parseClaudeSdkFlagOverridesFromArgs } from '@/backends/claude/remote/sdkFlagOverrides';

Expand Down Expand Up @@ -192,6 +196,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{
extraArgs.push(...buildClaudeEffortCliArgs({
modelId: effectiveModel,
effort: argOverrides.effort ?? params.mode.reasoningEffort,
supportedLevels: resolveModeEffortLevelsForModel(params.mode, effectiveModel),
}));
if (effectiveModel) {
extraArgs.push('--model', effectiveModel);
Expand Down Expand Up @@ -233,6 +238,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{
ultracodeEnabled: resolveClaudeUltracodeForModel({
modelId: effectiveModel,
ultracode: params.mode.ultracode,
supportedLevels: resolveModeEffortLevelsForModel(params.mode, effectiveModel),
}),
diagnostics: Object.freeze([...diagnostics]),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import {

export type ClaudeConnectedServiceId = 'claude-subscription' | 'anthropic';

/**
* The part of a resolved selection that decides the stable directory.
*
* `ConnectedServiceResolvedSelection` satisfies this structurally. Callers that only know the
* binding (e.g. the models probe, which never materializes credentials) can pass the narrow shape
* instead of synthesizing a credential record.
*/
export type ClaudeConnectedServiceStableDirSelection =
| Readonly<{ kind: 'profile'; profileId: string }>
| Readonly<{ kind: 'group'; groupId: string }>;

function readClaudeConnectedServiceId(value: ConnectedServiceId): ClaudeConnectedServiceId | null {
return value === 'claude-subscription' || value === 'anthropic' ? value : null;
}
Expand All @@ -18,7 +29,7 @@ export function resolveClaudeConnectedServiceStableRootDir(params: Readonly<{
activeServerDir: string;
serviceId: ConnectedServiceId;
fallbackProfileId: string;
selection: ConnectedServiceResolvedSelection | null | undefined;
selection: ConnectedServiceResolvedSelection | ClaudeConnectedServiceStableDirSelection | null | undefined;
}>): string | null {
const serviceId = readClaudeConnectedServiceId(params.serviceId);
if (!serviceId) return null;
Expand All @@ -41,7 +52,7 @@ export function resolveClaudeConnectedServiceStableConfigDir(params: Readonly<{
activeServerDir: string;
serviceId: ConnectedServiceId;
fallbackProfileId: string;
selection: ConnectedServiceResolvedSelection | null | undefined;
selection: ConnectedServiceResolvedSelection | ClaudeConnectedServiceStableDirSelection | null | undefined;
}>): string | null {
const rootDir = resolveClaudeConnectedServiceStableRootDir(params);
return rootDir ? join(rootDir, 'claude-config') : null;
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/backends/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { buildClaudeRuntimeLocalHandoffMetadata } from '@/backends/claude/sessio
import type { AgentCatalogEntry } from '../types';
import type { ConnectedServiceCredentialLifecycleDescriptor } from '@/daemon/connectedServices/credentials/lifecycleTypes';


const claudeConnectedServiceCredentialLifecycleDescriptor: ConnectedServiceCredentialLifecycleDescriptor = {
providerId: 'claude',
serviceIds: AGENTS_CORE.claude.connectedServices.supportedServiceIds,
Expand Down Expand Up @@ -122,6 +123,7 @@ export const agent = {
.hasClaudeEndpointDescriptorForSession(params),
vendorResumeSupport: AGENTS_CORE.claude.resume.vendorResume,
buildRuntimeLocalHandoffMetadata: buildClaudeRuntimeLocalHandoffMetadata,
needsAccountSettingsForProbes: true,
getPreflightSessionControlsProbeAdapter: async () => (await import('@/backends/claude/preflight/claudePreflightModelsProbeAdapter')).claudePreflightModelsProbeAdapter,
getHeadlessTmuxArgvTransform: async () => (await import('@/backends/claude/startup/headlessTmuxArgs')).ensureClaudeHeadlessTmuxStartingModeArgs,
} satisfies AgentCatalogEntry;
16 changes: 16 additions & 0 deletions apps/cli/src/backends/claude/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ export interface EnhancedMode {
* never `--effort` or the SDK `effort` option. Only honored on xhigh-capable models.
*/
ultracode?: boolean;
/**
* Effort tiers the selected model reports (Anthropic Models API), resolved once when the mode
* is built.
*
* Curated models carry their own static effort table; this supplies the same evidence for a
* discovered model. It is part of the mode — not read from a cache at spawn time — so
* launch-option hashing stays a pure function of the mode.
*/
modelEffortLevels?: readonly string[];
/**
* The model `modelEffortLevels` was resolved for.
*
* Call sites can override the model (e.g. `--model` inside `claudeArgs`), so tiers are only
* evidence when they belong to the model actually being launched.
*/
modelEffortLevelsModelId?: string | null;

// Claude remote-mode (provider-scoped) settings forwarded via message meta.
claudeRemoteAgentSdkEnabled?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createEnvKeyScope } from '@/testkit/env/envScope';

import type { AnthropicModelEntry } from './fetchAnthropicModels';

const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({
fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise<AnthropicModelEntry[] | null>>(),
readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
}));

vi.mock('./fetchAnthropicModels', async (importOriginal) => {
const actual = await importOriginal<typeof import('./fetchAnthropicModels')>();
return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock };
});

vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile')>();
return { ...actual, readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock };
});

import { createClaudeModelEffortLevelsTracker } from './claudeModelEffortLevelsTracker';
import { resetClaudeModelCatalogCacheForTests } from './resolveClaudeModelCatalog';

const envKeys = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_OAUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_BASE_URL',
] as const;
let envScope = createEnvKeyScope(envKeys);

const effortCapabilities = (tiers: readonly string[]): AnthropicModelEntry['capabilities'] => ({
effort: {
supported: true,
...Object.fromEntries(tiers.map((tier) => [tier, { supported: true }])),
},
});

const createTracker = (): ReturnType<typeof createClaudeModelEffortLevelsTracker> =>
createClaudeModelEffortLevelsTracker({ resolveTimeoutMs: () => 1_000 });

beforeEach(() => {
fetchAnthropicModelsMock.mockReset();
readClaudeCodeNativeCredentialMock.mockReset();
readClaudeCodeNativeCredentialMock.mockResolvedValue(null);
resetClaudeModelCatalogCacheForTests();
envScope.restore();
envScope = createEnvKeyScope(envKeys);
process.env.ANTHROPIC_API_KEY = 'sk-ant-key';
});

afterEach(() => {
// Leave no cached catalog behind: the cache is module state, so a later suite sharing this
// module context would otherwise read entries this one populated.
resetClaudeModelCatalogCacheForTests();
envScope.restore();
envScope = createEnvKeyScope(envKeys);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('createClaudeModelEffortLevelsTracker', () => {
it('reports the tiers a discovered model declares', async () => {
fetchAnthropicModelsMock.mockResolvedValue([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'high', 'xhigh']) },
]);
const tracker = createTracker();

await tracker.refresh('claude-opus-9');

expect(tracker.getModelId()).toBe('claude-opus-9');
expect(tracker.getLevels()).toEqual(['low', 'high', 'xhigh']);
});

it('forgets the previous model on reset to the CLI default', async () => {
fetchAnthropicModelsMock.mockResolvedValue([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) },
]);
const tracker = createTracker();
await tracker.refresh('claude-opus-9');
expect(tracker.getLevels()).not.toEqual([]);

await tracker.refresh(undefined);

// Leaving the tiers live would clamp whatever is selected next against this model.
expect(tracker.getModelId()).toBeNull();
expect(tracker.getLevels()).toEqual([]);
});

it('does not consult the catalog for a curated model', async () => {
const tracker = createTracker();

await tracker.refresh('claude-haiku-4-5');

// Curated models resolve effort from the static table, so a network round trip is wasted work.
expect(fetchAnthropicModelsMock).not.toHaveBeenCalled();
expect(tracker.getLevels()).toEqual([]);
expect(tracker.getModelId()).toBe('claude-haiku-4-5');
});

it('reports no tiers when the catalog lookup fails', async () => {
fetchAnthropicModelsMock.mockRejectedValue(new Error('network'));
const tracker = createTracker();

await tracker.refresh('claude-opus-9');

// Fails closed: no evidence means no `--effort`, never a guessed level.
expect(tracker.getLevels()).toEqual([]);
});

it('does not let a late lookup publish a superseded model tiers', async () => {
// Concurrent resolutions for one account share a single fetch, so both refreshes await the same
// promise. The guard has to be the model id, not which lookup happened to start first.
let releaseCatalog: ((entries: AnthropicModelEntry[]) => void) | null = null;
fetchAnthropicModelsMock.mockImplementation(() => new Promise<AnthropicModelEntry[]>((resolve) => {
releaseCatalog = resolve;
}));

const tracker = createTracker();
const first = tracker.refresh('claude-opus-9');
const second = tracker.refresh('claude-sonnet-9');
expect(tracker.getModelId()).toBe('claude-sonnet-9');

// Credential resolution is async, so the fetch starts a tick after refresh is called.
await new Promise((resolve) => { setTimeout(resolve, 0); });

const release = releaseCatalog as ((entries: AnthropicModelEntry[]) => void) | null;
if (!release) {
throw new Error('expected the catalog lookup to be in flight');
}
release([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) },
{ id: 'claude-sonnet-9', displayName: 'Sonnet 9', capabilities: effortCapabilities(['low']) },
]);
await Promise.all([first, second]);

// The superseded lookup must not publish Opus 9 tiers under Sonnet 9.
expect(tracker.getModelId()).toBe('claude-sonnet-9');
expect(tracker.getLevels()).toEqual(['low']);
});

it('does not block the caller past its budget on a cold catalog', async () => {
// SessionClient awaits the user-message callback as part of the pending-queue handoff, so an
// unbounded wait here would hold the queue behind this fetch.
fetchAnthropicModelsMock.mockImplementation(() => new Promise<AnthropicModelEntry[]>(() => {}));
const tracker = createTracker();

const startedAt = Date.now();
await tracker.refreshWithin('claude-opus-9', 30);

expect(Date.now() - startedAt).toBeLessThan(2_000);
// Past the budget the turn proceeds with no evidence, exactly as it would have before.
expect(tracker.getLevels()).toEqual([]);
});

it('returns immediately once the catalog is cached', async () => {
fetchAnthropicModelsMock.mockResolvedValue([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) },
]);
const tracker = createTracker();
await tracker.refresh('claude-opus-9');

// A different model on a warm catalog must still resolve within the budget, not fall back to [].
const second = createTracker();
await second.refreshWithin('claude-opus-9', 30);
expect(second.getLevels()).toEqual(['low', 'xhigh']);
});

it('shares one catalog fetch across concurrent refreshes for the same account', async () => {
fetchAnthropicModelsMock.mockResolvedValue([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low']) },
]);
const trackerA = createTracker();
const trackerB = createTracker();

await Promise.all([trackerA.refresh('claude-opus-9'), trackerB.refresh('claude-opus-9')]);

// The preflight probe and the session publisher both resolve at session start; one fetch is enough.
expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1);
});

it('joins a startup prewarm for the same model inside the first-turn budget', async () => {
let releaseCatalog: ((entries: AnthropicModelEntry[]) => void) | null = null;
fetchAnthropicModelsMock.mockImplementation(() => new Promise<AnthropicModelEntry[]>((resolve) => {
releaseCatalog = resolve;
}));
const tracker = createTracker();

void tracker.refresh('claude-opus-9');
const firstTurn = tracker.refreshWithin('claude-opus-9', 1_000);
await new Promise((resolve) => { setTimeout(resolve, 0); });

const release = releaseCatalog as ((entries: AnthropicModelEntry[]) => void) | null;
if (!release) throw new Error('expected the startup catalog lookup to be in flight');
release([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'medium']) },
]);
await firstTurn;

expect(tracker.getLevels()).toEqual(['low', 'medium']);
expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1);
});

it('retries the same discovered model after an unavailable catalog recovers', async () => {
fetchAnthropicModelsMock
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'high']) },
]);
const tracker = createTracker();

await tracker.refresh('claude-opus-9');
await tracker.refresh('claude-opus-9');

expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2);
expect(tracker.getLevels()).toEqual(['low', 'high']);
});

it('does not resolve the catalog again after the same model settles successfully', async () => {
fetchAnthropicModelsMock.mockResolvedValue([
{ id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities([]) },
]);
const resolveTimeoutMs = vi.fn(() => 1_000);
const tracker = createClaudeModelEffortLevelsTracker({ resolveTimeoutMs });

await tracker.refresh('claude-opus-9');
await tracker.refresh('claude-opus-9');

// An empty supported-tier list is still a successfully settled catalog answer, not a retry signal.
expect(resolveTimeoutMs).toHaveBeenCalledTimes(1);
expect(tracker.getLevels()).toEqual([]);
});
});
Loading
Loading