diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 9294501..1ef7a33 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -181,7 +181,7 @@ Missing: - [x] Release notes checking from `setup.ts` (local CHANGELOG.md, no network/cache layer) - [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) - [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) -- [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) +- [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) — partial: HOOK_EVENTS, EXIT_REASONS, ModelUsage, ThinkingConfig, MCP server configs, JsonSchemaOutputFormat ported in `src/sdk_core_types.py` - [x] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) ## 3. Prompt Assembly diff --git a/src/sdk_core_types.py b/src/sdk_core_types.py new file mode 100644 index 0000000..f2d8e18 --- /dev/null +++ b/src/sdk_core_types.py @@ -0,0 +1,319 @@ +"""Foundational SDK types — Python port of entrypoints/sdk/coreTypes.ts. + +Mirrors the public constants and a starter set of dataclasses for the +serializable types defined in `entrypoints/sdk/coreSchemas.ts`. The full +schema set (1800+ lines of Zod) is too large to port wholesale; this file +covers the foundational pieces other modules already reference and gives +later slices a place to extend. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + + +# --------------------------------------------------------------------------- +# Const arrays (HOOK_EVENTS, EXIT_REASONS) — mirror coreTypes.ts top-level +# --------------------------------------------------------------------------- + +HOOK_EVENTS: tuple[str, ...] = ( + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification', + 'UserPromptSubmit', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'StopFailure', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'PermissionRequest', + 'PermissionDenied', + 'Setup', + 'TeammateIdle', + 'TaskCreated', + 'TaskCompleted', + 'Elicitation', + 'ElicitationResult', + 'ConfigChange', + 'WorktreeCreate', + 'WorktreeRemove', + 'InstructionsLoaded', + 'CwdChanged', + 'FileChanged', +) + +EXIT_REASONS: tuple[str, ...] = ( + 'clear', + 'resume', + 'logout', + 'prompt_input_exit', + 'other', + 'bypass_permissions_disabled', +) + +API_KEY_SOURCES: tuple[str, ...] = ('user', 'project', 'org', 'temporary', 'oauth') +CONFIG_SCOPES: tuple[str, ...] = ('local', 'user', 'project') +SDK_BETAS: tuple[str, ...] = ('context-1m-2025-08-07',) + + +ApiKeySource = Literal['user', 'project', 'org', 'temporary', 'oauth'] +ConfigScope = Literal['local', 'user', 'project'] +SdkBeta = Literal['context-1m-2025-08-07'] + + +# --------------------------------------------------------------------------- +# Usage / model +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ModelUsage: + input_tokens: int + output_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + web_search_requests: int + cost_usd: float + context_window: int + max_output_tokens: int + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'ModelUsage': + return cls( + input_tokens=int(data['inputTokens']), + output_tokens=int(data['outputTokens']), + cache_read_input_tokens=int(data['cacheReadInputTokens']), + cache_creation_input_tokens=int(data['cacheCreationInputTokens']), + web_search_requests=int(data['webSearchRequests']), + cost_usd=float(data['costUSD']), + context_window=int(data['contextWindow']), + max_output_tokens=int(data['maxOutputTokens']), + ) + + def to_dict(self) -> dict[str, Any]: + return { + 'inputTokens': self.input_tokens, + 'outputTokens': self.output_tokens, + 'cacheReadInputTokens': self.cache_read_input_tokens, + 'cacheCreationInputTokens': self.cache_creation_input_tokens, + 'webSearchRequests': self.web_search_requests, + 'costUSD': self.cost_usd, + 'contextWindow': self.context_window, + 'maxOutputTokens': self.max_output_tokens, + } + + +# --------------------------------------------------------------------------- +# Thinking config +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ThinkingAdaptive: + type: Literal['adaptive'] = 'adaptive' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type} + + +@dataclass(frozen=True) +class ThinkingEnabled: + budget_tokens: int | None = None + type: Literal['enabled'] = 'enabled' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type} + if self.budget_tokens is not None: + out['budgetTokens'] = self.budget_tokens + return out + + +@dataclass(frozen=True) +class ThinkingDisabled: + type: Literal['disabled'] = 'disabled' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type} + + +ThinkingConfig = ThinkingAdaptive | ThinkingEnabled | ThinkingDisabled + + +def thinking_config_from_dict(data: dict[str, Any]) -> ThinkingConfig: + kind = data.get('type') + if kind == 'adaptive': + return ThinkingAdaptive() + if kind == 'enabled': + budget = data.get('budgetTokens') + return ThinkingEnabled( + budget_tokens=int(budget) if budget is not None else None, + ) + if kind == 'disabled': + return ThinkingDisabled() + raise ValueError(f'unknown thinking config type: {kind!r}') + + +# --------------------------------------------------------------------------- +# MCP server configs +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class McpStdioServerConfig: + command: str + args: list[str] | None = None + env: dict[str, str] | None = None + type: Literal['stdio'] = 'stdio' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type, 'command': self.command} + if self.args is not None: + out['args'] = list(self.args) + if self.env is not None: + out['env'] = dict(self.env) + return out + + +@dataclass(frozen=True) +class McpSSEServerConfig: + url: str + headers: dict[str, str] | None = None + type: Literal['sse'] = 'sse' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type, 'url': self.url} + if self.headers is not None: + out['headers'] = dict(self.headers) + return out + + +@dataclass(frozen=True) +class McpHttpServerConfig: + url: str + headers: dict[str, str] | None = None + type: Literal['http'] = 'http' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type, 'url': self.url} + if self.headers is not None: + out['headers'] = dict(self.headers) + return out + + +@dataclass(frozen=True) +class McpSdkServerConfig: + name: str + type: Literal['sdk'] = 'sdk' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type, 'name': self.name} + + +@dataclass(frozen=True) +class McpClaudeAIProxyServerConfig: + url: str + id: str + type: Literal['claudeai-proxy'] = 'claudeai-proxy' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type, 'url': self.url, 'id': self.id} + + +McpServerConfigForProcessTransport = ( + McpStdioServerConfig + | McpSSEServerConfig + | McpHttpServerConfig + | McpSdkServerConfig +) +McpServerStatusConfig = ( + McpServerConfigForProcessTransport | McpClaudeAIProxyServerConfig +) + + +def mcp_server_config_from_dict(data: dict[str, Any]) -> McpServerStatusConfig: + """Discriminate by `type` and route to the matching dataclass.""" + raw_type = data.get('type') + if raw_type is None or raw_type == 'stdio': + command = data.get('command') + if not isinstance(command, str): + raise ValueError('stdio server config requires a string command') + return McpStdioServerConfig( + command=command, + args=list(data['args']) if isinstance(data.get('args'), list) else None, + env=dict(data['env']) if isinstance(data.get('env'), dict) else None, + ) + if raw_type == 'sse': + return McpSSEServerConfig( + url=str(data['url']), + headers=dict(data['headers']) if isinstance(data.get('headers'), dict) else None, + ) + if raw_type == 'http': + return McpHttpServerConfig( + url=str(data['url']), + headers=dict(data['headers']) if isinstance(data.get('headers'), dict) else None, + ) + if raw_type == 'sdk': + return McpSdkServerConfig(name=str(data['name'])) + if raw_type == 'claudeai-proxy': + return McpClaudeAIProxyServerConfig( + url=str(data['url']), + id=str(data['id']), + ) + raise ValueError(f'unknown MCP server config type: {raw_type!r}') + + +# --------------------------------------------------------------------------- +# Output format +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class JsonSchemaOutputFormat: + schema: dict[str, Any] = field(default_factory=dict) + type: Literal['json_schema'] = 'json_schema' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type, 'schema': dict(self.schema)} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'JsonSchemaOutputFormat': + if data.get('type') != 'json_schema': + raise ValueError( + f'expected output format type "json_schema", got {data.get("type")!r}' + ) + schema = data.get('schema') + if not isinstance(schema, dict): + raise ValueError('json_schema output format requires a "schema" mapping') + return cls(schema=dict(schema)) + + +OutputFormat = JsonSchemaOutputFormat + + +__all__ = [ + 'HOOK_EVENTS', + 'EXIT_REASONS', + 'API_KEY_SOURCES', + 'CONFIG_SCOPES', + 'SDK_BETAS', + 'ApiKeySource', + 'ConfigScope', + 'SdkBeta', + 'ModelUsage', + 'ThinkingAdaptive', + 'ThinkingEnabled', + 'ThinkingDisabled', + 'ThinkingConfig', + 'thinking_config_from_dict', + 'McpStdioServerConfig', + 'McpSSEServerConfig', + 'McpHttpServerConfig', + 'McpSdkServerConfig', + 'McpClaudeAIProxyServerConfig', + 'McpServerConfigForProcessTransport', + 'McpServerStatusConfig', + 'mcp_server_config_from_dict', + 'JsonSchemaOutputFormat', + 'OutputFormat', +] diff --git a/tests/test_sdk_core_types.py b/tests/test_sdk_core_types.py new file mode 100644 index 0000000..596039b --- /dev/null +++ b/tests/test_sdk_core_types.py @@ -0,0 +1,175 @@ +"""Tests for SDK core types ported from entrypoints/sdk/coreTypes.ts.""" + +from __future__ import annotations + +import unittest + +from src.sdk_core_types import ( + API_KEY_SOURCES, + CONFIG_SCOPES, + EXIT_REASONS, + HOOK_EVENTS, + SDK_BETAS, + JsonSchemaOutputFormat, + McpClaudeAIProxyServerConfig, + McpHttpServerConfig, + McpSdkServerConfig, + McpSSEServerConfig, + McpStdioServerConfig, + ModelUsage, + ThinkingAdaptive, + ThinkingDisabled, + ThinkingEnabled, + mcp_server_config_from_dict, + thinking_config_from_dict, +) + + +class HookEventsTest(unittest.TestCase): + def test_includes_known_events(self) -> None: + for required in ( + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'SessionStart', + 'PreCompact', + 'WorktreeCreate', + 'CwdChanged', + 'FileChanged', + ): + self.assertIn(required, HOOK_EVENTS) + + def test_no_duplicates(self) -> None: + self.assertEqual(len(HOOK_EVENTS), len(set(HOOK_EVENTS))) + + +class ExitReasonsTest(unittest.TestCase): + def test_known_exit_reasons(self) -> None: + for required in ( + 'clear', 'resume', 'logout', 'prompt_input_exit', + 'other', 'bypass_permissions_disabled', + ): + self.assertIn(required, EXIT_REASONS) + + +class EnumLiteralsTest(unittest.TestCase): + def test_api_key_sources(self) -> None: + self.assertEqual(set(API_KEY_SOURCES), {'user', 'project', 'org', 'temporary', 'oauth'}) + + def test_config_scopes(self) -> None: + self.assertEqual(set(CONFIG_SCOPES), {'local', 'user', 'project'}) + + def test_sdk_betas_known(self) -> None: + self.assertIn('context-1m-2025-08-07', SDK_BETAS) + + +class ModelUsageTest(unittest.TestCase): + def test_round_trip(self) -> None: + raw = { + 'inputTokens': 100, 'outputTokens': 50, + 'cacheReadInputTokens': 10, 'cacheCreationInputTokens': 5, + 'webSearchRequests': 0, 'costUSD': 0.001, + 'contextWindow': 200000, 'maxOutputTokens': 8192, + } + usage = ModelUsage.from_dict(raw) + self.assertEqual(usage.input_tokens, 100) + self.assertEqual(usage.cost_usd, 0.001) + self.assertEqual(usage.to_dict(), raw) + + +class ThinkingConfigTest(unittest.TestCase): + def test_adaptive(self) -> None: + cfg = thinking_config_from_dict({'type': 'adaptive'}) + self.assertIsInstance(cfg, ThinkingAdaptive) + self.assertEqual(cfg.to_dict(), {'type': 'adaptive'}) + + def test_enabled_with_budget(self) -> None: + cfg = thinking_config_from_dict({'type': 'enabled', 'budgetTokens': 1024}) + self.assertIsInstance(cfg, ThinkingEnabled) + self.assertEqual(cfg.to_dict(), {'type': 'enabled', 'budgetTokens': 1024}) + + def test_enabled_without_budget(self) -> None: + cfg = thinking_config_from_dict({'type': 'enabled'}) + self.assertEqual(cfg.to_dict(), {'type': 'enabled'}) + + def test_disabled(self) -> None: + cfg = thinking_config_from_dict({'type': 'disabled'}) + self.assertIsInstance(cfg, ThinkingDisabled) + + def test_unknown_type_raises(self) -> None: + with self.assertRaises(ValueError): + thinking_config_from_dict({'type': 'something-else'}) + + +class McpServerConfigTest(unittest.TestCase): + def test_stdio_default_type(self) -> None: + cfg = mcp_server_config_from_dict({'command': 'npx'}) + self.assertIsInstance(cfg, McpStdioServerConfig) + self.assertEqual(cfg.command, 'npx') + + def test_stdio_with_args_env(self) -> None: + cfg = mcp_server_config_from_dict({ + 'type': 'stdio', + 'command': 'node', + 'args': ['./mcp.js'], + 'env': {'TOKEN': 'xyz'}, + }) + out = cfg.to_dict() + self.assertEqual(out['command'], 'node') + self.assertEqual(out['args'], ['./mcp.js']) + self.assertEqual(out['env'], {'TOKEN': 'xyz'}) + + def test_sse(self) -> None: + cfg = mcp_server_config_from_dict({'type': 'sse', 'url': 'https://x.example'}) + self.assertIsInstance(cfg, McpSSEServerConfig) + + def test_http_with_headers(self) -> None: + cfg = mcp_server_config_from_dict({ + 'type': 'http', + 'url': 'https://x.example', + 'headers': {'Auth': 'Bearer 1'}, + }) + self.assertIsInstance(cfg, McpHttpServerConfig) + self.assertEqual(cfg.headers, {'Auth': 'Bearer 1'}) + + def test_sdk(self) -> None: + cfg = mcp_server_config_from_dict({'type': 'sdk', 'name': 'my-sdk'}) + self.assertIsInstance(cfg, McpSdkServerConfig) + + def test_claudeai_proxy(self) -> None: + cfg = mcp_server_config_from_dict({ + 'type': 'claudeai-proxy', + 'url': 'https://claude.ai/p', + 'id': 'abc', + }) + self.assertIsInstance(cfg, McpClaudeAIProxyServerConfig) + + def test_unknown_type_raises(self) -> None: + with self.assertRaises(ValueError): + mcp_server_config_from_dict({'type': 'mystery'}) + + def test_stdio_requires_command(self) -> None: + with self.assertRaises(ValueError): + mcp_server_config_from_dict({'type': 'stdio'}) + + +class JsonSchemaOutputFormatTest(unittest.TestCase): + def test_round_trip(self) -> None: + fmt = JsonSchemaOutputFormat.from_dict({ + 'type': 'json_schema', + 'schema': {'type': 'object', 'properties': {}}, + }) + self.assertEqual(fmt.schema['type'], 'object') + self.assertEqual(fmt.to_dict()['type'], 'json_schema') + + def test_rejects_wrong_type(self) -> None: + with self.assertRaises(ValueError): + JsonSchemaOutputFormat.from_dict({'type': 'text', 'schema': {}}) + + def test_requires_schema_mapping(self) -> None: + with self.assertRaises(ValueError): + JsonSchemaOutputFormat.from_dict({'type': 'json_schema'}) + + +if __name__ == '__main__': + unittest.main()