diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index af133bcad27..2645bcdfddd 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -14,7 +14,7 @@ * which wraps these controllers with a stable programmatic API. * * Controllers: - * - SystemController: initialize, interrupt, set_model, supported_commands, get_context_usage + * - SystemController: initialize, interrupt, set_model, set_effort, supported_commands, get_context_usage, get_available_models, get_usage_info * - PermissionController: can_use_tool, set_permission_mode * - SdkMcpController: mcp_server_status (mcp_message handled via callback) * @@ -375,8 +375,11 @@ export class ControlDispatcher implements IPendingRequestRegistry { case 'interrupt': case 'continue_last_turn': case 'set_model': + case 'set_effort': case 'supported_commands': case 'get_context_usage': + case 'get_available_models': + case 'get_usage_info': return this.systemController; case 'can_use_tool': diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts index 7810b0d196d..d6dbaa0ac76 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts @@ -27,6 +27,9 @@ function createContext( setSessionSubagents: vi.fn(), setApprovalMode: vi.fn(), setModel: vi.fn(), + setReasoningEffort: vi.fn(), + getReasoningEffort: vi.fn().mockReturnValue(undefined), + getAvailableModels: vi.fn().mockReturnValue([]), } as unknown as IControlContext['config'], streamJson: { send: vi.fn(), @@ -248,4 +251,235 @@ describe('SystemController', () => { ).rejects.toThrow(/was not registered on ControlContext/); }); }); + + describe('set_effort', () => { + it('sets effort and returns applied=true when read-back matches', async () => { + const context = createContext(); + ( + context.config.getReasoningEffort as ReturnType + ).mockReturnValue('high'); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'set_effort', effort: 'high' }, + 'effort-1', + ); + + expect(context.config.setReasoningEffort).toHaveBeenCalledWith('high'); + expect(result).toEqual({ + subtype: 'set_effort', + effort: 'high', + applied: true, + }); + }); + + it('returns applied=false when thinking is disabled (read-back mismatch)', async () => { + const context = createContext(); + ( + context.config.getReasoningEffort as ReturnType + ).mockReturnValue('medium'); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'set_effort', effort: 'high' }, + 'effort-2', + ); + + expect(result).toEqual({ + subtype: 'set_effort', + effort: 'high', + applied: false, + }); + }); + + it('rejects invalid effort value', async () => { + const controller = new SystemController( + createContext(), + createRegistry(), + 'SystemController', + ); + + await expect( + controller.handleRequest( + { subtype: 'set_effort', effort: 'banana' }, + 'effort-3', + ), + ).rejects.toThrow('Invalid effort value'); + }); + + it('rejects empty effort string', async () => { + const controller = new SystemController( + createContext(), + createRegistry(), + 'SystemController', + ); + + await expect( + controller.handleRequest( + { subtype: 'set_effort', effort: ' ' }, + 'effort-4', + ), + ).rejects.toThrow('Invalid effort specified'); + }); + }); + + describe('get_available_models', () => { + it('returns models without exposing baseUrl or envKey', async () => { + const context = createContext(); + ( + context.config.getAvailableModels as ReturnType + ).mockReturnValue([ + { + id: 'qwen-max', + label: 'Qwen Max', + capabilities: { vision: true }, + contextWindowSize: 128000, + baseUrl: 'https://internal-proxy.corp/v1', + envKey: 'SECRET_API_KEY', + }, + ]); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'get_available_models' }, + 'models-1', + ); + + expect(result).toEqual({ + subtype: 'get_available_models', + models: [ + { + id: 'qwen-max', + label: 'Qwen Max', + capabilities: { vision: true }, + contextWindowSize: 128000, + }, + ], + }); + }); + + it('returns empty models list when none available', async () => { + const controller = new SystemController( + createContext(), + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'get_available_models' }, + 'models-2', + ); + + expect(result).toEqual({ + subtype: 'get_available_models', + models: [], + }); + }); + }); + + describe('get_usage_info', () => { + it('returns dashboard with subtype when range is provided', async () => { + const controller = new SystemController( + createContext(), + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'get_usage_info', range: 'week' }, + 'usage-1', + ); + + expect(result).toHaveProperty('subtype', 'get_usage_info'); + expect(result).toHaveProperty('generatedAt'); + expect(result).toHaveProperty('summary'); + }); + + it('returns dashboard without range filter', async () => { + const controller = new SystemController( + createContext(), + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'get_usage_info' }, + 'usage-2', + ); + + expect(result).toHaveProperty('subtype', 'get_usage_info'); + expect(result).toHaveProperty('generatedAt'); + }); + }); + + describe('initialize with effort', () => { + it('sets effort during initialize when provided', async () => { + const context = createContext(); + ( + context.config.getReasoningEffort as ReturnType + ).mockReturnValue('high'); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'initialize', effort: 'high' }, + 'init-effort-1', + ); + + expect(context.config.setReasoningEffort).toHaveBeenCalledWith('high'); + expect(result).toHaveProperty('subtype', 'initialize'); + expect(result).toHaveProperty('session_id', 'test-session-id'); + }); + + it('warns when effort not applied during initialize (thinking disabled)', async () => { + const context = createContext(); + ( + context.config.getReasoningEffort as ReturnType + ).mockReturnValue('medium'); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + const result = await controller.handleRequest( + { subtype: 'initialize', effort: 'high' }, + 'init-effort-2', + ); + + expect(context.config.setReasoningEffort).toHaveBeenCalledWith('high'); + expect(context.config.getReasoningEffort).toHaveBeenCalled(); + expect(result).toHaveProperty('subtype', 'initialize'); + }); + + it('rejects invalid effort during initialize', async () => { + const controller = new SystemController( + createContext(), + createRegistry(), + 'SystemController', + ); + + await expect( + controller.handleRequest( + { subtype: 'initialize', effort: 'banana' }, + 'init-effort-3', + ), + ).rejects.toThrow('Invalid effort value'); + }); + }); }); diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 1949e08198f..5f83dd99da0 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -18,6 +18,8 @@ import type { ControlRequestPayload, CLIControlInitializeRequest, CLIControlSetModelRequest, + CLIControlSetEffortRequest, + CLIControlGetUsageInfoRequest, CLIMcpServerConfig, CLIControlGetContextUsageRequest, } from '../../types.js'; @@ -26,6 +28,8 @@ import { createDebugLogger, MCPServerConfig, AuthProviderType, + normalizeReasoningEffort, + loadUsageDashboard, type MCPOAuthConfig, } from '@qwen-code/qwen-code-core'; @@ -70,6 +74,12 @@ export class SystemController extends BaseController { signal, ); + case 'set_effort': + return this.handleSetEffort( + payload as CLIControlSetEffortRequest, + signal, + ); + case 'supported_commands': return this.handleSupportedCommands(signal); @@ -79,6 +89,15 @@ export class SystemController extends BaseController { signal, ); + case 'get_available_models': + return this.handleGetAvailableModels(signal); + + case 'get_usage_info': + return this.handleGetUsageInfo( + payload as CLIControlGetUsageInfoRequest, + signal, + ); + default: throw new Error(`Unsupported request subtype in SystemController`); } @@ -153,6 +172,34 @@ export class SystemController extends BaseController { this.context.sdkCanUseToolTimeoutMs = canUseToolTimeout; } + if (payload.effort) { + const normalized = normalizeReasoningEffort(payload.effort); + if (normalized) { + try { + this.context.config.setReasoningEffort(normalized); + + if (this.context.config.getReasoningEffort() !== normalized) { + debugLogger.warn( + `[SystemController] Effort '${normalized}' was not applied (thinking may be disabled)`, + ); + } else { + debugLogger.info( + `[SystemController] Set reasoning effort to: ${normalized}`, + ); + } + } catch (error) { + debugLogger.error( + '[SystemController] Failed to set reasoning effort:', + error, + ); + } + } else { + throw new Error( + 'Invalid effort value. Supported: low, medium, high, xhigh, max', + ); + } + } + // Process SDK MCP servers if ( payload.sdkMcpServers && @@ -281,7 +328,12 @@ export class SystemController extends BaseController { can_set_permission_mode: typeof this.context.config.setApprovalMode === 'function', can_set_model: typeof this.context.config.setModel === 'function', + can_set_effort: + typeof this.context.config.setReasoningEffort === 'function', can_get_context_usage: true, + can_get_available_models: + typeof this.context.config.getAvailableModels === 'function', + can_get_usage_info: true, // SDK MCP servers are supported - messages routed through control plane can_handle_mcp_message: true, }; @@ -455,6 +507,130 @@ export class SystemController extends BaseController { } } + /** + * Handle set_effort request + * + * Sets the reasoning effort tier at runtime. + */ + private async handleSetEffort( + payload: CLIControlSetEffortRequest, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const effort = payload.effort; + if (typeof effort !== 'string' || effort.trim() === '') { + throw new Error('Invalid effort specified for set_effort request'); + } + + const normalized = normalizeReasoningEffort(effort); + if (!normalized) { + throw new Error( + 'Invalid effort value. Supported: low, medium, high, xhigh, max', + ); + } + + try { + this.context.config.setReasoningEffort(normalized); + + const applied = this.context.config.getReasoningEffort() === normalized; + + debugLogger.info( + `[SystemController] Reasoning effort set to: ${normalized} (applied: ${applied})`, + ); + + return { + subtype: 'set_effort', + effort: normalized, + applied, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to set effort'; + + debugLogger.error( + `[SystemController] Failed to set effort ${effort}:`, + error, + ); + + throw new Error(errorMessage); + } + } + + /** + * Handle get_available_models request + * + * Returns the list of models available for the current auth type. + */ + private async handleGetAvailableModels( + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + try { + const models = this.context.config + .getAvailableModels() + .map(({ id, label, capabilities, contextWindowSize }) => ({ + id, + label, + capabilities, + contextWindowSize, + })); + + return { + subtype: 'get_available_models', + models, + }; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to get available models'; + + debugLogger.error( + '[SystemController] Failed to get available models:', + error, + ); + + throw new Error(errorMessage); + } + } + + /** + * Handle get_usage_info request + * + * Returns usage dashboard data for the specified time range. + */ + private async handleGetUsageInfo( + payload: CLIControlGetUsageInfoRequest, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + try { + const range = payload.range; + const dashboard = await loadUsageDashboard(range ? { range } : undefined); + + return { + ...dashboard, + subtype: 'get_usage_info', + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to get usage info'; + + debugLogger.error('[SystemController] Failed to get usage info:', error); + + throw new Error(errorMessage); + } + } + /** * Handle supported_commands request * diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 4f8556eaebd..169170472e8 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -388,6 +388,11 @@ export interface CLIControlInitializeRequest { */ mcpServers?: Record; agents?: SubagentConfig[]; + /** + * Initial reasoning effort tier: 'low' | 'medium' | 'high' | 'xhigh' | 'max'. + * Applied at session start via config.setReasoningEffort(). + */ + effort?: string; } export interface CLIControlSetPermissionModeRequest { @@ -418,6 +423,20 @@ export interface CLIControlSetModelRequest { model: string; } +export interface CLIControlSetEffortRequest { + subtype: 'set_effort'; + effort: string; +} + +export interface CLIControlGetAvailableModelsRequest { + subtype: 'get_available_models'; +} + +export interface CLIControlGetUsageInfoRequest { + subtype: 'get_usage_info'; + range?: 'today' | 'week' | 'month' | 'all'; +} + export interface CLIControlMcpStatusRequest { subtype: 'mcp_server_status'; } @@ -440,9 +459,12 @@ export type ControlRequestPayload = | CLIHookCallbackRequest | CLIControlMcpMessageRequest | CLIControlSetModelRequest + | CLIControlSetEffortRequest | CLIControlMcpStatusRequest | CLIControlSupportedCommandsRequest - | CLIControlGetContextUsageRequest; + | CLIControlGetContextUsageRequest + | CLIControlGetAvailableModelsRequest + | CLIControlGetUsageInfoRequest; export interface CLIControlRequest { type: 'control_request'; diff --git a/packages/sdk-python/src/qwen_code_sdk/__init__.py b/packages/sdk-python/src/qwen_code_sdk/__init__.py index b2de65ad210..aed3ef40d2f 100644 --- a/packages/sdk-python/src/qwen_code_sdk/__init__.py +++ b/packages/sdk-python/src/qwen_code_sdk/__init__.py @@ -42,6 +42,7 @@ AuthType, CanUseTool, CanUseToolContext, + Effort, PermissionAllowResult, PermissionDenyResult, PermissionMode, @@ -70,6 +71,7 @@ def query_sync( "CanUseToolContext", "ContentBlock", "ControlRequestTimeoutError", + "Effort", "PermissionAllowResult", "PermissionDenyResult", "PermissionMode", diff --git a/packages/sdk-python/src/qwen_code_sdk/protocol.py b/packages/sdk-python/src/qwen_code_sdk/protocol.py index 7e5e50b701f..51a751ae865 100644 --- a/packages/sdk-python/src/qwen_code_sdk/protocol.py +++ b/packages/sdk-python/src/qwen_code_sdk/protocol.py @@ -218,6 +218,7 @@ class CLIControlInitializeRequest(TypedDict): subtype: Literal["initialize"] hooks: NotRequired[Any] mcpServers: NotRequired[dict[str, dict[str, Any]]] + effort: NotRequired[str] class CLIControlSetPermissionModeRequest(TypedDict): @@ -238,6 +239,25 @@ class CLIControlSupportedCommandsRequest(TypedDict): subtype: Literal["supported_commands"] +class CLIControlGetContextUsageRequest(TypedDict): + subtype: Literal["get_context_usage"] + show_details: NotRequired[bool] + + +class CLIControlSetEffortRequest(TypedDict): + subtype: Literal["set_effort"] + effort: str + + +class CLIControlGetAvailableModelsRequest(TypedDict): + subtype: Literal["get_available_models"] + + +class CLIControlGetUsageInfoRequest(TypedDict): + subtype: Literal["get_usage_info"] + range: NotRequired[Literal["today", "week", "month", "all"]] + + ControlRequestPayload: TypeAlias = ( CLIControlInterruptRequest | CLIControlPermissionRequest @@ -246,6 +266,10 @@ class CLIControlSupportedCommandsRequest(TypedDict): | CLIControlSetModelRequest | CLIControlMcpStatusRequest | CLIControlSupportedCommandsRequest + | CLIControlGetContextUsageRequest + | CLIControlSetEffortRequest + | CLIControlGetAvailableModelsRequest + | CLIControlGetUsageInfoRequest | dict[str, Any] ) diff --git a/packages/sdk-python/src/qwen_code_sdk/query.py b/packages/sdk-python/src/qwen_code_sdk/query.py index 9bef8578145..ae6c2af6710 100644 --- a/packages/sdk-python/src/qwen_code_sdk/query.py +++ b/packages/sdk-python/src/qwen_code_sdk/query.py @@ -7,7 +7,7 @@ from collections.abc import AsyncIterable, Mapping, MutableMapping from dataclasses import dataclass, replace from types import TracebackType -from typing import Any, cast +from typing import Any, Literal, cast from uuid import uuid4 from .errors import AbortError, ControlRequestTimeoutError @@ -29,6 +29,7 @@ from .transport import ProcessTransport from .types import ( CanUseToolContext, + Effort, PermissionDenyResult, QueryOptions, QueryOptionsDict, @@ -110,6 +111,8 @@ async def _ensure_started(self) -> None: async def _initialize(self) -> None: try: payload: dict[str, Any] = {"hooks": None} + if self._options.effort: + payload["effort"] = self._options.effort await self._send_control_request("initialize", payload) except Exception as exc: await self._finish_with_error(exc) @@ -482,6 +485,35 @@ async def mcp_server_status(self) -> dict[str, Any] | None: await self._ensure_started() return await self._send_control_request("mcp_server_status") + async def set_effort(self, effort: Effort) -> bool: + await self._ensure_started() + response = await self._send_control_request("set_effort", {"effort": effort}) + if response is None: + return False + return bool(response.get("applied", False)) + + async def get_available_models(self) -> dict[str, Any] | None: + await self._ensure_started() + return await self._send_control_request("get_available_models") + + async def get_context_usage( + self, show_details: bool = False + ) -> dict[str, Any] | None: + await self._ensure_started() + return await self._send_control_request( + "get_context_usage", {"show_details": show_details} + ) + + async def get_usage_info( + self, + time_range: Literal["today", "week", "month", "all"] | None = None, + ) -> dict[str, Any] | None: + await self._ensure_started() + data: dict[str, Any] = {} + if time_range is not None: + data["range"] = time_range + return await self._send_control_request("get_usage_info", data) + @property def control_request_timeout(self) -> float: return self._options.timeout.control_request diff --git a/packages/sdk-python/src/qwen_code_sdk/types.py b/packages/sdk-python/src/qwen_code_sdk/types.py index 3d8ec72038e..cdc3d18539f 100644 --- a/packages/sdk-python/src/qwen_code_sdk/types.py +++ b/packages/sdk-python/src/qwen_code_sdk/types.py @@ -23,6 +23,7 @@ "gemini", "vertex-ai", ] +Effort: TypeAlias = Literal["low", "medium", "high", "xhigh", "max"] class PermissionSuggestion(TypedDict): @@ -114,6 +115,7 @@ class QueryOptionsDict(TypedDict, total=False): timeout: TimeoutOptionsDict mcp_servers: dict[str, dict[str, Any]] stderr: Callable[[str], None] + effort: Effort @dataclass @@ -139,6 +141,7 @@ class QueryOptions: timeout: TimeoutOptions = TimeoutOptions() mcp_servers: dict[str, dict[str, Any]] | None = None stderr: Callable[[str], None] | None = None + effort: Effort | None = None @classmethod def from_mapping(cls, value: Mapping[str, Any] | None) -> QueryOptions: @@ -183,6 +186,10 @@ def from_mapping(cls, value: Mapping[str, Any] | None) -> QueryOptions: Callable[[str], None] | None, _as_optional_callable(data, "stderr"), ), + effort=cast( + Effort | None, + _as_optional_str(data, "effort"), + ), ) diff --git a/packages/sdk-python/src/qwen_code_sdk/validation.py b/packages/sdk-python/src/qwen_code_sdk/validation.py index f19fe4cfba6..e52431975c3 100644 --- a/packages/sdk-python/src/qwen_code_sdk/validation.py +++ b/packages/sdk-python/src/qwen_code_sdk/validation.py @@ -14,6 +14,7 @@ _VALID_PERMISSION_MODES = {"default", "plan", "auto-edit", "yolo"} _VALID_AUTH_TYPES = {"openai", "anthropic", "qwen-oauth", "gemini", "vertex-ai"} +_VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"} def validate_query_options(options: QueryOptions) -> None: @@ -32,6 +33,12 @@ def validate_query_options(options: QueryOptions) -> None: "Expected one of: openai, anthropic, qwen-oauth, gemini, vertex-ai." ) + if options.effort and options.effort not in _VALID_EFFORTS: + raise ValidationError( + f"Invalid effort: {options.effort!r}. " + "Expected one of: low, medium, high, xhigh, max." + ) + _validate_optional_callable(options.can_use_tool, _validate_can_use_tool_callable) _validate_optional_callable(options.stderr, _validate_stderr_callable) diff --git a/packages/sdk-python/tests/unit/test_query_core.py b/packages/sdk-python/tests/unit/test_query_core.py index 0dd8f3f6210..db7dc3e34ef 100644 --- a/packages/sdk-python/tests/unit/test_query_core.py +++ b/packages/sdk-python/tests/unit/test_query_core.py @@ -556,6 +556,151 @@ async def test_initialize_failure_no_unhandled_task_exception( assert task_warnings == [] +@pytest.mark.asyncio +async def test_set_effort_sends_control_request() -> None: + transport = FakeTransport() + query = await _start_query(transport) + + task = asyncio.create_task(query.set_effort("high")) + request = await _wait_for_request(transport, "set_effort") + + assert request["request"]["effort"] == "high" + + transport.push( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": request["request_id"], + "response": { + "subtype": "set_effort", + "effort": "high", + "applied": True, + }, + }, + } + ) + + result = await task + assert result is True + await query.close() + + +@pytest.mark.asyncio +async def test_get_available_models_sends_control_request() -> None: + transport = FakeTransport() + query = await _start_query(transport) + + task = asyncio.create_task(query.get_available_models()) + request = await _wait_for_request(transport, "get_available_models") + + assert request["request"]["subtype"] == "get_available_models" + + models = [{"id": "qwen-max", "label": "Qwen Max"}] + transport.push( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": request["request_id"], + "response": {"subtype": "get_available_models", "models": models}, + }, + } + ) + + result = await task + assert result == {"subtype": "get_available_models", "models": models} + await query.close() + + +@pytest.mark.asyncio +async def test_get_context_usage_sends_control_request() -> None: + transport = FakeTransport() + query = await _start_query(transport) + + task = asyncio.create_task(query.get_context_usage(show_details=True)) + request = await _wait_for_request(transport, "get_context_usage") + + assert request["request"]["show_details"] is True + + context_data = {"used_tokens": 1000, "total_tokens": 200000} + transport.push( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": request["request_id"], + "response": context_data, + }, + } + ) + + result = await task + assert result == context_data + await query.close() + + +@pytest.mark.asyncio +async def test_get_usage_info_sends_control_request() -> None: + transport = FakeTransport() + query = await _start_query(transport) + + task = asyncio.create_task(query.get_usage_info(time_range="week")) + request = await _wait_for_request(transport, "get_usage_info") + + assert request["request"]["range"] == "week" + + usage_data = {"range": "week", "summary": {"totalTokens": 50000}} + transport.push( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": request["request_id"], + "response": usage_data, + }, + } + ) + + result = await task + assert result == usage_data + await query.close() + + +@pytest.mark.asyncio +async def test_initialize_sends_effort() -> None: + transport = FakeTransport() + query = Query( + transport=transport, # type: ignore[arg-type] + options=QueryOptions( + effort="high", + timeout=TimeoutOptions( + can_use_tool=0.05, + control_request=0.05, + stream_close=0.05, + ), + ), + prompt="hello", + session_id=VALID_UUID, + ) + await query._ensure_started() + + init_request = await _wait_for_request(transport, "initialize") + assert init_request["request"]["effort"] == "high" + + transport.push( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": init_request["request_id"], + "response": {}, + }, + } + ) + await query.close() + + @pytest.mark.asyncio async def test_async_context_manager_closes_on_exit() -> None: transport = FakeTransport() diff --git a/packages/sdk-python/tests/unit/test_validation.py b/packages/sdk-python/tests/unit/test_validation.py index dd85ef2e9b0..254133a340f 100644 --- a/packages/sdk-python/tests/unit/test_validation.py +++ b/packages/sdk-python/tests/unit/test_validation.py @@ -172,3 +172,13 @@ def test_rejects_mcp_servers() -> None: validate_query_options( QueryOptions(mcp_servers={"my-server": {"command": "node", "args": []}}) ) + + +def test_rejects_invalid_effort() -> None: + with pytest.raises(ValidationError, match="Invalid effort"): + validate_query_options(QueryOptions(effort="invalid")) # type: ignore[arg-type] + + +def test_accepts_valid_effort() -> None: + for effort in ("low", "medium", "high", "xhigh", "max"): + validate_query_options(QueryOptions(effort=effort)) # type: ignore[arg-type] diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index f4c006b4c67..6b8c7bc8b1c 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -306,6 +306,7 @@ export class Query implements AsyncIterable { ? mcpServersForCli : undefined, agents: this.options.agents, + effort: this.options.effort, }); logger.info('Query initialized successfully'); } catch (error) { @@ -983,6 +984,47 @@ export class Query implements AsyncIterable { }); } + /** + * Set the reasoning effort tier at runtime. + * + * @param effort - One of 'low', 'medium', 'high', 'xhigh', 'max' + * @returns `true` if the effort was applied, `false` if it was a no-op (e.g. thinking disabled) + */ + async setEffort( + effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max', + ): Promise { + const response = await this.sendControlRequest( + ControlRequestType.SET_EFFORT, + { effort }, + ); + return Boolean((response as Record | null)?.applied); + } + + /** + * Get the list of models available for the current auth type. + * + * @returns Promise resolving to available models data + * @throws Error if query is closed + */ + async getAvailableModels(): Promise | null> { + return this.sendControlRequest(ControlRequestType.GET_AVAILABLE_MODELS); + } + + /** + * Get usage dashboard data from the CLI. + * + * @param range - Time range for usage data: 'today' (default), 'week', 'month', 'all' + * @returns Promise resolving to usage dashboard data + * @throws Error if query is closed + */ + async getUsageInfo( + range?: 'today' | 'week' | 'month' | 'all', + ): Promise | null> { + return this.sendControlRequest(ControlRequestType.GET_USAGE_INFO, { + ...(range ? { range } : {}), + }); + } + /** * Get list of control commands supported by the CLI * diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 012b35bd497..97b765fbb32 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -357,6 +357,11 @@ export interface CLIControlInitializeRequest { */ mcpServers?: Record; agents?: SubagentConfig[]; + /** + * Initial reasoning effort tier: 'low' | 'medium' | 'high' | 'xhigh' | 'max'. + * Applied at session start via config.setReasoningEffort(). + */ + effort?: string; } export interface CLIControlSetPermissionModeRequest { @@ -400,6 +405,20 @@ export interface CLIControlGetContextUsageRequest { show_details?: boolean; } +export interface CLIControlSetEffortRequest { + subtype: 'set_effort'; + effort: string; +} + +export interface CLIControlGetAvailableModelsRequest { + subtype: 'get_available_models'; +} + +export interface CLIControlGetUsageInfoRequest { + subtype: 'get_usage_info'; + range?: 'today' | 'week' | 'month' | 'all'; +} + export type ControlRequestPayload = | CLIControlInterruptRequest | CLIControlContinueLastTurnRequest @@ -411,7 +430,10 @@ export type ControlRequestPayload = | CLIControlSetModelRequest | CLIControlMcpStatusRequest | CLIControlSupportedCommandsRequest - | CLIControlGetContextUsageRequest; + | CLIControlGetContextUsageRequest + | CLIControlSetEffortRequest + | CLIControlGetAvailableModelsRequest + | CLIControlGetUsageInfoRequest; export interface CLIControlRequest { type: 'control_request'; @@ -604,8 +626,11 @@ export enum ControlRequestType { INTERRUPT = 'interrupt', CONTINUE_LAST_TURN = 'continue_last_turn', SET_MODEL = 'set_model', + SET_EFFORT = 'set_effort', SUPPORTED_COMMANDS = 'supported_commands', GET_CONTEXT_USAGE = 'get_context_usage', + GET_AVAILABLE_MODELS = 'get_available_models', + GET_USAGE_INFO = 'get_usage_info', // PermissionController requests CAN_USE_TOOL = 'can_use_tool', diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 702ea1c632d..f000811ab25 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -178,6 +178,7 @@ export const QueryOptionsSchema = z ), ) .optional(), + effort: z.enum(['low', 'medium', 'high', 'xhigh', 'max']).optional(), includePartialMessages: z.boolean().optional(), resume: z.string().optional(), sessionId: z.string().optional(), diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 3a7e26ee0aa..b12d7c27bef 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -442,6 +442,23 @@ export interface QueryOptions { */ agents?: SubagentConfig[]; + /** + * Initial reasoning effort tier applied at session start. + * + * Controls the depth of model reasoning/thinking. Higher tiers produce more + * thorough reasoning at the cost of latency and tokens. Provider adapters + * clamp the tier to what the active model supports. + * + * - `'low'`: Minimal reasoning, fastest responses + * - `'medium'`: Balanced reasoning and speed + * - `'high'`: More thorough reasoning + * - `'xhigh'`: Extended reasoning for complex tasks + * - `'max'`: Maximum reasoning depth + * + * Use {@link Query.setEffort} to change the tier at runtime. + */ + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** * Include partial messages in the response stream. * When true, the SDK will emit incomplete messages as they are being generated, diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 56c716351d1..4fa37a0dfe2 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -1270,6 +1270,124 @@ describe('Query', () => { await query.close(); }); + it('should provide setEffort() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + await respondToInitialize(transport, query); + + const setEffortPromise = query.setEffort('high'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setEffortMsg = findControlRequest( + messages, + ControlRequestType.SET_EFFORT, + ); + expect(setEffortMsg).toBeDefined(); + }); + + const messages = transport.getAllWrittenMessages(); + const setEffortMsg = findControlRequest( + messages, + ControlRequestType.SET_EFFORT, + )!; + + expect((setEffortMsg.request as Record).effort).toBe( + 'high', + ); + + transport.simulateMessage( + createControlResponse(setEffortMsg.request_id, true, { + subtype: 'set_effort', + effort: 'high', + applied: true, + }), + ); + + const result = await setEffortPromise; + expect(result).toBe(true); + + await query.close(); + }); + + it('should provide getAvailableModels() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + await respondToInitialize(transport, query); + + const modelsPromise = query.getAvailableModels(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const modelsMsg = findControlRequest( + messages, + ControlRequestType.GET_AVAILABLE_MODELS, + ); + expect(modelsMsg).toBeDefined(); + }); + + const messages = transport.getAllWrittenMessages(); + const modelsMsg = findControlRequest( + messages, + ControlRequestType.GET_AVAILABLE_MODELS, + )!; + + transport.simulateMessage( + createControlResponse(modelsMsg.request_id, true, { + subtype: 'get_available_models', + models: [{ id: 'qwen-max', label: 'Qwen Max' }], + }), + ); + + const result = await modelsPromise; + expect(result).toMatchObject({ + subtype: 'get_available_models', + models: [{ id: 'qwen-max', label: 'Qwen Max' }], + }); + + await query.close(); + }); + + it('should provide getUsageInfo() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + await respondToInitialize(transport, query); + + const usagePromise = query.getUsageInfo('week'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const usageMsg = findControlRequest( + messages, + ControlRequestType.GET_USAGE_INFO, + ); + expect(usageMsg).toBeDefined(); + }); + + const messages = transport.getAllWrittenMessages(); + const usageMsg = findControlRequest( + messages, + ControlRequestType.GET_USAGE_INFO, + )!; + + expect((usageMsg.request as Record).range).toBe('week'); + + transport.simulateMessage( + createControlResponse(usageMsg.request_id, true, { + range: 'week', + summary: { totalTokens: 50000 }, + }), + ); + + const result = await usagePromise; + expect(result).toMatchObject({ + range: 'week', + summary: { totalTokens: 50000 }, + }); + + await query.close(); + }); + it('should throw if methods called on closed query', async () => { const query = new Query(transport, { cwd: '/test' }); await respondToInitialize(transport, query); @@ -1285,6 +1403,27 @@ describe('Query', () => { ); await expect(query.mcpServerStatus()).rejects.toThrow('Query is closed'); await expect(query.getContextUsage()).rejects.toThrow('Query is closed'); + await expect(query.setEffort('high')).rejects.toThrow('Query is closed'); + await expect(query.getAvailableModels()).rejects.toThrow( + 'Query is closed', + ); + await expect(query.getUsageInfo()).rejects.toThrow('Query is closed'); + }); + + it('should send effort in initialize payload when provided in options', async () => { + const query = new Query(transport, { cwd: '/test', effort: 'high' }); + + await respondToInitialize(transport, query); + + const messages = transport.getAllWrittenMessages(); + const initMsg = findControlRequest( + messages, + ControlRequestType.INITIALIZE, + ); + expect(initMsg).toBeDefined(); + expect((initMsg!.request as Record).effort).toBe('high'); + + await query.close(); }); });