diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index de5f5c5500..23500b88c8 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -238,6 +238,7 @@ class ExternalProviderClient: enabled_tools: Optional[list[str]] = None, enable_prompt_caching: Optional[bool] = None, openai_code_exec_container_id: Optional[str] = None, + anthropic_code_exec_container_id: Optional[str] = None, stream: bool = True, ) -> AsyncGenerator[str, None]: """ @@ -263,6 +264,7 @@ class ExternalProviderClient: reasoning_effort, enabled_tools, enable_prompt_caching, + anthropic_code_exec_container_id, ): yield line return @@ -1063,6 +1065,7 @@ class ExternalProviderClient: reasoning_effort: Optional[str] = None, enabled_tools: Optional[list[str]] = None, enable_prompt_caching: Optional[bool] = None, + anthropic_code_exec_container_id: Optional[str] = None, ) -> AsyncGenerator[str, None]: """ Call the Anthropic Messages API and translate its SSE to OpenAI format. @@ -1313,6 +1316,19 @@ class ExternalProviderClient: } ) body["tools"] = anthropic_tools + # Reuse the prior turn's container so filesystem state + # (files written, packages installed, variables set) + # persists across turns of the same thread. Anthropic + # exposes the container id on the Message object's + # top-level `container.id`; on the SSE stream we latch it + # off `message_start.message.container.id` further down + # and emit a `container_ready` _toolEvent so the chat + # adapter persists it on the thread record. A stale id + # (container expired / not found) surfaces as a 4xx + # below, where we emit `container_invalidated` and let + # the next turn fall back to auto-create. + if anthropic_code_exec_container_id: + body["container"] = anthropic_code_exec_container_id url = f"{self.base_url}/messages" completion_id = f"chatcmpl-anthropic-{model.replace('/', '-')}" @@ -1376,6 +1392,28 @@ class ExternalProviderClient: response.status_code, error_text[:500], ) + # Stale container detection (mirror of the OpenAI + # path). When we sent a `container` field and the + # response is 4xx with any hint that the id is + # expired / missing, emit container_invalidated so + # the chat adapter clears the stored id and the + # next turn falls back to auto-create. + if ( + anthropic_code_exec_container_id + and 400 <= response.status_code < 500 + ): + lowered = error_text.lower() + if "container" in lowered and ( + "expired" in lowered + or "not_found" in lowered + or "not found" in lowered + or "no such container" in lowered + or "invalid" in lowered + ): + yield ( + f"data: " + f"{_json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': None}], '_toolEvent': {'type': 'container_invalidated'}})}" + ) yield _error_sse_line( response.status_code, error_text, self.provider_type ) @@ -1421,6 +1459,13 @@ class ExternalProviderClient: # them. Track the count so we know how often it would # have mattered. code_execution_generated_files = 0 + # Container id captured from `message_start.message.container.id` + # when code_execution is enabled. Emit a `container_ready` + # _toolEvent on first sight so the chat adapter persists it + # on the thread record. Only emitted when the value differs + # from the inbound id — no churn on reuse. + latched_container_id: Optional[str] = None + container_id_emitted = False # Cache usage tracking. message_start carries the input # accounting (incl. cache_creation_input_tokens and # cache_read_input_tokens); message_delta carries cumulative @@ -1800,6 +1845,36 @@ class ExternalProviderClient: delta_usage = event.get("usage") if isinstance(delta_usage, dict): last_usage.update(delta_usage) + # Anthropic reports the code_execution container + # id on `message_delta.delta.container.{id, + # expires_at}` (NOT on message_start — at start + # the container hasn't been provisioned yet). + # Latch on first sight and emit container_ready + # only when the value differs from the inbound + # id, so steady-state reuse doesn't re-write + # the same id to the thread record every turn. + delta_obj = event.get("delta") or {} + container_obj = delta_obj.get("container") + if ( + isinstance(container_obj, dict) + and latched_container_id is None + ): + probe = container_obj.get("id") + if isinstance(probe, str) and probe: + latched_container_id = probe + if ( + latched_container_id + and not container_id_emitted + and latched_container_id + != anthropic_code_exec_container_id + ): + yield _emit_tool_event( + { + "type": "container_ready", + "container_id": latched_container_id, + } + ) + container_id_emitted = True stop_reason = event.get("delta", {}).get("stop_reason") if stop_reason: if thinking_open: @@ -1869,6 +1944,7 @@ class ExternalProviderClient: "code_execution_invocations=%s, " "code_execution_results=%s, " "code_execution_generated_files=%s, " + "container_id_in=%s, container_id_out=%s, " "input_tokens=%s, output_tokens=%s, " "cache_creation_input_tokens=%s, " "cache_read_input_tokens=%s, events=%s)", @@ -1881,6 +1957,8 @@ class ExternalProviderClient: code_execution_invocations, code_execution_results, code_execution_generated_files, + anthropic_code_exec_container_id, + latched_container_id, last_usage.get("input_tokens"), last_usage.get("output_tokens"), last_usage.get("cache_creation_input_tokens"), diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 3aa89cc934..19b5c3dcd3 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -616,6 +616,20 @@ class ChatCompletionRequest(BaseModel): "OpenAI cloud + gpt-5.5 family path; ignored otherwise." ), ) + anthropic_code_exec_container_id: Optional[str] = Field( + None, + description = ( + "[x-unsloth] Anthropic code_execution container id from the prior " + "response in the same chat thread. When set and `code_execution` " + "is in `enabled_tools`, the next /v1/messages call carries a " + "top-level `container` field so the model sees filesystem state " + "from earlier turns. Unset → Anthropic auto-creates a fresh " + "container. Stale ids surface a 4xx with a `container_expired` / " + "`container_not_found` hint; the backend emits a synthetic " + "`container_invalidated` _toolEvent so the next turn falls back " + "to auto-create." + ), + ) # ── OpenAI shell-tool container management ───────────────────── diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 60078ecc9b..fdf9fd8f5e 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1737,6 +1737,7 @@ async def _proxy_to_external_provider( enabled_tools = payload.enabled_tools, enable_prompt_caching = payload.enable_prompt_caching, openai_code_exec_container_id = payload.openai_code_exec_container_id, + anthropic_code_exec_container_id = payload.anthropic_code_exec_container_id, stream = payload.stream, ) try: diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 7084b946bb..427f12c074 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -990,9 +990,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // container_id (if any) so subsequent turns in the same // thread reference the existing container instead of // auto-creating a fresh one. Empty string / undefined → - // backend falls back to container_auto. Anthropic doesn't - // use this (server-side per-turn container). + // backend falls back to container_auto. Anthropic uses + // the parallel `anthropicCodeExecContainerId` field below + // (sent as `container` on /v1/messages). let openaiCodeExecContainerId: string | null = null; + let anthropicCodeExecContainerId: string | null = null; const codeExecEnabledForThisTurn = codeToolsEnabled && providerSupportsBuiltinCodeExecution( @@ -1005,8 +1007,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { const thread = await db.threads.get(resolvedThreadId); openaiCodeExecContainerId = thread?.openaiCodeExecContainerId ?? null; + anthropicCodeExecContainerId = + thread?.anthropicCodeExecContainerId ?? null; } catch { openaiCodeExecContainerId = null; + anthropicCodeExecContainerId = null; } // Cross-thread inheritance: when the active thread has // no container yet, default to the one most recently @@ -1172,6 +1177,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { openai_code_exec_container_id: openaiCodeExecContainerId, } : {}), + ...(anthropicCodeExecContainerId + ? { + anthropic_code_exec_container_id: + anthropicCodeExecContainerId, + } + : {}), ...(supportsProviderPromptCaching(externalProvider.providerType) ? { enable_prompt_caching: @@ -1265,9 +1276,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { | string | undefined; if (newContainerId && resolvedThreadId) { + const field = + externalProvider?.providerType === "anthropic" + ? "anthropicCodeExecContainerId" + : "openaiCodeExecContainerId"; void db.threads .update(resolvedThreadId, { - openaiCodeExecContainerId: newContainerId, + [field]: newContainerId, }) .catch(() => {}); } @@ -1275,9 +1290,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } if (toolEvent.type === "container_invalidated") { if (resolvedThreadId) { + const field = + externalProvider?.providerType === "anthropic" + ? "anthropicCodeExecContainerId" + : "openaiCodeExecContainerId"; void db.threads .update(resolvedThreadId, { - openaiCodeExecContainerId: null, + [field]: null, }) .catch(() => {}); } diff --git a/studio/frontend/src/features/chat/types.ts b/studio/frontend/src/features/chat/types.ts index eb88da635d..d0c4e22870 100644 --- a/studio/frontend/src/features/chat/types.ts +++ b/studio/frontend/src/features/chat/types.ts @@ -26,11 +26,21 @@ export interface ThreadRecord { * if a stale id is sent, the backend surfaces an * `_toolEvent.type="container_invalidated"` and the chat-adapter * clears this field so the following turn falls back to auto-create. - * - * Anthropic's code-execution path doesn't need this — each turn - * gets a fresh container server-side. */ openaiCodeExecContainerId?: string | null; + /** + * Anthropic code_execution container id captured from a prior + * response on this thread. When set, the next turn sends a + * top-level `container` field on /v1/messages so filesystem state + * (files, packages, variables) persists across turns. When + * null/undefined, Anthropic auto-creates a fresh container. + * + * Anthropic containers expire after ~1 hour by default; on a stale + * id the backend surfaces `_toolEvent.type="container_invalidated"` + * and the chat-adapter clears this field so the following turn + * falls back to auto-create. + */ + anthropicCodeExecContainerId?: string | null; } export interface MessageRecord { diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index ec4a5ea355..3fc5d320df 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -235,6 +235,15 @@ export interface OpenAIChatCompletionsRequest { * container. Only meaningful for OpenAI cloud + gpt-5.5 family. */ openai_code_exec_container_id?: string | null; + /** + * Anthropic code_execution container id captured from the prior + * response in this chat thread. When set and the Code pill is on, + * the backend forwards a top-level `container` field on + * /v1/messages so filesystem state persists across turns. Unset → + * Anthropic auto-creates a fresh container. Only meaningful for + * the Anthropic provider with `code_execution` in `enabled_tools`. + */ + anthropic_code_exec_container_id?: string | null; } export interface OpenAIChatDelta {