bone-agent 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -201
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -144
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -50
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
package/src/exceptions.py DELETED
@@ -1,79 +0,0 @@
1
- """Custom exception hierarchy for bone-agent."""
2
-
3
- class BoneAgentError(Exception):
4
- """Base exception for all bone-agent application errors.
5
-
6
- All custom exceptions should inherit from this class.
7
- Provides consistent error handling and allows catching
8
- all bone-agent-specific errors with a single except clause.
9
- """
10
- def __init__(self, message: str, *, details: dict = None):
11
- """Initialize exception with optional details.
12
-
13
- Args:
14
- message: Human-readable error message
15
- details: Optional dictionary with additional error context
16
- """
17
- super().__init__(message)
18
- self.details = details or {}
19
-
20
- def __str__(self):
21
- base_msg = super().__str__()
22
- if self.details:
23
- # Skip multi-line values from the compact string representation
24
- # (they're included separately in detailed error formatting)
25
- single_line_details = {k: v for k, v in self.details.items() if "\n" not in str(v)}
26
- if single_line_details:
27
- details_str = ", ".join(f"{k}={v}" for k, v in single_line_details.items())
28
- return f"{base_msg} ({details_str})"
29
- return base_msg
30
-
31
-
32
- class ConfigurationError(BoneAgentError):
33
- """Raised when configuration is invalid, missing, or cannot be loaded."""
34
- pass
35
-
36
-
37
- class LLMError(BoneAgentError):
38
- """Raised when LLM API communication fails or returns unexpected data."""
39
- pass
40
-
41
-
42
- class LLMConnectionError(LLMError):
43
- """Raised when network connection to LLM provider fails."""
44
- pass
45
-
46
-
47
- class LLMResponseError(LLMError):
48
- """Raised when LLM response is malformed or invalid."""
49
- pass
50
-
51
-
52
- class ToolExecutionError(BoneAgentError):
53
- """Raised when tool execution fails."""
54
- pass
55
-
56
-
57
- class CommandExecutionError(ToolExecutionError):
58
- """Raised when shell command execution fails."""
59
- pass
60
-
61
-
62
- class FileEditError(ToolExecutionError):
63
- """Raised when file edit operation fails."""
64
- pass
65
-
66
-
67
- class ValidationError(BoneAgentError):
68
- """Raised when input validation fails."""
69
- pass
70
-
71
-
72
- class PathValidationError(ValidationError):
73
- """Raised when path validation fails (blocked by gitignore, etc.)."""
74
- pass
75
-
76
-
77
- class CommandValidationError(ValidationError):
78
- """Raised when command validation fails (dangerous operators, etc.)."""
79
- pass
@@ -1 +0,0 @@
1
- """LLM integration layer for bone-agent."""
package/src/llm/client.py DELETED
@@ -1,176 +0,0 @@
1
- """LLM client for making API requests to various providers."""
2
-
3
- import logging
4
-
5
- import requests
6
- from llm import config as config_module
7
- from llm.config import PROVIDER_REGISTRY, get_provider_config, get_providers
8
- from llm.providers import get_handler
9
- from exceptions import LLMConnectionError, LLMResponseError, ConfigurationError
10
- from utils.validation import validate_api_url
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
- # Connection/read timeouts (seconds)
15
- _CONNECT_TIMEOUT = 10
16
- _READ_TIMEOUT = 120
17
-
18
-
19
- class StreamWrapper:
20
- """Wraps streaming response generator with cleanup capability."""
21
-
22
- def __init__(self, response, generator):
23
- self._response = response
24
- self._generator = generator
25
-
26
- def __iter__(self):
27
- return self
28
-
29
- def __next__(self):
30
- return next(self._generator)
31
-
32
- def close(self):
33
- """Close underlying HTTP connection."""
34
- if self._response:
35
- self._response.close()
36
-
37
-
38
- class LLMClient:
39
- def __init__(self, provider=None):
40
- """Initialize LLM client.
41
-
42
- Args:
43
- provider: Provider name. If None, uses global LLM_PROVIDER from config.
44
- """
45
- self.provider = provider or config_module.LLM_PROVIDER
46
- self.handler = get_handler(self.provider)
47
- self.config = self._get_provider_config()
48
-
49
- @property
50
- def model(self) -> str:
51
- """Return configured model name, if any."""
52
- return str(self.config.get("payload", {}).get("model") or "")
53
-
54
- def _get_provider_config(self):
55
- """Build provider config from PROVIDER_REGISTRY."""
56
- registry = get_provider_config(self.provider)
57
- if not registry:
58
- raise ConfigurationError(f"Unknown provider: {self.provider}")
59
-
60
- # Build headers using handler
61
- headers = self.handler.build_headers(registry)
62
-
63
- # Build payload with model name
64
- payload = {}
65
- model_name = registry.get("api_model") or registry.get("model")
66
- if model_name:
67
- payload["model"] = model_name
68
-
69
- url = f"{registry['api_base']}{registry['endpoint']}"
70
- valid, err = validate_api_url(url)
71
- if not valid:
72
- raise ConfigurationError(
73
- f"Insecure API endpoint for provider '{self.provider}': {err}"
74
- )
75
-
76
- return {
77
- "url": url,
78
- "headers": headers,
79
- "payload": payload,
80
- "error_prefix": registry["error_prefix"],
81
- "registry": registry
82
- }
83
-
84
- def chat_completion(self, messages, stream=True, tools=None):
85
- """Make a chat completion request.
86
-
87
- Args:
88
- messages: List of message dicts
89
- stream: Whether to stream the response
90
- tools: Optional list of tool definitions
91
-
92
- Returns:
93
- StreamWrapper if stream=True, else response dict
94
- """
95
- config = self.config
96
- registry = config["registry"]
97
-
98
- # Build payload using handler
99
- payload = self.handler.build_payload(registry, messages, tools, stream)
100
-
101
- try:
102
- response = requests.post(
103
- config["url"],
104
- headers=config["headers"],
105
- json=payload,
106
- stream=stream,
107
- verify=True,
108
- timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
109
- )
110
-
111
- # For better debugging, include response text on 4xx errors
112
- if not response.ok:
113
- error_details = response.text if response.text else str(response.status_code)
114
- raise LLMConnectionError(
115
- f"Error communicating with {config['error_prefix']}",
116
- details={
117
- "provider": self.provider,
118
- "original_error": error_details,
119
- "status_code": response.status_code,
120
- }
121
- )
122
- response.raise_for_status()
123
-
124
- if stream:
125
- return StreamWrapper(
126
- response,
127
- self.handler.parse_stream(response)
128
- )
129
- else:
130
- try:
131
- response_json = response.json()
132
- return self.handler.parse_response(response_json)
133
- except ValueError:
134
- if getattr(self.handler, "supports_sse_response_fallback", False):
135
- return self.handler.parse_sse_response(response.text)
136
- raise
137
-
138
- except requests.exceptions.RequestException as e:
139
- raise LLMConnectionError(
140
- f"Error communicating with {config['error_prefix']}",
141
- details={"provider": self.provider, "original_error": str(e)}
142
- )
143
-
144
- def switch_provider(self, new_provider):
145
- """Switch to a different provider.
146
-
147
- Args:
148
- new_provider: Name of the provider to switch to.
149
-
150
- Returns:
151
- True if successful, False if provider not found.
152
- """
153
- if new_provider in get_providers():
154
- self.provider = new_provider
155
- self.handler = get_handler(new_provider)
156
- self.config = self._get_provider_config()
157
- return True
158
- return False
159
-
160
- def sync_provider_from_config(self):
161
- """Sync this client's provider and config with the current config.
162
-
163
- This should be called after config is reloaded from disk.
164
- """
165
- current_provider = config_module.LLM_PROVIDER
166
- if self.provider != current_provider:
167
- self.provider = current_provider
168
- self.handler = get_handler(current_provider)
169
- self.config = self._get_provider_config()
170
- return True
171
- # Even if provider hasn't changed, config values (model, api_key) might have
172
- self.config = self._get_provider_config()
173
- return False
174
-
175
-
176
- __all__ = ['LLMClient']
@@ -1,350 +0,0 @@
1
- """Codex provider adapter.
2
-
3
- Codex is intentionally isolated from the normal provider handlers because it is
4
- not a Chat Completions-compatible API. It targets the ChatGPT Codex Responses
5
- backend and adapts that protocol back into vmCode's OpenAI-style internal shape.
6
- """
7
-
8
- import copy
9
- import hashlib
10
- import json
11
- from typing import Any, Dict, Iterator, Optional
12
-
13
- import requests
14
-
15
- from exceptions import LLMResponseError
16
-
17
-
18
- class CodexResponsesHandler:
19
- """Adapter for the ChatGPT Codex Responses backend.
20
-
21
- Codex-specific behavior kept here:
22
- - Uses `instructions` + `input` instead of Chat Completions `messages`.
23
- - Always sends `stream: true`; the backend returns SSE even for logical
24
- non-streaming agent calls.
25
- - Stores `_responses_output` replay metadata so tool-call turns can be sent
26
- back in Responses-native form while using `store: false`.
27
- """
28
-
29
- supports_sse_response_fallback = True
30
-
31
- def build_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
32
- """Build request headers."""
33
- headers = {"Content-Type": "application/json"}
34
- if config.get("type") == "api" and config.get("api_key"):
35
- headers["Authorization"] = f"Bearer {config['api_key']}"
36
- if "headers_extra" in config:
37
- headers.update(config["headers_extra"])
38
- return headers
39
-
40
- def build_payload(self, config: Dict[str, Any], messages: list,
41
- tools: Optional[list] = None, stream: bool = True) -> Dict[str, Any]:
42
- """Build request payload for Codex backend Responses API."""
43
- system_parts = [m["content"] for m in messages if m.get("role") == "system"]
44
- instructions = "\n".join(system_parts) if system_parts else "You are a helpful assistant."
45
-
46
- codex_input = []
47
- for m in messages:
48
- if m.get("role") == "system":
49
- continue
50
- role = m.get("role", "user")
51
- content = m.get("content", "")
52
-
53
- if role == "assistant" and m.get("_responses_output"):
54
- codex_input.extend(m.get("_responses_output") or [])
55
- continue
56
-
57
- if role == "assistant" and m.get("tool_calls"):
58
- if content:
59
- codex_input.append({
60
- "role": "assistant",
61
- "content": [{"type": "input_text", "text": content}]
62
- })
63
- for tool_call in m.get("tool_calls", []):
64
- function = tool_call.get("function", {})
65
- codex_input.append({
66
- "type": "function_call",
67
- "call_id": tool_call.get("id"),
68
- "name": function.get("name", ""),
69
- "arguments": function.get("arguments", "{}"),
70
- })
71
- continue
72
-
73
- if role == "tool":
74
- codex_input.append({
75
- "type": "function_call_output",
76
- "call_id": m.get("tool_call_id"),
77
- "output": content,
78
- })
79
- continue
80
-
81
- content_type = "output_text" if role == "assistant" else "input_text"
82
- codex_input.append({
83
- "role": role,
84
- "content": [{"type": content_type, "text": content}]
85
- })
86
-
87
- payload = {
88
- **config.get("payload", {}),
89
- "instructions": instructions,
90
- "input": codex_input,
91
- "store": False,
92
- "stream": True,
93
- }
94
-
95
- if "model" not in payload:
96
- model_name = config.get("api_model") or config.get("model")
97
- if model_name:
98
- payload["model"] = model_name
99
-
100
- if tools:
101
- payload["tools"] = [self._convert_tool_to_responses(tool) for tool in tools]
102
-
103
- if "prompt_cache_key" not in payload:
104
- model = payload.get("model") or "unknown-model"
105
- payload["prompt_cache_key"] = self._build_prompt_cache_key(
106
- model=model,
107
- instructions=instructions,
108
- tools=payload.get("tools"),
109
- )
110
-
111
- if "temperature" not in payload and config.get("allow_temperature", True):
112
- payload["temperature"] = config.get("default_temperature", 0.1)
113
- if "top_p" not in payload and config.get("allow_top_p", True):
114
- payload["top_p"] = config.get("default_top_p", 0.9)
115
-
116
- return payload
117
-
118
- def _build_prompt_cache_key(
119
- self,
120
- *,
121
- model: str,
122
- instructions: str,
123
- tools: Optional[list] = None,
124
- ) -> str:
125
- """Build a stable prompt-cache key for the reusable Codex prefix."""
126
- cache_scope = {
127
- "model": model,
128
- "instructions": instructions,
129
- "tools": tools or [],
130
- }
131
- canonical = json.dumps(
132
- cache_scope,
133
- sort_keys=True,
134
- separators=(",", ":"),
135
- ensure_ascii=True,
136
- )
137
- cache_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
138
- return f"bone-agent:{cache_hash}"
139
-
140
- def parse_response(self, response_json: Dict[str, Any]) -> Dict[str, Any]:
141
- """Parse Responses API output into Chat Completions format."""
142
- return self._normalize_response(response_json)
143
-
144
- def parse_sse_response(self, response_text: str) -> Dict[str, Any]:
145
- """Parse a full SSE response body into Chat Completions format."""
146
- completed_response = None
147
- output_items = []
148
-
149
- for raw_line in response_text.splitlines():
150
- line = raw_line.strip()
151
- if not line.startswith("data: "):
152
- continue
153
- data_str = line[6:]
154
- if data_str == "[DONE]":
155
- break
156
- try:
157
- data = json.loads(data_str)
158
- except json.JSONDecodeError as e:
159
- raise LLMResponseError(
160
- "Failed to decode SSE response from Codex backend",
161
- details={"original_error": str(e)}
162
- )
163
-
164
- if data.get("type") == "response.output_item.done":
165
- item = data.get("item")
166
- if item:
167
- output_items.append(item)
168
- continue
169
-
170
- if data.get("type") == "response.completed":
171
- completed_response = data.get("response")
172
- break
173
-
174
- if completed_response is None:
175
- raise LLMResponseError(
176
- "Codex backend returned streaming data without a completed response event"
177
- )
178
-
179
- if not completed_response.get("output") and output_items:
180
- completed_response = dict(completed_response)
181
- completed_response["output"] = output_items
182
-
183
- return self._normalize_response(completed_response)
184
-
185
- def parse_stream(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
186
- """Parse streaming Responses API."""
187
- usage_data = None
188
-
189
- for line in response.iter_lines():
190
- if line:
191
- line = line.decode('utf-8')
192
-
193
- if line.startswith('data: '):
194
- data_str = line[6:]
195
- if data_str.strip() == '[DONE]':
196
- break
197
-
198
- try:
199
- data = json.loads(data_str)
200
-
201
- if 'error' in data:
202
- error_msg = data.get('error', {}).get('message', 'Unknown streaming error')
203
- raise LLMResponseError(
204
- f"Streaming error: {error_msg}",
205
- details={"error_data": data.get('error')}
206
- )
207
-
208
- event_type = data.get("type", "")
209
-
210
- if event_type == "response.completed":
211
- resp = data.get("response", {})
212
- if "usage" in resp:
213
- usage_data = self._normalize_usage(resp["usage"])
214
-
215
- if event_type == "response.output_text.delta":
216
- delta = data.get("delta", "")
217
- if delta:
218
- yield delta
219
-
220
- except json.JSONDecodeError as e:
221
- raise LLMResponseError(
222
- f"Failed to decode streaming response",
223
- details={"original_error": str(e)}
224
- )
225
-
226
- if usage_data:
227
- yield {'__usage__': usage_data}
228
-
229
- def _convert_tool_to_responses(self, tool: Dict[str, Any]) -> Dict[str, Any]:
230
- """Convert Chat Completions tool schema to Responses/Codex schema."""
231
- if tool.get("type") == "function" and "function" in tool:
232
- function = tool["function"]
233
- return {
234
- "type": "function",
235
- "name": function.get("name", ""),
236
- "description": function.get("description", ""),
237
- "parameters": self._normalize_json_schema(function.get("parameters", {})),
238
- "strict": False,
239
- }
240
- return tool
241
-
242
- def _normalize_response(self, response_json: Dict[str, Any]) -> Dict[str, Any]:
243
- """Normalize Responses output into Chat Completions message shape."""
244
- raw_usage = response_json.get("usage", {})
245
- usage = self._normalize_usage(raw_usage)
246
-
247
- output_items = response_json.get("output", [])
248
- content_parts = []
249
- tool_calls = []
250
-
251
- for item in output_items:
252
- item_type = item.get("type")
253
-
254
- if item_type == "function_call":
255
- call_id = item.get("call_id") or item.get("id")
256
- tool_calls.append({
257
- "id": call_id,
258
- "type": "function",
259
- "function": {
260
- "name": item.get("name", ""),
261
- "arguments": item.get("arguments", "{}"),
262
- }
263
- })
264
- continue
265
-
266
- if item_type != "message":
267
- continue
268
-
269
- for c in item.get("content", []):
270
- if c.get("type") in {"output_text", "text"}:
271
- text = c.get("text")
272
- if text is not None:
273
- content_parts.append(text)
274
-
275
- message = {"role": "assistant"}
276
- text_content = "\n".join(content_parts) if content_parts else ""
277
- if tool_calls:
278
- message["tool_calls"] = tool_calls
279
- message["content"] = text_content or None
280
- else:
281
- message["content"] = text_content
282
-
283
- replay_items = copy.deepcopy(output_items)
284
- for item in replay_items:
285
- item.pop("id", None)
286
- message["_responses_output"] = replay_items
287
-
288
- return {
289
- "choices": [{
290
- "message": message,
291
- "finish_reason": "tool_calls" if tool_calls else "stop",
292
- }],
293
- "usage": usage,
294
- }
295
-
296
- def _normalize_usage(self, usage: Any) -> Dict[str, Any]:
297
- """Normalize Codex Responses usage into vmCode's OpenAI-style usage shape."""
298
- if not isinstance(usage, dict):
299
- return {}
300
-
301
- normalized = dict(usage)
302
-
303
- input_tokens = normalized.get("input_tokens")
304
- output_tokens = normalized.get("output_tokens")
305
-
306
- if normalized.get("prompt_tokens") is None and input_tokens is not None:
307
- normalized["prompt_tokens"] = input_tokens
308
- if normalized.get("completion_tokens") is None and output_tokens is not None:
309
- normalized["completion_tokens"] = output_tokens
310
- if normalized.get("total_tokens") is None:
311
- prompt_tokens = normalized.get("prompt_tokens")
312
- completion_tokens = normalized.get("completion_tokens")
313
- if prompt_tokens is not None and completion_tokens is not None:
314
- normalized["total_tokens"] = prompt_tokens + completion_tokens
315
-
316
- input_details = normalized.get("input_tokens_details")
317
- if isinstance(input_details, dict) and input_details.get("cached_tokens") is not None:
318
- cached_tokens = input_details["cached_tokens"]
319
- if normalized.get("prompt_tokens_details") is None:
320
- normalized["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
321
- elif isinstance(normalized["prompt_tokens_details"], dict):
322
- normalized["prompt_tokens_details"].setdefault("cached_tokens", cached_tokens)
323
- normalized.setdefault("cached_tokens", cached_tokens)
324
-
325
- return normalized
326
-
327
- def _normalize_json_schema(self, schema: Any) -> Any:
328
- """Normalize JSON Schema for strict Responses function tools."""
329
- if not isinstance(schema, dict):
330
- return schema
331
-
332
- normalized = dict(schema)
333
- schema_type = normalized.get("type")
334
-
335
- if schema_type == "object":
336
- properties = normalized.get("properties", {})
337
- normalized["properties"] = {
338
- key: self._normalize_json_schema(value)
339
- for key, value in properties.items()
340
- }
341
- normalized.setdefault("additionalProperties", False)
342
-
343
- if schema_type == "array" and "items" in normalized:
344
- normalized["items"] = self._normalize_json_schema(normalized["items"])
345
-
346
- for key in ("anyOf", "oneOf", "allOf"):
347
- if key in normalized and isinstance(normalized[key], list):
348
- normalized[key] = [self._normalize_json_schema(item) for item in normalized[key]]
349
-
350
- return normalized