Merge branch 'fix-issue-5344-quantization-guardrail' into feat-gemma4-moe-4bit-swap
This commit is contained in:
commit
48291ffab4
25 changed files with 3215 additions and 114 deletions
|
|
@ -345,15 +345,13 @@ jobs:
|
|||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
# `set +e` + redirect everything: Git Bash on windows-latest
|
||||
# has been observed to exit 143 from the kill/sleep block even
|
||||
# when the upstream test work passed, masking a green run. The
|
||||
# teardown does not gate correctness, so absorb any signal.
|
||||
run: |
|
||||
set +e
|
||||
kill "${STUDIO_PID}" >/dev/null 2>&1 || true
|
||||
sleep 2 >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
# Run as cmd so we are not running through the Git Bash shell;
|
||||
# Git Bash on windows-latest has been observed to exit 143
|
||||
# (SIGTERM) from any inline kill/sleep block, masking a green
|
||||
# test run. The runner reclaims the Studio child process at
|
||||
# job end either way, so just emit a marker and exit 0.
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
|
|
@ -768,15 +766,13 @@ jobs:
|
|||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
# `set +e` + redirect everything: Git Bash on windows-latest
|
||||
# has been observed to exit 143 from the kill/sleep block even
|
||||
# when the upstream test work passed, masking a green run. The
|
||||
# teardown does not gate correctness, so absorb any signal.
|
||||
run: |
|
||||
set +e
|
||||
kill "${STUDIO_PID}" >/dev/null 2>&1 || true
|
||||
sleep 2 >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
# Run as cmd so we are not running through the Git Bash shell;
|
||||
# Git Bash on windows-latest has been observed to exit 143
|
||||
# (SIGTERM) from any inline kill/sleep block, masking a green
|
||||
# test run. The runner reclaims the Studio child process at
|
||||
# job end either way, so just emit a marker and exit 0.
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
|
|
@ -1162,15 +1158,13 @@ jobs:
|
|||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
# `set +e` + redirect everything: Git Bash on windows-latest
|
||||
# has been observed to exit 143 from the kill/sleep block even
|
||||
# when the upstream test work passed, masking a green run. The
|
||||
# teardown does not gate correctness, so absorb any signal.
|
||||
run: |
|
||||
set +e
|
||||
kill "${STUDIO_PID}" >/dev/null 2>&1 || true
|
||||
sleep 2 >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
# Run as cmd so we are not running through the Git Bash shell;
|
||||
# Git Bash on windows-latest has been observed to exit 143
|
||||
# (SIGTERM) from any inline kill/sleep block, masking a green
|
||||
# test run. The runner reclaims the Studio child process at
|
||||
# job end either way, so just emit a marker and exit 0.
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
|
|
|
|||
|
|
@ -208,10 +208,11 @@ class ExternalProviderClient:
|
|||
auth_header = provider_info.get("auth_header", "Authorization")
|
||||
auth_prefix = provider_info.get("auth_prefix", "Bearer ")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
auth_header: f"{auth_prefix}{self.api_key}",
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
# Skip auth header when api_key is empty (optional for local providers);
|
||||
# httpx rejects an empty `Bearer ` value as "Illegal header value".
|
||||
if self.api_key:
|
||||
headers[auth_header] = f"{auth_prefix}{self.api_key}"
|
||||
# Merge any provider-specific extra headers (e.g. anthropic-version, OpenRouter attribution)
|
||||
headers.update(provider_info.get("extra_headers", {}))
|
||||
return headers
|
||||
|
|
@ -236,6 +237,7 @@ class ExternalProviderClient:
|
|||
reasoning_effort: Optional[str] = None,
|
||||
enabled_tools: Optional[list[str]] = None,
|
||||
enable_prompt_caching: Optional[bool] = None,
|
||||
openai_code_exec_container_id: Optional[str] = None,
|
||||
stream: bool = True,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
|
|
@ -281,6 +283,7 @@ class ExternalProviderClient:
|
|||
reasoning_effort,
|
||||
enabled_tools,
|
||||
enable_prompt_caching,
|
||||
openai_code_exec_container_id,
|
||||
):
|
||||
yield line
|
||||
return
|
||||
|
|
@ -1284,6 +1287,33 @@ class ExternalProviderClient:
|
|||
)
|
||||
body["tools"] = anthropic_tools
|
||||
|
||||
# Anthropic server-side code execution — see
|
||||
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool
|
||||
# `code_execution_20250825` runs Python + bash + str_replace
|
||||
# file edits inside a 5 GB sandboxed container per request, with
|
||||
# no internet access. The tool entry itself takes no extra
|
||||
# parameters; on the SSE stream Anthropic emits two sub-tool
|
||||
# names — `bash_code_execution` and
|
||||
# `text_editor_code_execution` — wrapped in the standard
|
||||
# server_tool_use / *_tool_result block shape. The matching
|
||||
# beta header (`code-execution-2025-08-25`) is set further down
|
||||
# in this function alongside the request headers.
|
||||
# v1 wires the tool only; file uploads (container_upload
|
||||
# content blocks and generated-file retrieval via the Files
|
||||
# API) are a deliberate follow-up.
|
||||
code_execution_enabled = bool(
|
||||
enabled_tools and "code_execution" in enabled_tools
|
||||
)
|
||||
if code_execution_enabled:
|
||||
anthropic_tools = list(body.get("tools") or [])
|
||||
anthropic_tools.append(
|
||||
{
|
||||
"type": "code_execution_20250825",
|
||||
"name": "code_execution",
|
||||
}
|
||||
)
|
||||
body["tools"] = anthropic_tools
|
||||
|
||||
url = f"{self.base_url}/messages"
|
||||
completion_id = f"chatcmpl-anthropic-{model.replace('/', '-')}"
|
||||
|
||||
|
|
@ -1313,12 +1343,29 @@ class ExternalProviderClient:
|
|||
|
||||
logger.info("Proxying Anthropic Messages API to %s (model=%s)", url, model)
|
||||
|
||||
request_headers = self._auth_headers()
|
||||
if code_execution_enabled:
|
||||
# Anthropic accepts comma-separated beta features in a single
|
||||
# `anthropic-beta` header. Merge our flag onto whatever the
|
||||
# registry's extra_headers contributed (currently nothing on
|
||||
# the beta axis, just anthropic-version) so future betas
|
||||
# added at the registry level keep working.
|
||||
existing_beta = request_headers.get("anthropic-beta", "").strip()
|
||||
beta_parts = (
|
||||
[p.strip() for p in existing_beta.split(",") if p.strip()]
|
||||
if existing_beta
|
||||
else []
|
||||
)
|
||||
if "code-execution-2025-08-25" not in beta_parts:
|
||||
beta_parts.append("code-execution-2025-08-25")
|
||||
request_headers["anthropic-beta"] = ",".join(beta_parts)
|
||||
|
||||
try:
|
||||
async with _http_client.stream(
|
||||
"POST",
|
||||
url,
|
||||
json = body,
|
||||
headers = self._auth_headers(),
|
||||
headers = request_headers,
|
||||
timeout = self._stream_timeout,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
|
|
@ -1352,6 +1399,28 @@ class ExternalProviderClient:
|
|||
current_server_tool_use: Optional[dict[str, Any]] = None
|
||||
current_result_block: Optional[dict[str, Any]] = None
|
||||
web_search_calls: dict[str, dict[str, Any]] = {}
|
||||
# code_execution state. Anthropic's
|
||||
# `code_execution_20250825` tool emits the same
|
||||
# server_tool_use → *_tool_result block shape as
|
||||
# web_search, but the server_tool_use carries one of
|
||||
# two sub-tool names (`bash_code_execution` or
|
||||
# `text_editor_code_execution`) and the result block
|
||||
# type matches (`bash_code_execution_tool_result` /
|
||||
# `text_editor_code_execution_tool_result`). Kept
|
||||
# parallel to web_search state so the two paths don't
|
||||
# collide when both pills are on in the same turn.
|
||||
current_code_exec_use: Optional[dict[str, Any]] = None
|
||||
current_code_exec_result: Optional[dict[str, Any]] = None
|
||||
code_execution_calls: dict[str, dict[str, Any]] = {}
|
||||
# Counts surfaced in the final log line so reports of
|
||||
# "Code execution did nothing" can be triaged at a
|
||||
# glance. generated_files_count is interesting for the
|
||||
# future Files API PR — when bash creates files inside
|
||||
# the container, they show up as file_id entries on
|
||||
# bash_code_execution_result.content, and v1 drops
|
||||
# them. Track the count so we know how often it would
|
||||
# have mattered.
|
||||
code_execution_generated_files = 0
|
||||
# Cache usage tracking. message_start carries the input
|
||||
# accounting (incl. cache_creation_input_tokens and
|
||||
# cache_read_input_tokens); message_delta carries cumulative
|
||||
|
|
@ -1405,6 +1474,48 @@ class ExternalProviderClient:
|
|||
blocks.append(f"Title: {title}\nURL: {url}")
|
||||
return "\n---\n".join(blocks)
|
||||
|
||||
def _format_code_execution_result(
|
||||
inner: dict[str, Any],
|
||||
) -> str:
|
||||
"""Render an Anthropic code-execution result block as
|
||||
the preformatted text payload the frontend's
|
||||
CodeExecutionToolUI displays inside a <pre>. Handles
|
||||
bash, text_editor (view/create/str_replace), and the
|
||||
matching error variants.
|
||||
"""
|
||||
inner_type = inner.get("type") or ""
|
||||
if inner_type.endswith("_error"):
|
||||
return f"Error: {inner.get('error_code', 'unknown')}"
|
||||
if inner_type == "bash_code_execution_result":
|
||||
stdout = inner.get("stdout") or ""
|
||||
stderr = inner.get("stderr") or ""
|
||||
return_code = inner.get("return_code")
|
||||
parts: list[str] = []
|
||||
if stdout:
|
||||
parts.append(stdout)
|
||||
if stderr:
|
||||
parts.append(f"--- stderr ---\n{stderr}")
|
||||
if isinstance(return_code, int) and return_code != 0:
|
||||
parts.append(f"return_code: {return_code}")
|
||||
return "\n".join(parts) if parts else "(no output)"
|
||||
if inner_type == "text_editor_code_execution_result":
|
||||
# view: file content; create: is_file_update flag;
|
||||
# str_replace: diff `lines` list. The matching
|
||||
# server_tool_use carries the command + path, but
|
||||
# that's encoded into the tool_start arguments
|
||||
# already — here we only format the result body.
|
||||
if "lines" in inner and isinstance(inner.get("lines"), list):
|
||||
return "\n".join(str(line) for line in inner["lines"])
|
||||
if "is_file_update" in inner:
|
||||
return (
|
||||
"Updated" if inner.get("is_file_update") else "Created"
|
||||
)
|
||||
content_field = inner.get("content")
|
||||
if isinstance(content_field, str):
|
||||
return content_field
|
||||
return "(file operation complete)"
|
||||
return "(code execution complete)"
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -1446,9 +1557,10 @@ class ExternalProviderClient:
|
|||
if event_type == "content_block_start":
|
||||
content_block = event.get("content_block") or {}
|
||||
block_type = content_block.get("type")
|
||||
block_name = content_block.get("name")
|
||||
if (
|
||||
block_type == "server_tool_use"
|
||||
and content_block.get("name") == "web_search"
|
||||
and block_name == "web_search"
|
||||
):
|
||||
tool_use_id = content_block.get("id", "") or (
|
||||
f"ws_{len(web_search_calls)}"
|
||||
|
|
@ -1474,6 +1586,44 @@ class ExternalProviderClient:
|
|||
if isinstance(content, list)
|
||||
else [],
|
||||
}
|
||||
elif block_type == "server_tool_use" and block_name in (
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
):
|
||||
tool_use_id = content_block.get("id", "") or (
|
||||
f"ce_{len(code_execution_calls)}"
|
||||
)
|
||||
kind = (
|
||||
"bash"
|
||||
if block_name == "bash_code_execution"
|
||||
else "text_editor"
|
||||
)
|
||||
current_code_exec_use = {
|
||||
"id": tool_use_id,
|
||||
"kind": kind,
|
||||
"buffer": "",
|
||||
}
|
||||
code_execution_calls[tool_use_id] = {
|
||||
"kind": kind,
|
||||
"arguments": {},
|
||||
"result": None,
|
||||
}
|
||||
elif block_type in (
|
||||
"bash_code_execution_tool_result",
|
||||
"text_editor_code_execution_tool_result",
|
||||
):
|
||||
# Anthropic ships the full result content
|
||||
# on the start event for code-exec result
|
||||
# blocks (unlike web_search, which can
|
||||
# split across deltas). Capture it and
|
||||
# finalize on content_block_stop so the
|
||||
# ordering matches the web_search path.
|
||||
tool_use_id = content_block.get("tool_use_id", "")
|
||||
inner = content_block.get("content") or {}
|
||||
current_code_exec_result = {
|
||||
"tool_use_id": tool_use_id,
|
||||
"inner": inner if isinstance(inner, dict) else {},
|
||||
}
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
delta = event.get("delta", {})
|
||||
|
|
@ -1507,15 +1657,20 @@ class ExternalProviderClient:
|
|||
# per-call by Anthropic via the
|
||||
# `web_search_tool_result` block; we don't
|
||||
# need to scrape them off the text events.
|
||||
elif (
|
||||
delta_type == "input_json_delta"
|
||||
and current_server_tool_use is not None
|
||||
):
|
||||
# Streamed partial_json carrying the search
|
||||
# query. Buffer until content_block_stop.
|
||||
current_server_tool_use["buffer"] += delta.get(
|
||||
"partial_json", ""
|
||||
)
|
||||
elif delta_type == "input_json_delta":
|
||||
# Streamed partial_json carrying tool inputs
|
||||
# — the search query for web_search, or the
|
||||
# command/path/etc. for code execution.
|
||||
# Route to whichever buffer is open. The two
|
||||
# state slots are exclusive in practice
|
||||
# (Anthropic doesn't interleave tool input
|
||||
# streams), but checking both keeps the
|
||||
# dispatch robust if that ever changes.
|
||||
partial = delta.get("partial_json", "")
|
||||
if current_server_tool_use is not None:
|
||||
current_server_tool_use["buffer"] += partial
|
||||
elif current_code_exec_use is not None:
|
||||
current_code_exec_use["buffer"] += partial
|
||||
# signature_delta and any other delta types are
|
||||
# intentionally skipped — they carry trust /
|
||||
# verification metadata, not user-visible content.
|
||||
|
|
@ -1571,6 +1726,68 @@ class ExternalProviderClient:
|
|||
}
|
||||
)
|
||||
current_result_block = None
|
||||
elif current_code_exec_use is not None:
|
||||
# End of a code-execution server_tool_use —
|
||||
# parse the buffered input_json into a
|
||||
# {command, path, ...} dict and emit
|
||||
# tool_start. The matching tool_end fires
|
||||
# on the result block's content_block_stop.
|
||||
buffer = current_code_exec_use["buffer"]
|
||||
parsed_args: dict[str, Any] = {}
|
||||
if buffer:
|
||||
try:
|
||||
parsed_obj = _json.loads(buffer)
|
||||
if isinstance(parsed_obj, dict):
|
||||
parsed_args = parsed_obj
|
||||
except Exception:
|
||||
parsed_args = {}
|
||||
tool_use_id = current_code_exec_use["id"]
|
||||
kind = current_code_exec_use["kind"]
|
||||
emit_args = {"kind": kind, **parsed_args}
|
||||
if tool_use_id in code_execution_calls:
|
||||
code_execution_calls[tool_use_id]["arguments"] = (
|
||||
emit_args
|
||||
)
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "tool_start",
|
||||
"tool_name": "code_execution",
|
||||
"tool_call_id": tool_use_id,
|
||||
"arguments": emit_args,
|
||||
}
|
||||
)
|
||||
current_code_exec_use = None
|
||||
elif current_code_exec_result is not None:
|
||||
# End of a code-execution result block —
|
||||
# format the inner result into the text
|
||||
# payload CodeExecutionToolUI renders.
|
||||
tool_use_id = current_code_exec_result["tool_use_id"]
|
||||
inner = current_code_exec_result["inner"]
|
||||
# Track generated-file count for the
|
||||
# follow-up Files API PR. v1 drops them.
|
||||
if isinstance(inner, dict):
|
||||
file_blocks = inner.get("content")
|
||||
if isinstance(file_blocks, list):
|
||||
for entry in file_blocks:
|
||||
if isinstance(entry, dict) and entry.get(
|
||||
"file_id"
|
||||
):
|
||||
code_execution_generated_files += 1
|
||||
result_text = _format_code_execution_result(
|
||||
inner if isinstance(inner, dict) else {}
|
||||
)
|
||||
if tool_use_id in code_execution_calls:
|
||||
code_execution_calls[tool_use_id]["result"] = (
|
||||
result_text
|
||||
)
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "tool_end",
|
||||
"tool_call_id": tool_use_id,
|
||||
"result": result_text,
|
||||
}
|
||||
)
|
||||
current_code_exec_result = None
|
||||
elif thinking_open:
|
||||
# Close the <think> tag when the thinking block
|
||||
# ends, in case no text_delta follows (e.g.
|
||||
|
|
@ -1638,10 +1855,20 @@ class ExternalProviderClient:
|
|||
# instead. cache_creation tokens are billed at a
|
||||
# small premium; cache_read tokens are billed at a
|
||||
# discount.
|
||||
code_execution_invocations = len(code_execution_calls)
|
||||
code_execution_results = sum(
|
||||
1
|
||||
for c in code_execution_calls.values()
|
||||
if c.get("result") is not None
|
||||
)
|
||||
logger.info(
|
||||
"Anthropic stream complete (model=%s, "
|
||||
"web_search_requested=%s, web_search_invocations=%s, "
|
||||
"results=%s, queries=%s, "
|
||||
"code_execution_requested=%s, "
|
||||
"code_execution_invocations=%s, "
|
||||
"code_execution_results=%s, "
|
||||
"code_execution_generated_files=%s, "
|
||||
"input_tokens=%s, output_tokens=%s, "
|
||||
"cache_creation_input_tokens=%s, "
|
||||
"cache_read_input_tokens=%s, events=%s)",
|
||||
|
|
@ -1650,6 +1877,10 @@ class ExternalProviderClient:
|
|||
web_search_invocations,
|
||||
total_results,
|
||||
queries,
|
||||
code_execution_enabled,
|
||||
code_execution_invocations,
|
||||
code_execution_results,
|
||||
code_execution_generated_files,
|
||||
last_usage.get("input_tokens"),
|
||||
last_usage.get("output_tokens"),
|
||||
last_usage.get("cache_creation_input_tokens"),
|
||||
|
|
@ -1692,6 +1923,7 @@ class ExternalProviderClient:
|
|||
reasoning_effort: Optional[str],
|
||||
enabled_tools: Optional[list[str]] = None,
|
||||
enable_prompt_caching: Optional[bool] = None,
|
||||
openai_code_exec_container_id: Optional[str] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Call OpenAI's /v1/responses endpoint and translate its SSE stream back
|
||||
|
|
@ -1816,15 +2048,41 @@ class ExternalProviderClient:
|
|||
|
||||
# OpenAI server-side tools — see
|
||||
# https://developers.openai.com/api/docs/guides/tools
|
||||
# The frontend's Search button maps to the unified
|
||||
# enabled_tools=["web_search"] shorthand; translate that into the
|
||||
# Responses-API tool schema. Other built-in tools (file_search,
|
||||
# code_interpreter, image_generation, computer_use_preview) can be
|
||||
# added with the same pattern when we surface their toggles.
|
||||
# https://developers.openai.com/api/docs/guides/tools-shell
|
||||
# The frontend's Search/Code buttons map to the unified
|
||||
# enabled_tools shorthand; translate that into the Responses-API
|
||||
# tool schema. Other built-in tools (file_search,
|
||||
# code_interpreter, image_generation, computer_use_preview) can
|
||||
# be added with the same pattern when we surface their toggles.
|
||||
code_execution_enabled_openai = bool(
|
||||
enabled_tools and "code_execution" in enabled_tools and is_openai_cloud
|
||||
)
|
||||
if enabled_tools:
|
||||
tools_array: list[dict[str, Any]] = []
|
||||
if "web_search" in enabled_tools:
|
||||
tools_array.append({"type": "web_search"})
|
||||
if code_execution_enabled_openai:
|
||||
# `container_auto` lets OpenAI auto-create a fresh
|
||||
# container per request; we capture the resulting
|
||||
# container_id off the SSE stream and the chat-adapter
|
||||
# persists it onto the thread record. Subsequent turns
|
||||
# in the same thread pass it back as
|
||||
# `openai_code_exec_container_id`, which we translate to
|
||||
# `container_reference` here so the model sees
|
||||
# filesystem state from prior turns. Container expires
|
||||
# after ~20 min of inactivity per OpenAI's default
|
||||
# policy — a stale id 400s, the chat-adapter clears it
|
||||
# via container_invalidated, and the next turn falls
|
||||
# back to auto-create.
|
||||
shell_env: dict[str, Any]
|
||||
if openai_code_exec_container_id:
|
||||
shell_env = {
|
||||
"type": "container_reference",
|
||||
"container_id": openai_code_exec_container_id,
|
||||
}
|
||||
else:
|
||||
shell_env = {"type": "container_auto"}
|
||||
tools_array.append({"type": "shell", "environment": shell_env})
|
||||
if tools_array:
|
||||
body["tools"] = tools_array
|
||||
|
||||
|
|
@ -1849,6 +2107,29 @@ class ExternalProviderClient:
|
|||
response.status_code,
|
||||
error_text[:500],
|
||||
)
|
||||
# Detect stale-container errors so the frontend can
|
||||
# drop its persisted id. OpenAI doesn't pin an
|
||||
# error code in the public docs for this case, so
|
||||
# match a couple of likely substrings. If we sent
|
||||
# a container_reference and the response is 4xx
|
||||
# with any hint of "container not found / expired",
|
||||
# emit container_invalidated; the next turn will
|
||||
# fall back to container_auto.
|
||||
if (
|
||||
openai_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
|
||||
):
|
||||
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
|
||||
)
|
||||
|
|
@ -1886,6 +2167,28 @@ class ExternalProviderClient:
|
|||
# web_search_calls: { item_id -> {query} }
|
||||
web_search_calls: dict[str, dict[str, Any]] = {}
|
||||
all_url_citations: list[dict[str, str]] = []
|
||||
# Shell-tool (code execution) state. OpenAI emits
|
||||
# `shell_call` items (model requesting a command list)
|
||||
# paired with `shell_call_output` items (execution
|
||||
# results). We mirror the Anthropic code-execution UX
|
||||
# by emitting one `_toolEvent` tool_start per
|
||||
# shell_call and one tool_end per shell_call_output;
|
||||
# they're linked via `shell_call_output.call_id`
|
||||
# matching `shell_call.id`. Items are independent of
|
||||
# web_search (different keyed map).
|
||||
# shell_calls: { call_id -> {commands, output} }
|
||||
shell_calls: dict[str, dict[str, Any]] = {}
|
||||
# Container id captured from the response stream. When
|
||||
# it differs from the inbound id, emit a synthetic
|
||||
# `container_ready` _toolEvent so the frontend can
|
||||
# persist it onto the thread record for the next turn.
|
||||
# Where OpenAI surfaces it is documented loosely; we
|
||||
# probe two known fields (response.container_id on
|
||||
# response.completed, item.environment.container_id on
|
||||
# shell_call output items) and latch the first one we
|
||||
# see.
|
||||
latched_container_id: Optional[str] = None
|
||||
container_id_emitted = False
|
||||
|
||||
def _emit_tool_event(payload: dict[str, Any]) -> str:
|
||||
chunk = {
|
||||
|
|
@ -1902,6 +2205,45 @@ class ExternalProviderClient:
|
|||
}
|
||||
return f"data: {_json.dumps(chunk)}"
|
||||
|
||||
def _format_shell_output(output: Any) -> str:
|
||||
"""Render an OpenAI `shell_call_output.output` list
|
||||
as the preformatted text payload the frontend's
|
||||
CodeExecutionToolUI displays inside a <pre>. Each
|
||||
entry has stdout/stderr/outcome — concatenate them
|
||||
with a separator block per entry and append
|
||||
`return_code` / `(timeout)` annotations only when
|
||||
they convey information beyond "succeeded".
|
||||
"""
|
||||
if not isinstance(output, list):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for entry in output:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
stdout = entry.get("stdout") or ""
|
||||
stderr = entry.get("stderr") or ""
|
||||
outcome = entry.get("outcome") or {}
|
||||
chunk_parts: list[str] = []
|
||||
if stdout:
|
||||
chunk_parts.append(stdout)
|
||||
if stderr:
|
||||
chunk_parts.append(f"--- stderr ---\n{stderr}")
|
||||
if isinstance(outcome, dict):
|
||||
outcome_type = outcome.get("type")
|
||||
if outcome_type == "exit":
|
||||
exit_code = outcome.get("exit_code")
|
||||
if isinstance(exit_code, int) and exit_code != 0:
|
||||
chunk_parts.append(f"return_code: {exit_code}")
|
||||
elif outcome_type == "timeout":
|
||||
chunk_parts.append("(timeout)")
|
||||
if chunk_parts:
|
||||
parts.append("\n".join(chunk_parts))
|
||||
return (
|
||||
"\n--- next command ---\n".join(parts)
|
||||
if parts
|
||||
else "(no output)"
|
||||
)
|
||||
|
||||
def _record_url_citation(payload: dict[str, Any]) -> None:
|
||||
"""Append a url_citation onto the shared all_url_citations
|
||||
list. Dedup by URL — the same source can be cited multiple
|
||||
|
|
@ -2024,6 +2366,37 @@ class ExternalProviderClient:
|
|||
f"ws_{len(web_search_calls)}"
|
||||
)
|
||||
web_search_calls.setdefault(item_id, {"query": ""})
|
||||
# Shell-tool: register the call eagerly so
|
||||
# the matching shell_call_output can link
|
||||
# back even if `done` arrives out of order.
|
||||
# Also probe for container_id on the
|
||||
# environment field — when container_auto
|
||||
# auto-creates one, this is the first place
|
||||
# the new id might surface (OpenAI doesn't
|
||||
# promise this in docs, but the field is
|
||||
# cheap to scan and lets us emit
|
||||
# container_ready earlier than
|
||||
# response.completed).
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "shell_call"
|
||||
):
|
||||
item_id = item.get("id", "") or (
|
||||
f"sc_{len(shell_calls)}"
|
||||
)
|
||||
shell_calls.setdefault(
|
||||
item_id,
|
||||
{"commands": [], "output": None},
|
||||
)
|
||||
env = item.get("environment")
|
||||
if isinstance(env, dict):
|
||||
probe = env.get("container_id") or env.get("id")
|
||||
if (
|
||||
isinstance(probe, str)
|
||||
and probe.startswith("cntr_")
|
||||
and latched_container_id is None
|
||||
):
|
||||
latched_container_id = probe
|
||||
|
||||
elif event_type == "response.output_item.done":
|
||||
item = event.get("item", {})
|
||||
|
|
@ -2079,6 +2452,65 @@ class ExternalProviderClient:
|
|||
"result": "",
|
||||
}
|
||||
)
|
||||
elif item.get("type") == "shell_call":
|
||||
# OpenAI ships the commands array on the
|
||||
# action field. Join them onto one
|
||||
# command string for the tool card —
|
||||
# the renderer is shared with Anthropic
|
||||
# bash, which only carries a single
|
||||
# `command`. Multiple commands in one
|
||||
# shell_call get joined with newlines so
|
||||
# they still render as one card.
|
||||
item_id = item.get("id", "") or (
|
||||
f"sc_{len(shell_calls)}"
|
||||
)
|
||||
action = item.get("action") or {}
|
||||
commands = (
|
||||
action.get("commands")
|
||||
if isinstance(action, dict)
|
||||
else None
|
||||
) or []
|
||||
joined_command = (
|
||||
"\n".join(str(c) for c in commands)
|
||||
if isinstance(commands, list)
|
||||
else ""
|
||||
)
|
||||
shell_calls.setdefault(
|
||||
item_id,
|
||||
{"commands": [], "output": None},
|
||||
)
|
||||
shell_calls[item_id]["commands"] = (
|
||||
list(commands) if isinstance(commands, list) else []
|
||||
)
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "tool_start",
|
||||
"tool_name": "code_execution",
|
||||
"tool_call_id": item_id,
|
||||
"arguments": {
|
||||
"kind": "bash",
|
||||
"command": joined_command,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif item.get("type") == "shell_call_output":
|
||||
# `call_id` links back to the shell_call's
|
||||
# `id`, which is what we used as the
|
||||
# tool_call_id on tool_start. Match on
|
||||
# call_id when present so the matching
|
||||
# card transitions to complete.
|
||||
call_id = item.get("call_id") or item.get("id") or ""
|
||||
output = item.get("output") or []
|
||||
if call_id in shell_calls:
|
||||
shell_calls[call_id]["output"] = output
|
||||
result_text = _format_shell_output(output)
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "tool_end",
|
||||
"tool_call_id": call_id,
|
||||
"result": result_text,
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(event_type, str) and "reasoning" in event_type:
|
||||
reasoning_delta = _extract_reasoning_text(event)
|
||||
|
|
@ -2096,6 +2528,39 @@ class ExternalProviderClient:
|
|||
if reasoning_open:
|
||||
yield _chunk_with_text("</think>")
|
||||
reasoning_open = False
|
||||
# Probe response.container_id (top-level) and
|
||||
# response.container.id for the shell-tool
|
||||
# container id. OpenAI's docs don't pin the
|
||||
# exact field, so we scan both. Emit
|
||||
# `container_ready` only when the value
|
||||
# differs from the inbound one — no churn on
|
||||
# reuse.
|
||||
response_obj = event.get("response") or {}
|
||||
if isinstance(response_obj, dict):
|
||||
probe_id = response_obj.get("container_id")
|
||||
if not probe_id:
|
||||
container_field = response_obj.get("container")
|
||||
if isinstance(container_field, dict):
|
||||
probe_id = container_field.get("id")
|
||||
if (
|
||||
isinstance(probe_id, str)
|
||||
and probe_id.startswith("cntr_")
|
||||
and latched_container_id is None
|
||||
):
|
||||
latched_container_id = probe_id
|
||||
if (
|
||||
latched_container_id
|
||||
and not container_id_emitted
|
||||
and latched_container_id
|
||||
!= openai_code_exec_container_id
|
||||
):
|
||||
yield _emit_tool_event(
|
||||
{
|
||||
"type": "container_ready",
|
||||
"container_id": latched_container_id,
|
||||
}
|
||||
)
|
||||
container_id_emitted = True
|
||||
# Apply the aggregated citation list onto the
|
||||
# *last* web_search call by overwriting its
|
||||
# tool_end result. The frontend's
|
||||
|
|
@ -2226,10 +2691,19 @@ class ExternalProviderClient:
|
|||
details = last_usage.get("input_tokens_details")
|
||||
if isinstance(details, dict):
|
||||
cached_input_tokens = details.get("cached_tokens")
|
||||
code_execution_requested = code_execution_enabled_openai
|
||||
code_execution_invocations = len(shell_calls)
|
||||
code_execution_results = sum(
|
||||
1 for sc in shell_calls.values() if sc.get("output") is not None
|
||||
)
|
||||
logger.info(
|
||||
"OpenAI Responses stream complete (model=%s, "
|
||||
"web_search_requested=%s, web_search_invocations=%s, "
|
||||
"citations=%s, queries=%s, reasoning_emitted=%s, "
|
||||
"code_execution_requested=%s, "
|
||||
"code_execution_invocations=%s, "
|
||||
"code_execution_results=%s, "
|
||||
"container_id_in=%s, container_id_out=%s, "
|
||||
"input_tokens=%s, output_tokens=%s, "
|
||||
"cached_input_tokens=%s)",
|
||||
model,
|
||||
|
|
@ -2238,6 +2712,11 @@ class ExternalProviderClient:
|
|||
total_citations,
|
||||
queries,
|
||||
reasoning_emitted,
|
||||
code_execution_requested,
|
||||
code_execution_invocations,
|
||||
code_execution_results,
|
||||
openai_code_exec_container_id,
|
||||
latched_container_id,
|
||||
(last_usage or {}).get("input_tokens"),
|
||||
(last_usage or {}).get("output_tokens"),
|
||||
cached_input_tokens,
|
||||
|
|
@ -2358,6 +2837,90 @@ class ExternalProviderClient:
|
|||
)
|
||||
raise
|
||||
|
||||
def _container_headers(self) -> dict[str, str]:
|
||||
"""Auth headers plus the OpenAI-Beta opt-in for /v1/containers.
|
||||
|
||||
OpenAI's containers API requires ``OpenAI-Beta: containers=v1``.
|
||||
Without it, DELETE silently no-ops: the API returns 200 with a
|
||||
``{"deleted": true}`` body but does not actually remove the
|
||||
container (verified 2026-05-15). The header is required for
|
||||
list / create / delete to behave consistently.
|
||||
"""
|
||||
headers = self._auth_headers()
|
||||
headers["OpenAI-Beta"] = "containers=v1"
|
||||
return headers
|
||||
|
||||
async def list_openai_containers(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
GET /v1/containers on the user's OpenAI account.
|
||||
|
||||
Returns the raw container records (id, name, created_at,
|
||||
last_active_at, expires_after, status). The route layer
|
||||
reshapes these into the UI summary shape.
|
||||
|
||||
Only valid against api.openai.com — non-cloud OpenAI-compat
|
||||
servers don't implement /v1/containers and would 404 here.
|
||||
Caller is responsible for the is_openai_cloud guard.
|
||||
"""
|
||||
response = await _http_client.get(
|
||||
f"{self.base_url}/containers",
|
||||
headers = self._container_headers(),
|
||||
timeout = self._timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
containers = data.get("data") if isinstance(data, dict) else None
|
||||
return list(containers) if isinstance(containers, list) else []
|
||||
|
||||
async def create_openai_container(
|
||||
self,
|
||||
name: str,
|
||||
ttl_minutes: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
POST /v1/containers with ``expires_after.anchor="last_active_at"``.
|
||||
``ttl_minutes`` is the idle timeout — every API call that
|
||||
touches the container resets the timer.
|
||||
"""
|
||||
body = {
|
||||
"name": name,
|
||||
"expires_after": {
|
||||
"anchor": "last_active_at",
|
||||
"minutes": ttl_minutes,
|
||||
},
|
||||
}
|
||||
response = await _http_client.post(
|
||||
f"{self.base_url}/containers",
|
||||
json = body,
|
||||
headers = self._container_headers(),
|
||||
timeout = self._timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def delete_openai_container(self, container_id: str) -> None:
|
||||
"""DELETE /v1/containers/{id}. 404s are surfaced as HTTPError.
|
||||
|
||||
Verifies the response body reports ``deleted: true``. OpenAI
|
||||
returns a 2xx ``deleted: true`` body even when the request is
|
||||
silently rejected (e.g. missing OpenAI-Beta header), so a
|
||||
status-only check is not sufficient.
|
||||
"""
|
||||
response = await _http_client.delete(
|
||||
f"{self.base_url}/containers/{container_id}",
|
||||
headers = self._container_headers(),
|
||||
timeout = self._timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
if not (isinstance(payload, dict) and payload.get("deleted") is True):
|
||||
raise httpx.HTTPError(
|
||||
f"OpenAI did not confirm container deletion: {response.text[:200]}"
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""No-op — the underlying client is shared across requests."""
|
||||
|
||||
|
|
|
|||
|
|
@ -604,6 +604,81 @@ class ChatCompletionRequest(BaseModel):
|
|||
"vllm, local, etc.). Treated as enabled when omitted."
|
||||
),
|
||||
)
|
||||
openai_code_exec_container_id: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
"[x-unsloth] OpenAI shell-tool container id from the prior response "
|
||||
"in the same chat thread. When set and `code_execution` is in "
|
||||
"`enabled_tools`, the next /v1/responses call uses "
|
||||
"environment.type='container_reference' so filesystem state "
|
||||
"persists across turns. Unset → environment.type='container_auto' "
|
||||
"and OpenAI creates a fresh container. Only meaningful for the "
|
||||
"OpenAI cloud + gpt-5.5 family path; ignored otherwise."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── OpenAI shell-tool container management ─────────────────────
|
||||
|
||||
|
||||
class OpenAIContainerRequest(BaseModel):
|
||||
"""
|
||||
Shared body for the three OpenAI container endpoints (list / create
|
||||
/ delete). Carries the encrypted API key + base URL so the route
|
||||
handler can decrypt it and proxy to the user's OpenAI account.
|
||||
Same pattern as the inference proxy endpoints — keeps the key off
|
||||
persistent storage on the backend.
|
||||
"""
|
||||
|
||||
encrypted_api_key: str = Field(
|
||||
...,
|
||||
description = "[x-unsloth] RSA-encrypted, base64-encoded OpenAI API key.",
|
||||
)
|
||||
provider_base_url: Optional[str] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] OpenAI base URL. Only api.openai.com is supported; non-cloud bases are rejected with 400.",
|
||||
)
|
||||
|
||||
|
||||
class CreateOpenAIContainerBody(OpenAIContainerRequest):
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length = 1,
|
||||
max_length = 256,
|
||||
description = "Human-readable container name. Surfaces in the picker UI.",
|
||||
)
|
||||
ttl_minutes: int = Field(
|
||||
20,
|
||||
ge = 1,
|
||||
le = 10080, # 1 week
|
||||
description = (
|
||||
"Idle-timeout TTL the new container will inherit (anchor="
|
||||
"last_active_at). OpenAI's default is 20; we cap at one "
|
||||
"week as a safety bound."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DeleteOpenAIContainerBody(OpenAIContainerRequest):
|
||||
container_id: str = Field(
|
||||
...,
|
||||
description = "OpenAI container id (cntr_...) to delete.",
|
||||
)
|
||||
|
||||
|
||||
class OpenAIContainerSummary(BaseModel):
|
||||
"""One row from GET /v1/containers, reshaped for the UI."""
|
||||
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
created_at: Optional[int] = None
|
||||
last_active_at: Optional[int] = None
|
||||
expires_after_minutes: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class ListOpenAIContainersResponse(BaseModel):
|
||||
containers: list[OpenAIContainerSummary]
|
||||
|
||||
|
||||
# ── Streaming response chunks ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -95,8 +95,9 @@ class ProviderModelsRequest(BaseModel):
|
|||
"""Request to list models from an external provider."""
|
||||
|
||||
provider_type: str = Field(..., description = "Provider type from the registry")
|
||||
encrypted_api_key: str = Field(
|
||||
..., description = "RSA-encrypted, base64-encoded API key"
|
||||
encrypted_api_key: Optional[str] = Field(
|
||||
None,
|
||||
description = "RSA-encrypted, base64-encoded API key (optional for local providers)",
|
||||
)
|
||||
base_url: Optional[str] = Field(
|
||||
None, description = "Custom base URL (overrides registry default)"
|
||||
|
|
@ -110,8 +111,9 @@ class ProviderTestRequest(BaseModel):
|
|||
"""Request to test connectivity to an external provider."""
|
||||
|
||||
provider_type: str = Field(..., description = "Provider type from the registry")
|
||||
encrypted_api_key: str = Field(
|
||||
..., description = "RSA-encrypted, base64-encoded API key"
|
||||
encrypted_api_key: Optional[str] = Field(
|
||||
None,
|
||||
description = "RSA-encrypted, base64-encoded API key (optional for local providers)",
|
||||
)
|
||||
base_url: Optional[str] = Field(
|
||||
None, description = "Custom base URL (overrides registry default)"
|
||||
|
|
|
|||
|
|
@ -194,6 +194,11 @@ from models.inference import (
|
|||
AnthropicResponseTextBlock,
|
||||
AnthropicResponseToolUseBlock,
|
||||
AnthropicUsage,
|
||||
CreateOpenAIContainerBody,
|
||||
DeleteOpenAIContainerBody,
|
||||
ListOpenAIContainersResponse,
|
||||
OpenAIContainerRequest,
|
||||
OpenAIContainerSummary,
|
||||
)
|
||||
from core.inference.anthropic_compat import (
|
||||
anthropic_messages_to_openai,
|
||||
|
|
@ -1554,15 +1559,16 @@ async def _proxy_to_external_provider(
|
|||
detail = f"Unknown provider type: {provider_type}",
|
||||
)
|
||||
|
||||
# Decrypt the API key
|
||||
try:
|
||||
api_key = decrypt_api_key(payload.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning("external_provider.decrypt_failed", error = str(exc))
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
|
||||
)
|
||||
api_key = ""
|
||||
if payload.encrypted_api_key:
|
||||
try:
|
||||
api_key = decrypt_api_key(payload.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning("external_provider.decrypt_failed", error = str(exc))
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
|
||||
)
|
||||
|
||||
model = payload.external_model or payload.model
|
||||
if model == "default":
|
||||
|
|
@ -1597,6 +1603,7 @@ async def _proxy_to_external_provider(
|
|||
reasoning_effort = payload.reasoning_effort,
|
||||
enabled_tools = payload.enabled_tools,
|
||||
enable_prompt_caching = payload.enable_prompt_caching,
|
||||
openai_code_exec_container_id = payload.openai_code_exec_container_id,
|
||||
stream = payload.stream,
|
||||
)
|
||||
try:
|
||||
|
|
@ -1626,6 +1633,158 @@ async def _proxy_to_external_provider(
|
|||
)
|
||||
|
||||
|
||||
# ── OpenAI shell-tool container management ───────────────────────
|
||||
|
||||
|
||||
def _resolve_openai_cloud_client(
|
||||
body: OpenAIContainerRequest,
|
||||
) -> ExternalProviderClient:
|
||||
"""
|
||||
Decrypt the API key + validate the base URL points at OpenAI cloud,
|
||||
then build an ExternalProviderClient for the three container CRUD
|
||||
endpoints below. The shell tool only exists on api.openai.com, so
|
||||
rejecting non-cloud bases up front prevents confusing 404s on
|
||||
ollama / llama.cpp / vLLM / custom presets.
|
||||
"""
|
||||
base_url = body.provider_base_url or get_base_url("openai")
|
||||
if not base_url or "api.openai.com" not in base_url:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = (
|
||||
"OpenAI container management is only available on the "
|
||||
"managed cloud (api.openai.com). The provider's base URL "
|
||||
f"points at {base_url!r}."
|
||||
),
|
||||
)
|
||||
try:
|
||||
api_key = decrypt_api_key(body.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning("external_provider.decrypt_failed", error = str(exc))
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
|
||||
)
|
||||
return ExternalProviderClient(
|
||||
provider_type = "openai",
|
||||
base_url = base_url,
|
||||
api_key = api_key,
|
||||
)
|
||||
|
||||
|
||||
def _summarize_container(raw: dict) -> OpenAIContainerSummary:
|
||||
expires = raw.get("expires_after")
|
||||
expires_minutes: Optional[int] = None
|
||||
if isinstance(expires, dict):
|
||||
minutes = expires.get("minutes")
|
||||
if isinstance(minutes, int):
|
||||
expires_minutes = minutes
|
||||
return OpenAIContainerSummary(
|
||||
id = str(raw.get("id") or ""),
|
||||
name = raw.get("name"),
|
||||
created_at = raw.get("created_at")
|
||||
if isinstance(raw.get("created_at"), int)
|
||||
else None,
|
||||
last_active_at = raw.get("last_active_at")
|
||||
if isinstance(raw.get("last_active_at"), int)
|
||||
else None,
|
||||
expires_after_minutes = expires_minutes,
|
||||
status = raw.get("status") if isinstance(raw.get("status"), str) else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/external/openai/containers/list",
|
||||
response_model = ListOpenAIContainersResponse,
|
||||
)
|
||||
async def list_openai_containers(
|
||||
body: OpenAIContainerRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ListOpenAIContainersResponse:
|
||||
"""List the user's OpenAI shell-tool containers."""
|
||||
client = _resolve_openai_cloud_client(body)
|
||||
try:
|
||||
try:
|
||||
raw = await client.list_openai_containers()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:500] if exc.response is not None else str(exc)
|
||||
raise HTTPException(
|
||||
status_code = exc.response.status_code if exc.response else 502,
|
||||
detail = f"OpenAI rejected /containers list: {detail}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 502,
|
||||
detail = f"Failed to reach OpenAI: {exc}",
|
||||
)
|
||||
return ListOpenAIContainersResponse(
|
||||
containers = [_summarize_container(c) for c in raw if isinstance(c, dict)],
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/external/openai/containers/create",
|
||||
response_model = OpenAIContainerSummary,
|
||||
)
|
||||
async def create_openai_container(
|
||||
body: CreateOpenAIContainerBody,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> OpenAIContainerSummary:
|
||||
"""Create a named container with the user-chosen idle TTL."""
|
||||
client = _resolve_openai_cloud_client(body)
|
||||
try:
|
||||
try:
|
||||
raw = await client.create_openai_container(
|
||||
name = body.name,
|
||||
ttl_minutes = body.ttl_minutes,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:500] if exc.response is not None else str(exc)
|
||||
raise HTTPException(
|
||||
status_code = exc.response.status_code if exc.response else 502,
|
||||
detail = f"OpenAI rejected /containers create: {detail}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 502,
|
||||
detail = f"Failed to reach OpenAI: {exc}",
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(
|
||||
status_code = 502,
|
||||
detail = "OpenAI returned an unexpected container payload.",
|
||||
)
|
||||
return _summarize_container(raw)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@router.post("/external/openai/containers/delete", status_code = 204)
|
||||
async def delete_openai_container(
|
||||
body: DeleteOpenAIContainerBody,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> None:
|
||||
"""Delete a named container by id."""
|
||||
client = _resolve_openai_cloud_client(body)
|
||||
try:
|
||||
try:
|
||||
await client.delete_openai_container(body.container_id)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:500] if exc.response is not None else str(exc)
|
||||
raise HTTPException(
|
||||
status_code = exc.response.status_code if exc.response else 502,
|
||||
detail = f"OpenAI rejected /containers delete: {detail}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 502,
|
||||
detail = f"Failed to reach OpenAI: {exc}",
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def openai_chat_completions(
|
||||
payload: ChatCompletionRequest,
|
||||
|
|
@ -1646,7 +1805,8 @@ async def openai_chat_completions(
|
|||
- Other models → Unsloth/transformers via InferenceBackend
|
||||
"""
|
||||
# ── External provider routing ────────────────────────────────
|
||||
if payload.encrypted_api_key and (payload.provider_id or payload.provider_type):
|
||||
# encrypted_api_key is optional — local providers (llama.cpp / vLLM / Ollama) may run without auth.
|
||||
if payload.provider_id or payload.provider_type:
|
||||
return await _proxy_to_external_provider(payload, request)
|
||||
|
||||
llama_backend = get_llama_cpp_backend()
|
||||
|
|
|
|||
|
|
@ -200,14 +200,18 @@ async def test_provider(
|
|||
detail = f"Unknown provider type: {payload.provider_type}",
|
||||
)
|
||||
|
||||
try:
|
||||
api_key = decrypt_api_key(payload.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc)
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
|
||||
)
|
||||
api_key = ""
|
||||
if payload.encrypted_api_key:
|
||||
try:
|
||||
api_key = decrypt_api_key(payload.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to decrypt API key (%s): %s", type(exc).__name__, exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
|
||||
)
|
||||
|
||||
base_url = payload.base_url or info["base_url"]
|
||||
client = ExternalProviderClient(
|
||||
|
|
@ -265,14 +269,18 @@ async def list_provider_models(
|
|||
detail = f"Unknown provider type: {payload.provider_type}",
|
||||
)
|
||||
|
||||
try:
|
||||
api_key = decrypt_api_key(payload.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc)
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
|
||||
)
|
||||
api_key = ""
|
||||
if payload.encrypted_api_key:
|
||||
try:
|
||||
api_key = decrypt_api_key(payload.encrypted_api_key)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to decrypt API key (%s): %s", type(exc).__name__, exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
|
||||
)
|
||||
|
||||
if info.get("model_list_mode") == "curated":
|
||||
return [
|
||||
|
|
|
|||
419
studio/backend/tests/test_anthropic_code_execution.py
Normal file
419
studio/backend/tests/test_anthropic_code_execution.py
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Unit tests for Anthropic's server-side `code_execution_20250825` tool
|
||||
translation in `_stream_anthropic`.
|
||||
|
||||
Covers:
|
||||
- Request body: when ``enabled_tools=["code_execution"]``, the outbound
|
||||
``tools`` array carries ``{"type": "code_execution_20250825", "name":
|
||||
"code_execution"}`` and the ``anthropic-beta`` header includes
|
||||
``code-execution-2025-08-25``.
|
||||
- Combined request: ``enabled_tools=["web_search", "code_execution"]``
|
||||
sends both tool entries; the beta header still merges the code-exec
|
||||
flag onto whatever the registry contributed.
|
||||
- SSE translation: a `bash_code_execution` server_tool_use +
|
||||
`bash_code_execution_tool_result` pair emits one tool_start and one
|
||||
tool_end ``_toolEvent`` chunk with the expected arguments and result.
|
||||
- SSE translation: a `text_editor_code_execution` create + result emits
|
||||
a tool_start with ``kind="text_editor"`` + parsed args, and tool_end
|
||||
with ``"Created"`` (or ``"Updated"``) based on the ``is_file_update``
|
||||
flag.
|
||||
- Error path: a ``bash_code_execution_tool_result_error`` with
|
||||
``error_code="container_expired"`` renders as ``"Error:
|
||||
container_expired"`` in the tool_end ``result``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from core.inference import external_provider as ep_mod
|
||||
from core.inference.external_provider import ExternalProviderClient
|
||||
|
||||
|
||||
def _drive(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
async def _collect(agen):
|
||||
out = []
|
||||
async for line in agen:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def _mock_http_client(monkeypatch, handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
|
||||
|
||||
|
||||
def _make_client() -> ExternalProviderClient:
|
||||
return ExternalProviderClient(
|
||||
provider_type = "anthropic",
|
||||
base_url = "https://api.anthropic.com/v1",
|
||||
api_key = "sk-ant-test",
|
||||
)
|
||||
|
||||
|
||||
def _anthropic_sse(events: list[dict]) -> bytes:
|
||||
chunks: list[str] = []
|
||||
for event in events:
|
||||
chunks.append(f"event: {event['type']}")
|
||||
chunks.append(f"data: {json.dumps(event)}")
|
||||
chunks.append("")
|
||||
return ("\n".join(chunks) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _tool_events(lines: list[str]) -> list[dict]:
|
||||
"""Extract `_toolEvent` payloads from emitted SSE data lines."""
|
||||
out: list[dict] = []
|
||||
for line in lines:
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[len("data:") :].strip()
|
||||
if not raw or raw == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict) and "_toolEvent" in parsed:
|
||||
out.append(parsed["_toolEvent"])
|
||||
return out
|
||||
|
||||
|
||||
def test_code_execution_tool_appended_to_request_body(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
captured["headers"] = dict(request.headers)
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _anthropic_sse([{"type": "message_stop"}]),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
async for _ in client._stream_anthropic(
|
||||
messages = [{"role": "user", "content": "compute 2 + 2"}],
|
||||
model = "claude-opus-4-7",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enabled_tools = ["code_execution"],
|
||||
):
|
||||
pass
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
|
||||
body = captured["body"]
|
||||
tools = body.get("tools") or []
|
||||
assert {
|
||||
"type": "code_execution_20250825",
|
||||
"name": "code_execution",
|
||||
} in tools
|
||||
# No web_search entry when only code_execution is enabled.
|
||||
assert all(t.get("type") != "web_search_20250305" for t in tools)
|
||||
# Beta header carries the documented flag.
|
||||
beta_header = captured["headers"].get("anthropic-beta", "")
|
||||
assert "code-execution-2025-08-25" in beta_header
|
||||
|
||||
|
||||
def test_code_execution_with_web_search_sends_both_tools(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
captured["headers"] = dict(request.headers)
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _anthropic_sse([{"type": "message_stop"}]),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
async for _ in client._stream_anthropic(
|
||||
messages = [{"role": "user", "content": "look it up and chart it"}],
|
||||
model = "claude-opus-4-7",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enabled_tools = ["web_search", "code_execution"],
|
||||
):
|
||||
pass
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
|
||||
tools = captured["body"].get("tools") or []
|
||||
tool_types = {t.get("type") for t in tools if isinstance(t, dict)}
|
||||
assert "web_search_20250305" in tool_types
|
||||
assert "code_execution_20250825" in tool_types
|
||||
assert "code-execution-2025-08-25" in captured["headers"].get("anthropic-beta", "")
|
||||
|
||||
|
||||
def test_no_code_execution_tool_when_pill_off(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
captured["headers"] = dict(request.headers)
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _anthropic_sse([{"type": "message_stop"}]),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
async for _ in client._stream_anthropic(
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
model = "claude-opus-4-7",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
):
|
||||
pass
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
|
||||
tools = captured["body"].get("tools") or []
|
||||
assert all(t.get("type") != "code_execution_20250825" for t in tools)
|
||||
# Beta header must NOT mention code-execution when the tool isn't on
|
||||
# — that flag is opt-in only.
|
||||
assert "code-execution-2025-08-25" not in captured["headers"].get(
|
||||
"anthropic-beta", ""
|
||||
)
|
||||
|
||||
|
||||
def test_bash_code_execution_emits_tool_start_and_end(monkeypatch):
|
||||
sse_events = [
|
||||
{"type": "message_start", "message": {"usage": {}}},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {
|
||||
"type": "server_tool_use",
|
||||
"id": "srvtoolu_1",
|
||||
"name": "bash_code_execution",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": '{"command": "ls -la"}',
|
||||
},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": "srvtoolu_1",
|
||||
"content": {
|
||||
"type": "bash_code_execution_result",
|
||||
"stdout": "total 24\ndrwxr-xr-x 2 user user 4096 Jan 1 12:00 .",
|
||||
"stderr": "",
|
||||
"return_code": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 1},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _anthropic_sse(sse_events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_anthropic(
|
||||
messages = [{"role": "user", "content": "list files"}],
|
||||
model = "claude-opus-4-7",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enabled_tools = ["code_execution"],
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
|
||||
assert len(events) == 2
|
||||
start, end = events
|
||||
assert start["type"] == "tool_start"
|
||||
assert start["tool_name"] == "code_execution"
|
||||
assert start["tool_call_id"] == "srvtoolu_1"
|
||||
assert start["arguments"] == {"kind": "bash", "command": "ls -la"}
|
||||
|
||||
assert end["type"] == "tool_end"
|
||||
assert end["tool_call_id"] == "srvtoolu_1"
|
||||
assert "total 24" in end["result"]
|
||||
# Non-zero return_code not present, so no return_code line.
|
||||
assert "return_code:" not in end["result"]
|
||||
|
||||
|
||||
def test_text_editor_create_emits_kind_and_status(monkeypatch):
|
||||
sse_events = [
|
||||
{"type": "message_start", "message": {"usage": {}}},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {
|
||||
"type": "server_tool_use",
|
||||
"id": "srvtoolu_2",
|
||||
"name": "text_editor_code_execution",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": (
|
||||
'{"command": "create", "path": "new_file.txt", '
|
||||
'"file_text": "hi"}'
|
||||
),
|
||||
},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {
|
||||
"type": "text_editor_code_execution_tool_result",
|
||||
"tool_use_id": "srvtoolu_2",
|
||||
"content": {
|
||||
"type": "text_editor_code_execution_result",
|
||||
"is_file_update": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 1},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _anthropic_sse(sse_events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_anthropic(
|
||||
messages = [{"role": "user", "content": "write a file"}],
|
||||
model = "claude-opus-4-7",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enabled_tools = ["code_execution"],
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
|
||||
assert len(events) == 2
|
||||
start, end = events
|
||||
assert start["arguments"]["kind"] == "text_editor"
|
||||
assert start["arguments"]["command"] == "create"
|
||||
assert start["arguments"]["path"] == "new_file.txt"
|
||||
assert end["result"] == "Created"
|
||||
|
||||
|
||||
def test_code_execution_error_renders_error_code(monkeypatch):
|
||||
sse_events = [
|
||||
{"type": "message_start", "message": {"usage": {}}},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {
|
||||
"type": "server_tool_use",
|
||||
"id": "srvtoolu_3",
|
||||
"name": "bash_code_execution",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": '{"command": "echo broken"}',
|
||||
},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": "srvtoolu_3",
|
||||
"content": {
|
||||
"type": "bash_code_execution_tool_result_error",
|
||||
"error_code": "container_expired",
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 1},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _anthropic_sse(sse_events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_anthropic(
|
||||
messages = [{"role": "user", "content": "run it"}],
|
||||
model = "claude-opus-4-7",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enabled_tools = ["code_execution"],
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
|
||||
assert len(events) == 2
|
||||
end = events[1]
|
||||
assert end["type"] == "tool_end"
|
||||
assert end["result"] == "Error: container_expired"
|
||||
391
studio/backend/tests/test_openai_code_execution.py
Normal file
391
studio/backend/tests/test_openai_code_execution.py
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Unit tests for OpenAI's server-side `shell` tool translation in
|
||||
`_stream_openai_responses`.
|
||||
|
||||
Covers:
|
||||
- Request body: ``enabled_tools=["code_execution"]`` on the OpenAI
|
||||
cloud base_url appends ``{"type": "shell", "environment": {"type":
|
||||
"container_auto"}}`` to ``tools``.
|
||||
- Container reuse: when ``openai_code_exec_container_id`` is provided,
|
||||
the outgoing ``environment.type`` flips to ``"container_reference"``
|
||||
and the id propagates.
|
||||
- Cloud guard: code_execution on a non-cloud base_url (e.g. a local
|
||||
OpenAI-compat preset / ollama / llama.cpp / vLLM) does NOT add the
|
||||
shell tool, preventing a guaranteed 400 from those servers.
|
||||
- SSE translation: a `shell_call` + `shell_call_output` pair emits one
|
||||
``_toolEvent`` `tool_start` (`tool_name="code_execution"`,
|
||||
`arguments.kind="bash"`) and one `tool_end` whose `result` contains
|
||||
the joined stdout from the shell_call_output entries.
|
||||
- Container surfacing: container_id captured from
|
||||
`response.completed.container_id` is emitted as a synthetic
|
||||
`container_ready` `_toolEvent` (only when it differs from the
|
||||
inbound id).
|
||||
- Stale-container handling: 400 with "container expired" body emits a
|
||||
`container_invalidated` event before propagating the error.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from core.inference import external_provider as ep_mod
|
||||
from core.inference.external_provider import ExternalProviderClient
|
||||
|
||||
|
||||
def _drive(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
async def _collect(agen):
|
||||
out = []
|
||||
async for line in agen:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def _mock_http_client(monkeypatch, handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
|
||||
|
||||
|
||||
def _make_client(base_url: str = "https://api.openai.com/v1") -> ExternalProviderClient:
|
||||
return ExternalProviderClient(
|
||||
provider_type = "openai",
|
||||
base_url = base_url,
|
||||
api_key = "sk-test",
|
||||
)
|
||||
|
||||
|
||||
def _openai_sse(events: list[dict]) -> bytes:
|
||||
chunks: list[str] = []
|
||||
for event in events:
|
||||
chunks.append(f"event: {event['type']}")
|
||||
chunks.append(f"data: {json.dumps(event)}")
|
||||
chunks.append("")
|
||||
return ("\n".join(chunks) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _tool_events(lines: list[str]) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for line in lines:
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[len("data:") :].strip()
|
||||
if not raw or raw == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict) and "_toolEvent" in parsed:
|
||||
out.append(parsed["_toolEvent"])
|
||||
return out
|
||||
|
||||
|
||||
def test_shell_tool_added_on_cloud_with_container_auto(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _openai_sse([{"type": "response.completed", "response": {}}]),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
async for _ in client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "compute 2+2"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
):
|
||||
pass
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
|
||||
tools = captured["body"].get("tools") or []
|
||||
assert {
|
||||
"type": "shell",
|
||||
"environment": {"type": "container_auto"},
|
||||
} in tools
|
||||
|
||||
|
||||
def test_shell_tool_uses_container_reference_when_id_supplied(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _openai_sse([{"type": "response.completed", "response": {}}]),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
async for _ in client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "what did i write earlier"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
openai_code_exec_container_id = "cntr_abc123",
|
||||
):
|
||||
pass
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
|
||||
tools = captured["body"].get("tools") or []
|
||||
assert {
|
||||
"type": "shell",
|
||||
"environment": {
|
||||
"type": "container_reference",
|
||||
"container_id": "cntr_abc123",
|
||||
},
|
||||
} in tools
|
||||
|
||||
|
||||
def test_shell_tool_refused_for_non_cloud_base_url(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _openai_sse([{"type": "response.completed", "response": {}}]),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client(base_url = "http://localhost:11434/v1")
|
||||
async for _ in client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
):
|
||||
pass
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
|
||||
tools = captured["body"].get("tools") or []
|
||||
# Shell tool must NOT leak to local OpenAI-compat servers — those
|
||||
# 400 on the unknown tool type.
|
||||
assert all(t.get("type") != "shell" for t in tools)
|
||||
|
||||
|
||||
def test_shell_call_emits_tool_start_and_end(monkeypatch):
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "shell_call",
|
||||
"id": "scall_1",
|
||||
"action": {"commands": ["ls -la"]},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "shell_call",
|
||||
"id": "scall_1",
|
||||
"action": {"commands": ["ls -la"]},
|
||||
"status": "completed",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "shell_call_output",
|
||||
"id": "scout_1",
|
||||
"call_id": "scall_1",
|
||||
"output": [
|
||||
{
|
||||
"stdout": "total 24\ndrwxr-xr-x .",
|
||||
"stderr": "",
|
||||
"outcome": {"type": "exit", "exit_code": 0},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _openai_sse(sse_events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "list files"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
starts = [e for e in events if e["type"] == "tool_start"]
|
||||
ends = [e for e in events if e["type"] == "tool_end"]
|
||||
assert len(starts) == 1
|
||||
assert len(ends) == 1
|
||||
assert starts[0]["tool_name"] == "code_execution"
|
||||
assert starts[0]["tool_call_id"] == "scall_1"
|
||||
assert starts[0]["arguments"] == {"kind": "bash", "command": "ls -la"}
|
||||
assert ends[0]["tool_call_id"] == "scall_1"
|
||||
assert "total 24" in ends[0]["result"]
|
||||
|
||||
|
||||
def test_container_ready_emitted_when_new_id_surfaces(monkeypatch):
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {"container_id": "cntr_new_456"},
|
||||
},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _openai_sse(sse_events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "do stuff"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
ready = [e for e in events if e["type"] == "container_ready"]
|
||||
assert len(ready) == 1
|
||||
assert ready[0]["container_id"] == "cntr_new_456"
|
||||
|
||||
|
||||
def test_container_ready_not_emitted_when_id_unchanged(monkeypatch):
|
||||
sse_events = [
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {"container_id": "cntr_same_789"},
|
||||
},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _openai_sse(sse_events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "do stuff"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
openai_code_exec_container_id = "cntr_same_789",
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
# No churn — id matches the one already on the thread record.
|
||||
assert not any(e["type"] == "container_ready" for e in events)
|
||||
|
||||
|
||||
def test_stale_container_emits_invalidated(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
400,
|
||||
content = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": "container has expired",
|
||||
"type": "invalid_request_error",
|
||||
}
|
||||
}
|
||||
).encode("utf-8"),
|
||||
headers = {"content-type": "application/json"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
return await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = 4096,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
enabled_tools = ["code_execution"],
|
||||
openai_code_exec_container_id = "cntr_stale_999",
|
||||
)
|
||||
)
|
||||
|
||||
lines = _drive(run())
|
||||
events = _tool_events(lines)
|
||||
invalidated = [e for e in events if e["type"] == "container_invalidated"]
|
||||
assert len(invalidated) == 1
|
||||
151
studio/backend/tests/test_openai_container_crud.py
Normal file
151
studio/backend/tests/test_openai_container_crud.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Unit tests for the /v1/containers CRUD client methods.
|
||||
|
||||
Covers:
|
||||
- All three calls (list / create / delete) send
|
||||
``OpenAI-Beta: containers=v1``. Without it, OpenAI silently no-ops
|
||||
the DELETE while still returning 200 ``{"deleted": true}``.
|
||||
- ``delete_openai_container`` raises when the response body does not
|
||||
report ``{"deleted": true}``, even on a 2xx response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from core.inference import external_provider as ep_mod
|
||||
from core.inference.external_provider import ExternalProviderClient
|
||||
|
||||
|
||||
def _drive(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _mock_http_client(monkeypatch, handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
|
||||
|
||||
|
||||
def _make_client() -> ExternalProviderClient:
|
||||
return ExternalProviderClient(
|
||||
provider_type = "openai",
|
||||
base_url = "https://api.openai.com/v1",
|
||||
api_key = "sk-test",
|
||||
)
|
||||
|
||||
|
||||
def test_list_sends_openai_beta_header(monkeypatch):
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["headers"] = dict(request.headers)
|
||||
seen["url"] = str(request.url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json = {"data": [{"id": "cntr_x", "name": "auto"}]},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
result = _drive(_make_client().list_openai_containers())
|
||||
|
||||
assert result == [{"id": "cntr_x", "name": "auto"}]
|
||||
assert seen["headers"].get("openai-beta") == "containers=v1"
|
||||
assert seen["url"] == "https://api.openai.com/v1/containers"
|
||||
|
||||
|
||||
def test_create_sends_openai_beta_header(monkeypatch):
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["headers"] = dict(request.headers)
|
||||
seen["body"] = json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(200, json = {"id": "cntr_new", "name": "analysis"})
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
result = _drive(
|
||||
_make_client().create_openai_container(name = "analysis", ttl_minutes = 30)
|
||||
)
|
||||
|
||||
assert result == {"id": "cntr_new", "name": "analysis"}
|
||||
assert seen["headers"].get("openai-beta") == "containers=v1"
|
||||
assert seen["body"]["name"] == "analysis"
|
||||
assert seen["body"]["expires_after"] == {
|
||||
"anchor": "last_active_at",
|
||||
"minutes": 30,
|
||||
}
|
||||
|
||||
|
||||
def test_delete_sends_openai_beta_header_and_accepts_confirmation(monkeypatch):
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["headers"] = dict(request.headers)
|
||||
seen["url"] = str(request.url)
|
||||
seen["method"] = request.method
|
||||
return httpx.Response(
|
||||
200,
|
||||
json = {"id": "cntr_x", "object": "container.deleted", "deleted": True},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
_drive(_make_client().delete_openai_container("cntr_x"))
|
||||
|
||||
assert seen["method"] == "DELETE"
|
||||
assert seen["url"] == "https://api.openai.com/v1/containers/cntr_x"
|
||||
assert seen["headers"].get("openai-beta") == "containers=v1"
|
||||
|
||||
|
||||
def test_delete_raises_when_response_lacks_deleted_true(monkeypatch):
|
||||
"""OpenAI returns 200 ``{"deleted": true}`` even when the request is
|
||||
silently rejected (e.g. before we started sending OpenAI-Beta).
|
||||
Defensive guard: when the body omits ``deleted: true``, surface it
|
||||
as an error so the UI can report the failure instead of falsely
|
||||
reporting success."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
# 200 but no deleted flag — simulate an unexpected payload shape.
|
||||
return httpx.Response(200, json = {"id": "cntr_x", "object": "container"})
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
|
||||
_drive(_make_client().delete_openai_container("cntr_x"))
|
||||
|
||||
|
||||
def test_delete_raises_when_deleted_is_false(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json = {"id": "cntr_x", "object": "container.deleted", "deleted": False},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
|
||||
_drive(_make_client().delete_openai_container("cntr_x"))
|
||||
|
||||
|
||||
def test_delete_raises_when_body_is_not_json(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content = b"<html>OK</html>")
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
with pytest.raises(httpx.HTTPError, match = "did not confirm container deletion"):
|
||||
_drive(_make_client().delete_openai_container("cntr_x"))
|
||||
|
||||
|
||||
def test_delete_propagates_openai_4xx(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404, json = {"error": {"message": "not found"}})
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_drive(_make_client().delete_openai_container("cntr_missing"))
|
||||
|
|
@ -13,6 +13,7 @@ import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
|||
import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
||||
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
|
||||
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
||||
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
|
||||
|
|
@ -750,9 +751,18 @@ const CodeToolsToggle: FC = () => {
|
|||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
);
|
||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||
// External providers have no local tool runtime, but Anthropic's
|
||||
// Claude 4.x dispatches code_execution_20250825 server-side. The
|
||||
// chat-page resolver stashes that capability in the runtime store
|
||||
// (next to supportsBuiltinWebSearch). Mirror of shared-composer's
|
||||
// codeDisabled so this pill lights up in active threads too.
|
||||
const supportsBuiltinCodeExecution = useChatRuntimeStore(
|
||||
(s) => s.supportsBuiltinCodeExecution,
|
||||
);
|
||||
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
|
||||
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
|
||||
const disabled = !(modelLoaded && supportsTools);
|
||||
const disabled =
|
||||
!modelLoaded || !(supportsTools || supportsBuiltinCodeExecution);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -946,6 +956,7 @@ const AssistantMessage: FC = () => {
|
|||
web_search: WebSearchToolUI,
|
||||
python: PythonToolUI,
|
||||
terminal: TerminalToolUI,
|
||||
code_execution: CodeExecutionToolUI,
|
||||
},
|
||||
Fallback: ToolFallback,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
|
||||
import { FileTextIcon, LoaderIcon, TerminalIcon } from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import {
|
||||
ToolFallbackContent,
|
||||
ToolFallbackRoot,
|
||||
ToolFallbackTrigger,
|
||||
} from "./tool-fallback";
|
||||
|
||||
/**
|
||||
* Renders the synthetic `_toolEvent` chunks emitted by
|
||||
* `_stream_anthropic` when Anthropic's `code_execution_20250825` tool
|
||||
* fires. The backend collapses Anthropic's two sub-tools
|
||||
* (`bash_code_execution`, `text_editor_code_execution`) into a single
|
||||
* `tool_name: "code_execution"`, with `arguments.kind` ("bash" or
|
||||
* "text_editor") and a per-kind argument shape:
|
||||
*
|
||||
* kind=bash: { command: "<shell command>" }
|
||||
* kind=text_editor: { command: "view"|"create"|"str_replace", path, ... }
|
||||
*
|
||||
* The `result` payload is preformatted text:
|
||||
* - bash: stdout, then "--- stderr ---" block + return_code if non-zero
|
||||
* - text_editor view: file contents verbatim
|
||||
* - text_editor create: "Created <path>" / "Updated <path>"
|
||||
* - text_editor str_replace: unified-diff `lines` joined with "\n"
|
||||
* - error: "Error: <error_code>"
|
||||
*/
|
||||
interface CodeExecutionArgs {
|
||||
kind?: "bash" | "text_editor";
|
||||
command?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
}) => {
|
||||
const parsedArgs = (args as CodeExecutionArgs) ?? {};
|
||||
const kind = parsedArgs.kind ?? "bash";
|
||||
const command = parsedArgs.command ?? "";
|
||||
const path = parsedArgs.path ?? "";
|
||||
const isRunning = status?.type === "running";
|
||||
|
||||
let runningLabel: string;
|
||||
let completedLabel: string;
|
||||
let Icon = TerminalIcon;
|
||||
if (kind === "text_editor") {
|
||||
Icon = FileTextIcon;
|
||||
if (command === "view") {
|
||||
runningLabel = path ? `Viewing ${path}…` : "Viewing file…";
|
||||
completedLabel = path ? `Viewed ${path}` : "Viewed file";
|
||||
} else if (command === "create") {
|
||||
runningLabel = path ? `Writing ${path}…` : "Writing file…";
|
||||
completedLabel = path ? `Wrote ${path}` : "Wrote file";
|
||||
} else if (command === "str_replace") {
|
||||
runningLabel = path ? `Editing ${path}…` : "Editing file…";
|
||||
completedLabel = path ? `Edited ${path}` : "Edited file";
|
||||
} else {
|
||||
runningLabel = "Running file operation…";
|
||||
completedLabel = "File operation";
|
||||
}
|
||||
} else {
|
||||
runningLabel = "Running command…";
|
||||
completedLabel = command ? `Ran \`${command}\`` : "Ran command";
|
||||
}
|
||||
|
||||
// Collapse the card once the model has resumed streaming prose after
|
||||
// the tool call. Mirrors WebSearchToolUI's behavior so the tool-card
|
||||
// doesn't crowd the final answer once the run is done.
|
||||
const hasText = useAuiState(({ message }) =>
|
||||
message.content.some(
|
||||
(p) =>
|
||||
p.type === "text" &&
|
||||
"text" in p &&
|
||||
(p as { text: string }).text.length > 0,
|
||||
),
|
||||
);
|
||||
const [open, setOpen] = useState(isRunning);
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
setOpen(true);
|
||||
} else if (hasText) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [isRunning, hasText]);
|
||||
|
||||
const resultText =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result != null
|
||||
? JSON.stringify(result, null, 2)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
|
||||
<ToolFallbackTrigger
|
||||
toolName={isRunning ? runningLabel : completedLabel}
|
||||
status={status}
|
||||
icon={Icon}
|
||||
/>
|
||||
<ToolFallbackContent>
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>{runningLabel}</span>
|
||||
</div>
|
||||
) : resultText ? (
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
|
||||
{resultText}
|
||||
</pre>
|
||||
) : null}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeExecutionToolUI = memo(
|
||||
CodeExecutionToolUIImpl,
|
||||
) as unknown as ToolCallMessagePartComponent;
|
||||
CodeExecutionToolUI.displayName = "CodeExecutionToolUI";
|
||||
|
|
@ -15,6 +15,8 @@ import {
|
|||
streamChatCompletions,
|
||||
validateModel,
|
||||
} from "./chat-api";
|
||||
import { pickFriendlyContainerName } from "../lib/friendly-names";
|
||||
import { createOpenAIContainer } from "./openai-containers";
|
||||
import {
|
||||
encryptProviderApiKey,
|
||||
isProviderKeyRotationError,
|
||||
|
|
@ -26,6 +28,7 @@ import type {
|
|||
} from "../types/api";
|
||||
import {
|
||||
getExternalProviderApiKey,
|
||||
isCustomProviderType,
|
||||
loadExternalProviders,
|
||||
parseExternalModelId,
|
||||
supportsProviderPromptCaching,
|
||||
|
|
@ -37,6 +40,7 @@ import {
|
|||
getExternalMinOutputTokens,
|
||||
getExternalReasoningCapabilities,
|
||||
getProviderCapabilities,
|
||||
providerSupportsBuiltinCodeExecution,
|
||||
providerSupportsBuiltinWebSearch,
|
||||
} from "../provider-capabilities";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
|
|
@ -748,7 +752,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
});
|
||||
throw new Error("External provider not found.");
|
||||
}
|
||||
if (isExternalRequest && !externalApiKey) {
|
||||
// Local providers (llama.cpp / vLLM / Ollama) allow an empty key — only block hosted providers.
|
||||
const externalProviderIsCustom = externalProvider
|
||||
? isCustomProviderType(externalProvider.providerType)
|
||||
: false;
|
||||
if (isExternalRequest && !externalApiKey && !externalProviderIsCustom) {
|
||||
toast.error("Missing API key for selected external provider.", {
|
||||
description: "Open Connections and set the API key again.",
|
||||
});
|
||||
|
|
@ -978,6 +986,106 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
forceRefreshPublicKey = false,
|
||||
): Promise<OpenAIChatCompletionsRequest> => {
|
||||
if (externalSelection && externalProvider) {
|
||||
// OpenAI shell-tool container reuse: pull the per-thread
|
||||
// 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).
|
||||
let openaiCodeExecContainerId: string | null = null;
|
||||
const codeExecEnabledForThisTurn =
|
||||
codeToolsEnabled &&
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
externalProvider.providerType,
|
||||
externalSelection.modelId,
|
||||
externalProvider.baseUrl,
|
||||
);
|
||||
if (codeExecEnabledForThisTurn && resolvedThreadId) {
|
||||
try {
|
||||
const thread = await db.threads.get(resolvedThreadId);
|
||||
openaiCodeExecContainerId =
|
||||
thread?.openaiCodeExecContainerId ?? null;
|
||||
} catch {
|
||||
openaiCodeExecContainerId = null;
|
||||
}
|
||||
// Cross-thread inheritance: when the active thread has
|
||||
// no container yet, default to the one most recently
|
||||
// used on *any* other thread (provider-scoped).
|
||||
// Matches what the Code Execution settings section
|
||||
// shows in the picker, and keeps the user from getting
|
||||
// a fresh container on every new thread. The picker
|
||||
// can still be set to "Auto-create per thread"
|
||||
// explicitly to opt into a fresh container — but
|
||||
// that's done via the dropdown, not silently.
|
||||
if (
|
||||
!openaiCodeExecContainerId &&
|
||||
externalProvider.providerType === "openai"
|
||||
) {
|
||||
try {
|
||||
const others = await db.threads
|
||||
.orderBy("createdAt")
|
||||
.reverse()
|
||||
.toArray();
|
||||
for (const t of others) {
|
||||
if (t.id === resolvedThreadId) continue;
|
||||
if (t.openaiCodeExecContainerId) {
|
||||
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
|
||||
void db.threads
|
||||
.update(resolvedThreadId, {
|
||||
openaiCodeExecContainerId,
|
||||
})
|
||||
.catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to lazy-create below */
|
||||
}
|
||||
}
|
||||
// Lazy pre-create when there's no inherited container.
|
||||
// We always POST /v1/containers ourselves (rather than
|
||||
// letting the backend send container_auto) so every
|
||||
// container shows up in the picker with a friendly
|
||||
// English-word name and the user's configured TTL.
|
||||
// Falls back to container_auto only if the POST fails
|
||||
// — keeps the chat moving in that case.
|
||||
if (
|
||||
!openaiCodeExecContainerId &&
|
||||
externalProvider.providerType === "openai"
|
||||
) {
|
||||
const ttl = externalProvider.openaiContainerTtlMinutes;
|
||||
const ttlToUse =
|
||||
typeof ttl === "number" && ttl >= 1 ? ttl : 20;
|
||||
try {
|
||||
const created = await createOpenAIContainer(
|
||||
{
|
||||
apiKey: externalApiKey,
|
||||
baseUrl: externalProvider.baseUrl || null,
|
||||
},
|
||||
{
|
||||
// Friendly English-word name so the container
|
||||
// is human-readable in the picker list (e.g.
|
||||
// "kestrel-3f9c") instead of a thread-id slug
|
||||
// or OpenAI's default blank name.
|
||||
name: pickFriendlyContainerName(),
|
||||
ttlMinutes: ttlToUse,
|
||||
},
|
||||
);
|
||||
openaiCodeExecContainerId = created.id;
|
||||
void db.threads
|
||||
.update(resolvedThreadId, {
|
||||
openaiCodeExecContainerId: created.id,
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
// Fall back to backend's container_auto path on
|
||||
// failure — keeps the chat moving; the next turn
|
||||
// can retry. The auto-created container will be
|
||||
// unnamed, but the chat doesn't break.
|
||||
openaiCodeExecContainerId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
model: externalSelection.modelId,
|
||||
messages: outboundMessages,
|
||||
|
|
@ -1011,28 +1119,59 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
...(externalCapabilities?.presencePenalty
|
||||
? { presence_penalty: params.presencePenalty }
|
||||
: {}),
|
||||
// Built-in web search: when the user has the Search toggle
|
||||
// on AND the active provider supports a server-side
|
||||
// web_search tool (currently OpenAI's /v1/responses), pass
|
||||
// the enable_tools shorthand. Backend translates
|
||||
// enabled_tools=["web_search"] into the provider's tool
|
||||
// schema — for OpenAI that's `tools: [{type:"web_search"}]`
|
||||
// on the Responses body, see _stream_openai_responses.
|
||||
...(toolsEnabled &&
|
||||
providerSupportsBuiltinWebSearch(externalProvider.providerType)
|
||||
// Built-in tools: Search pill maps to provider-side
|
||||
// web_search (currently OpenAI / Anthropic / OpenRouter /
|
||||
// Kimi); Code pill maps to Anthropic's server-side
|
||||
// code_execution_20250825 tool (Anthropic is the only
|
||||
// external provider that ships one today). Backend
|
||||
// translates enabled_tools into each provider's tool
|
||||
// schema — for Anthropic that's the entries appended to
|
||||
// body["tools"] inside _stream_anthropic.
|
||||
...((toolsEnabled &&
|
||||
providerSupportsBuiltinWebSearch(externalProvider.providerType)) ||
|
||||
(codeToolsEnabled &&
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
externalProvider.providerType,
|
||||
externalSelection.modelId,
|
||||
externalProvider.baseUrl,
|
||||
))
|
||||
? {
|
||||
enable_tools: true,
|
||||
enabled_tools: ["web_search"],
|
||||
enabled_tools: [
|
||||
...(toolsEnabled &&
|
||||
providerSupportsBuiltinWebSearch(
|
||||
externalProvider.providerType,
|
||||
)
|
||||
? ["web_search"]
|
||||
: []),
|
||||
...(codeToolsEnabled &&
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
externalProvider.providerType,
|
||||
externalSelection.modelId,
|
||||
externalProvider.baseUrl,
|
||||
)
|
||||
? ["code_execution"]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
provider_id: externalProvider.id,
|
||||
provider_type: externalBackendProviderType,
|
||||
external_model: externalSelection.modelId,
|
||||
encrypted_api_key: await encryptProviderApiKey(
|
||||
externalApiKey,
|
||||
forceRefreshPublicKey,
|
||||
),
|
||||
...(externalApiKey
|
||||
? {
|
||||
encrypted_api_key: await encryptProviderApiKey(
|
||||
externalApiKey,
|
||||
forceRefreshPublicKey,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
provider_base_url: externalProvider.baseUrl || null,
|
||||
...(openaiCodeExecContainerId
|
||||
? {
|
||||
openai_code_exec_container_id: openaiCodeExecContainerId,
|
||||
}
|
||||
: {}),
|
||||
...(supportsProviderPromptCaching(externalProvider.providerType)
|
||||
? {
|
||||
enable_prompt_caching:
|
||||
|
|
@ -1116,6 +1255,34 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// On tool_end: set result on the existing part (transitions to "complete").
|
||||
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
|
||||
if (toolEvent !== undefined) {
|
||||
// OpenAI shell-tool container persistence — see
|
||||
// ThreadRecord.openaiCodeExecContainerId. The backend
|
||||
// emits these synthetic events on the OpenAI Responses
|
||||
// SSE stream after capturing the container_id from a
|
||||
// response, or detecting an expired-container error.
|
||||
if (toolEvent.type === "container_ready") {
|
||||
const newContainerId = toolEvent.container_id as
|
||||
| string
|
||||
| undefined;
|
||||
if (newContainerId && resolvedThreadId) {
|
||||
void db.threads
|
||||
.update(resolvedThreadId, {
|
||||
openaiCodeExecContainerId: newContainerId,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (toolEvent.type === "container_invalidated") {
|
||||
if (resolvedThreadId) {
|
||||
void db.threads
|
||||
.update(resolvedThreadId, {
|
||||
openaiCodeExecContainerId: null,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (toolEvent.type === "tool_start") {
|
||||
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
|
||||
const toolArgs = (toolEvent.arguments ?? {}) as ToolCallMessagePart["args"];
|
||||
|
|
|
|||
121
studio/frontend/src/features/chat/api/openai-containers.ts
Normal file
121
studio/frontend/src/features/chat/api/openai-containers.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Wrappers for the three OpenAI shell-tool container management
|
||||
* endpoints exposed by the backend (studio/backend/routes/inference.py).
|
||||
* Each one proxies to OpenAI's /v1/containers REST surface using the
|
||||
* user's encrypted API key. Backend rejects any base URL that isn't
|
||||
* api.openai.com — the shell tool only exists on the managed cloud.
|
||||
*/
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { encryptProviderApiKey } from "./providers-api";
|
||||
|
||||
export interface OpenAIContainerSummary {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
createdAt?: number | null;
|
||||
lastActiveAt?: number | null;
|
||||
expiresAfterMinutes?: number | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
interface RawSummary {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
created_at?: number | null;
|
||||
last_active_at?: number | null;
|
||||
expires_after_minutes?: number | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
function fromRaw(raw: RawSummary): OpenAIContainerSummary {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name ?? null,
|
||||
createdAt: raw.created_at ?? null,
|
||||
lastActiveAt: raw.last_active_at ?? null,
|
||||
expiresAfterMinutes: raw.expires_after_minutes ?? null,
|
||||
status: raw.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { detail?: string };
|
||||
if (body && typeof body.detail === "string") return body.detail;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
interface AuthInputs {
|
||||
apiKey: string;
|
||||
baseUrl: string | null;
|
||||
}
|
||||
|
||||
async function buildAuthBody(auth: AuthInputs) {
|
||||
return {
|
||||
encrypted_api_key: await encryptProviderApiKey(auth.apiKey),
|
||||
provider_base_url: auth.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listOpenAIContainers(
|
||||
auth: AuthInputs,
|
||||
): Promise<OpenAIContainerSummary[]> {
|
||||
const response = await authFetch(
|
||||
"/api/inference/external/openai/containers/list",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(await buildAuthBody(auth)),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const body = (await response.json()) as { containers?: RawSummary[] };
|
||||
return (body.containers ?? []).map(fromRaw);
|
||||
}
|
||||
|
||||
export async function createOpenAIContainer(
|
||||
auth: AuthInputs,
|
||||
params: { name: string; ttlMinutes: number },
|
||||
): Promise<OpenAIContainerSummary> {
|
||||
const response = await authFetch(
|
||||
"/api/inference/external/openai/containers/create",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...(await buildAuthBody(auth)),
|
||||
name: params.name,
|
||||
ttl_minutes: params.ttlMinutes,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const raw = (await response.json()) as RawSummary;
|
||||
return fromRaw(raw);
|
||||
}
|
||||
|
||||
export async function deleteOpenAIContainer(
|
||||
auth: AuthInputs,
|
||||
containerId: string,
|
||||
): Promise<void> {
|
||||
const response = await authFetch(
|
||||
"/api/inference/external/openai/containers/delete",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...(await buildAuthBody(auth)),
|
||||
container_id: containerId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error(await parseError(response));
|
||||
}
|
||||
}
|
||||
|
|
@ -176,8 +176,12 @@ export async function updateProviderConfig(
|
|||
|
||||
async function withApiKeyEncryptionRetry<T>(
|
||||
plaintextApiKey: string,
|
||||
call: (encryptedApiKey: string) => Promise<T>,
|
||||
call: (encryptedApiKey: string | null) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// Empty key (local providers): skip RSA round-trip and let the backend omit auth.
|
||||
if (!plaintextApiKey) {
|
||||
return await call(null);
|
||||
}
|
||||
try {
|
||||
const encrypted = await encryptProviderApiKey(plaintextApiKey, false);
|
||||
return await call(encrypted);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
clampReasoningEffortToLevels,
|
||||
getExternalReasoningCapabilities,
|
||||
getProviderCapabilities,
|
||||
providerSupportsBuiltinCodeExecution,
|
||||
providerSupportsBuiltinWebSearch,
|
||||
} from "./provider-capabilities";
|
||||
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
|
|
@ -716,6 +717,11 @@ export function ChatPage(): ReactElement {
|
|||
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
|
||||
provider?.providerType,
|
||||
);
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
provider?.providerType,
|
||||
selection.modelId,
|
||||
provider?.baseUrl,
|
||||
);
|
||||
// Kimi's k2.6/k2.5 default to thinking enabled on the server side
|
||||
// (per https://platform.kimi.ai/docs/models). Mirror that default
|
||||
// in the UI so the Think pill comes up clicked when the user picks
|
||||
|
|
@ -748,14 +754,16 @@ export function ChatPage(): ReactElement {
|
|||
: true
|
||||
: state.reasoningEnabled,
|
||||
supportsPreserveThinking: false,
|
||||
// External models never give us a local tool runtime (no Code
|
||||
// execution, no python sandbox), so `supportsTools` must be
|
||||
// false — that's what gates the Code pill in the composer.
|
||||
// `supportsBuiltinWebSearch` is the separate flag that lets the
|
||||
// Search pill light up for providers (currently just OpenAI) who
|
||||
// run web_search server-side.
|
||||
// External models never give us a local tool runtime (no
|
||||
// python sandbox), so `supportsTools` must be false. The two
|
||||
// `supportsBuiltin*` flags pick up the slack for providers that
|
||||
// run the tool server-side: `supportsBuiltinWebSearch` lights
|
||||
// up the Search pill (OpenAI / Anthropic / OpenRouter / Kimi),
|
||||
// `supportsBuiltinCodeExecution` lights up the Code pill
|
||||
// (Anthropic Claude 4.x only, today).
|
||||
supportsTools: false,
|
||||
supportsBuiltinWebSearch,
|
||||
supportsBuiltinCodeExecution,
|
||||
toolsEnabled: searchOnByDefault,
|
||||
codeToolsEnabled: false,
|
||||
});
|
||||
|
|
@ -915,6 +923,11 @@ export function ChatPage(): ReactElement {
|
|||
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
|
||||
selectedProvider?.providerType,
|
||||
);
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
// See sibling useEffect above: Kimi's k2.x default to thinking
|
||||
// enabled, so the Think pill comes up clicked. Search pill stays
|
||||
// off by default; mutual exclusion flips them via the composer.
|
||||
|
|
@ -946,12 +959,15 @@ export function ChatPage(): ReactElement {
|
|||
: true
|
||||
: store.reasoningEnabled,
|
||||
supportsPreserveThinking: false,
|
||||
// External models have no local tool runtime → supportsTools=false
|
||||
// keeps the Code pill greyed out. supportsBuiltinWebSearch is the
|
||||
// separate flag the composer reads to light up the Search pill
|
||||
// when the provider offers a server-side web_search tool.
|
||||
// External models have no local tool runtime → supportsTools
|
||||
// stays false. The two supportsBuiltin* flags carry the
|
||||
// server-side capability info for each pill:
|
||||
// - Search → providerSupportsBuiltinWebSearch
|
||||
// - Code → providerSupportsBuiltinCodeExecution
|
||||
// (Anthropic Claude 4.x only, today)
|
||||
supportsTools: false,
|
||||
supportsBuiltinWebSearch,
|
||||
supportsBuiltinCodeExecution,
|
||||
toolsEnabled: searchOnByDefault,
|
||||
codeToolsEnabled: false,
|
||||
...(stillOnOpenRouterFree ? {} : { lastOpenRouterChosenModel: null }),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ import { toast } from "sonner";
|
|||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import {
|
||||
type ExternalProviderConfig,
|
||||
getExternalProviderApiKey,
|
||||
parseExternalModelId,
|
||||
supportsProviderPromptCaching,
|
||||
} from "./external-providers";
|
||||
import {
|
||||
|
|
@ -84,9 +86,11 @@ import {
|
|||
toPresetParams,
|
||||
type Preset,
|
||||
} from "./presets/preset-policy";
|
||||
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
|
||||
import {
|
||||
EXTERNAL_MAX_OUTPUT_TOKENS,
|
||||
getExternalMinOutputTokens,
|
||||
providerSupportsBuiltinCodeExecution,
|
||||
type ProviderCapabilities,
|
||||
} from "./provider-capabilities";
|
||||
import type { InferenceParams } from "./types/runtime";
|
||||
|
|
@ -675,6 +679,21 @@ export function ChatSettingsPanel({
|
|||
supportsProviderPromptCaching(activeExternalProvider.providerType);
|
||||
const promptCachingEnabled =
|
||||
activeExternalProvider?.enablePromptCaching !== false;
|
||||
const externalSelection = currentCheckpoint
|
||||
? parseExternalModelId(currentCheckpoint)
|
||||
: null;
|
||||
const showOpenAICodeExecSection =
|
||||
activeExternalProvider != null &&
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
activeExternalProvider.providerType,
|
||||
externalSelection?.modelId,
|
||||
activeExternalProvider.baseUrl,
|
||||
) &&
|
||||
activeExternalProvider.providerType === "openai";
|
||||
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
const openAiApiKeyForSection = activeExternalProvider
|
||||
? getExternalProviderApiKey(activeExternalProvider.id) || null
|
||||
: null;
|
||||
|
||||
function set<K extends keyof InferenceParams>(key: K) {
|
||||
return (v: InferenceParams[K]) => {
|
||||
|
|
@ -1184,6 +1203,17 @@ export function ChatSettingsPanel({
|
|||
</CollapsibleSection>
|
||||
) : null}
|
||||
|
||||
{showOpenAICodeExecSection && activeExternalProvider ? (
|
||||
<CollapsibleSection label="Code Execution" defaultOpen={false}>
|
||||
<OpenAICodeExecSection
|
||||
provider={activeExternalProvider}
|
||||
apiKey={openAiApiKeyForSection}
|
||||
activeThreadId={activeThreadId}
|
||||
onProviderChange={(p) => onExternalProviderChange?.(p)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
) : null}
|
||||
|
||||
<CollapsibleSection label="System Prompt" defaultOpen={true}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,477 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Settings-sheet section for OpenAI shell-tool container management.
|
||||
* Renders only when:
|
||||
* - active provider is OpenAI cloud (api.openai.com base URL), AND
|
||||
* - the active model is gpt-5.5 or gpt-5.5-pro (the only families
|
||||
* where the shell tool is wired through today).
|
||||
*
|
||||
* Surfaces three controls:
|
||||
* 1. Default container idle-timeout (minutes). Persists on the
|
||||
* provider record; pre-fills the create dialog and is used by
|
||||
* the chat-adapter's lazy-create path on the first turn of a
|
||||
* thread.
|
||||
* 2. Container picker for the *active thread* — pick any of the
|
||||
* user's existing OpenAI containers, or "Auto-create per thread"
|
||||
* (default; lets the auto-create path manage it).
|
||||
* 3. Create-new-container inline form. Refresh + delete actions
|
||||
* per row.
|
||||
*
|
||||
* State persistence:
|
||||
* - TTL → ExternalProviderConfig.openaiContainerTtlMinutes
|
||||
* - Active container for this thread → ThreadRecord.openaiCodeExecContainerId
|
||||
*
|
||||
* No new global stores — list is fetched on open / refresh and held
|
||||
* in component state.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TrashIcon, RefreshCwIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
createOpenAIContainer,
|
||||
deleteOpenAIContainer,
|
||||
listOpenAIContainers,
|
||||
type OpenAIContainerSummary,
|
||||
} from "../api/openai-containers";
|
||||
import { db } from "../db";
|
||||
import type { ExternalProviderConfig } from "../external-providers";
|
||||
import { useLiveQuery } from "../db";
|
||||
import { ensureThreadRecord } from "../runtime-provider";
|
||||
|
||||
const AUTO_OPTION_VALUE = "__auto__";
|
||||
const DEFAULT_TTL_MINUTES = 20;
|
||||
const TTL_MIN = 1;
|
||||
const TTL_MAX = 10080; // one week — matches backend bound
|
||||
|
||||
function ageLabel(epochSeconds: number | null | undefined): string {
|
||||
if (!epochSeconds) return "";
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
|
||||
if (ageSec < 60) return `${ageSec}s ago`;
|
||||
const ageMin = Math.floor(ageSec / 60);
|
||||
if (ageMin < 60) return `${ageMin}m ago`;
|
||||
const ageHr = Math.floor(ageMin / 60);
|
||||
if (ageHr < 48) return `${ageHr}h ago`;
|
||||
const ageDay = Math.floor(ageHr / 24);
|
||||
return `${ageDay}d ago`;
|
||||
}
|
||||
|
||||
interface OpenAICodeExecSectionProps {
|
||||
provider: ExternalProviderConfig;
|
||||
apiKey: string | null;
|
||||
activeThreadId: string | null;
|
||||
onProviderChange: (provider: ExternalProviderConfig) => void;
|
||||
}
|
||||
|
||||
export function OpenAICodeExecSection({
|
||||
provider,
|
||||
apiKey,
|
||||
activeThreadId,
|
||||
onProviderChange,
|
||||
}: OpenAICodeExecSectionProps) {
|
||||
const [containers, setContainers] = useState<OpenAIContainerSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createTtl, setCreateTtl] = useState<number>(
|
||||
provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES,
|
||||
);
|
||||
|
||||
const thread = useLiveQuery(
|
||||
async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined),
|
||||
[activeThreadId],
|
||||
);
|
||||
const activeContainerId = thread?.openaiCodeExecContainerId ?? null;
|
||||
|
||||
// Containers sorted newest-first by lastActiveAt so the dropdown's
|
||||
// default (auto-bind target) shows up first.
|
||||
const sortedContainers = useMemo(
|
||||
() =>
|
||||
[...containers].sort(
|
||||
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
|
||||
),
|
||||
[containers],
|
||||
);
|
||||
|
||||
// What the dropdown should display right now. We decouple this from
|
||||
// `activeContainerId` (which is whatever is in Dexie) so the user
|
||||
// immediately sees the most-recent container by name when there is
|
||||
// no thread binding yet, rather than a "Selecting most recent…"
|
||||
// placeholder while the auto-bind effect's async write propagates
|
||||
// back through useLiveQuery. The auto-bind effect still writes the
|
||||
// bind to Dexie so the chat adapter sees it on send.
|
||||
const displayedContainerId =
|
||||
activeContainerId ?? sortedContainers[0]?.id ?? null;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!apiKey) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const list = await listOpenAIContainers({
|
||||
apiKey,
|
||||
baseUrl: provider.baseUrl || null,
|
||||
});
|
||||
setContainers(list);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to list containers: ${err instanceof Error ? err.message : "Unknown"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [apiKey, provider.baseUrl]);
|
||||
|
||||
// Fetch once when the section mounts (or provider changes).
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Auto-bind the active thread to the most-recently-active container
|
||||
// whenever the thread has none set and at least one container exists
|
||||
// on the user's OpenAI account. Sorting by `lastActiveAt` matches
|
||||
// what feels "most recent" from the user's perspective.
|
||||
//
|
||||
// We eagerly materialize the thread row via `ensureThreadRecord` so
|
||||
// the bind actually lands in Dexie before the user has sent a first
|
||||
// message. This does NOT create anything at OpenAI — only a local
|
||||
// ThreadRecord — so it does not bypass the user's expectation that
|
||||
// a fresh OpenAI container is not created until first send.
|
||||
//
|
||||
// If `containers` is empty (no OpenAI containers exist yet), this
|
||||
// effect short-circuits: the picker renders an empty-state hint and
|
||||
// the chat-adapter's lazy-create path will mint the first container
|
||||
// on first send.
|
||||
useEffect(() => {
|
||||
if (!activeThreadId || activeContainerId || containers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sorted = [...containers].sort(
|
||||
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
|
||||
);
|
||||
const candidate = sorted[0];
|
||||
if (!candidate) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureThreadRecord({
|
||||
threadId: activeThreadId,
|
||||
modelType: "base",
|
||||
});
|
||||
await db.threads.update(activeThreadId, {
|
||||
openaiCodeExecContainerId: candidate.id,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort; the chat-adapter will inherit/create on send.
|
||||
}
|
||||
})();
|
||||
}, [activeThreadId, activeContainerId, containers]);
|
||||
|
||||
const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
|
||||
|
||||
const onTtlChange = (raw: string) => {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
const clamped = Math.min(Math.max(n, TTL_MIN), TTL_MAX);
|
||||
onProviderChange({ ...provider, openaiContainerTtlMinutes: clamped });
|
||||
};
|
||||
|
||||
const onPick = async (value: string) => {
|
||||
if (!activeThreadId || !value) return;
|
||||
// value is always a container id now — the "Auto-create per thread"
|
||||
// option has been removed in favour of always defaulting to the
|
||||
// most-recently-active container. The chat-adapter still handles
|
||||
// the no-containers-exist case (lazy-create on first send).
|
||||
//
|
||||
// ensureThreadRecord materializes the thread row eagerly (modelType
|
||||
// "base" — settings sheet is single-thread-mode only) so the update
|
||||
// actually lands when the user hasn't sent a message yet.
|
||||
try {
|
||||
await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" });
|
||||
const affected = await db.threads.update(activeThreadId, {
|
||||
openaiCodeExecContainerId: value,
|
||||
});
|
||||
if (affected === 0) {
|
||||
toast.error("Could not update thread.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Could not update thread: ${err instanceof Error ? err.message : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!apiKey) return;
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
toast.error("Container name is required");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await createOpenAIContainer(
|
||||
{ apiKey, baseUrl: provider.baseUrl || null },
|
||||
{ name, ttlMinutes: createTtl },
|
||||
);
|
||||
toast.success(`Created container ${name}`);
|
||||
setCreateName("");
|
||||
setCreateOpen(false);
|
||||
await refresh();
|
||||
// Auto-bind the just-created container to the active thread.
|
||||
// ensureThreadRecord first so the bind lands even when the user
|
||||
// creates a container before sending the first message — without
|
||||
// it, db.threads.update silently affects 0 rows and the chat
|
||||
// adapter falls back to cross-thread inheritance / lazy-create,
|
||||
// which can pick a stale container that fails with "container
|
||||
// does not exist" on the first turn.
|
||||
if (activeThreadId) {
|
||||
try {
|
||||
await ensureThreadRecord({
|
||||
threadId: activeThreadId,
|
||||
modelType: "base",
|
||||
});
|
||||
await db.threads.update(activeThreadId, {
|
||||
openaiCodeExecContainerId: created.id,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort; toast above already confirmed creation */
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Create failed: ${err instanceof Error ? err.message : "Unknown"}`,
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (id: string, name: string | null | undefined) => {
|
||||
if (!apiKey) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete container ${name || id}? Threads using it will fall back to auto-create on their next turn.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteOpenAIContainer(
|
||||
{ apiKey, baseUrl: provider.baseUrl || null },
|
||||
id,
|
||||
);
|
||||
// Clear any thread bindings pointing at the now-deleted id.
|
||||
const affected = await db.threads
|
||||
.filter((t) => t.openaiCodeExecContainerId === id)
|
||||
.toArray();
|
||||
await Promise.all(
|
||||
affected.map((t) =>
|
||||
db.threads.update(t.id, { openaiCodeExecContainerId: null }),
|
||||
),
|
||||
);
|
||||
toast.success(`Deleted container ${name || id}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Delete failed: ${err instanceof Error ? err.message : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
{/* TTL */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor="openai-container-ttl"
|
||||
className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg"
|
||||
>
|
||||
New-container idle timeout (min)
|
||||
</label>
|
||||
<Input
|
||||
id="openai-container-ttl"
|
||||
type="number"
|
||||
min={TTL_MIN}
|
||||
max={TTL_MAX}
|
||||
value={ttlValue}
|
||||
onChange={(e) => onTtlChange(e.target.value)}
|
||||
className="h-8 w-24 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active container picker — visually emphasized so it reads as
|
||||
the primary control vs. the static list below. Accent
|
||||
background + ring outline distinguish it from the plain
|
||||
bordered list items beneath. */}
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-primary/30 bg-primary/5 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-semibold leading-[1.25] tracking-nav text-primary">
|
||||
Active for this thread
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={() => void refresh()}
|
||||
disabled={isLoading || !apiKey}
|
||||
aria-label="Refresh container list"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3.5 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* When no containers exist yet, render a disabled placeholder
|
||||
instead of the picker. The first one is created by the
|
||||
chat-adapter on first send (lazy-create) and will appear
|
||||
here after the next refresh. */}
|
||||
{sortedContainers.length === 0 ? (
|
||||
<div className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 flex items-center text-sm text-muted-foreground">
|
||||
(none yet — will be created on first send)
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={displayedContainerId ?? sortedContainers[0].id}
|
||||
onChange={(e) => onPick(e.target.value)}
|
||||
disabled={!activeThreadId}
|
||||
className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 text-sm font-medium shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
{sortedContainers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name ?? "(unnamed)"} · {c.id.slice(0, 14)}…
|
||||
{c.lastActiveAt ? ` · active ${ageLabel(c.lastActiveAt)}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Container list with delete actions — labeled and visually
|
||||
quieter so it's clearly the "all containers, manage them"
|
||||
area rather than the active selector above. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
All containers
|
||||
</span>
|
||||
{isLoading && containers.length === 0 ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : containers.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1 max-h-44 overflow-auto">
|
||||
{containers.map((c) => {
|
||||
const isActive = c.id === activeContainerId;
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
className={`flex items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-xs ${
|
||||
isActive
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">
|
||||
{c.name ?? "(unnamed)"}
|
||||
{isActive ? (
|
||||
<span className="ml-1.5 text-[10px] font-normal uppercase tracking-wider text-primary">
|
||||
· active
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{c.id} · TTL{" "}
|
||||
{c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES}m
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
onClick={() => void onDelete(c.id, c.name)}
|
||||
aria-label={`Delete container ${c.name ?? c.id}`}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No saved containers yet. Use auto-create or create a named
|
||||
one below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create new */}
|
||||
{createOpen ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border/60 p-2">
|
||||
<Input
|
||||
placeholder="Container name (e.g. data-analysis)"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={TTL_MIN}
|
||||
max={TTL_MAX}
|
||||
value={createTtl}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(n))
|
||||
setCreateTtl(Math.min(Math.max(n, TTL_MIN), TTL_MAX));
|
||||
}}
|
||||
className="h-8 w-24 text-sm"
|
||||
aria-label="Idle timeout in minutes"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min idle</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7"
|
||||
onClick={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => void onCreate()}
|
||||
disabled={creating || !createName.trim() || !apiKey}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setCreateTtl(ttlValue);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
disabled={!apiKey}
|
||||
>
|
||||
<PlusIcon className="size-3.5 mr-1" />
|
||||
New container
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,13 @@ export interface ExternalProviderConfig {
|
|||
enablePromptCaching?: boolean;
|
||||
/** User-pinned: the loaded vLLM model supports `enable_thinking`. */
|
||||
isReasoningModel?: boolean;
|
||||
/**
|
||||
* Default idle-timeout (in minutes) for newly created OpenAI shell
|
||||
* containers. Pre-fills the "Create container" dialog and is the
|
||||
* TTL the auto-create-per-thread path POSTs to /v1/containers with.
|
||||
* OpenAI's hard default is 20. Only meaningful for OpenAI cloud.
|
||||
*/
|
||||
openaiContainerTtlMinutes?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
|
@ -226,6 +233,12 @@ function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig
|
|||
isReasoningModel: supportsProviderReasoningToggle(providerType)
|
||||
? raw.isReasoningModel === true
|
||||
: undefined,
|
||||
openaiContainerTtlMinutes:
|
||||
providerType === "openai" &&
|
||||
typeof raw.openaiContainerTtlMinutes === "number" &&
|
||||
raw.openaiContainerTtlMinutes >= 1
|
||||
? Math.min(raw.openaiContainerTtlMinutes, 10080)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
244
studio/frontend/src/features/chat/lib/friendly-names.ts
Normal file
244
studio/frontend/src/features/chat/lib/friendly-names.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Friendly default names for auto-created OpenAI shell containers.
|
||||
* Used by the chat-adapter when the lazy-create path fires (Code pill
|
||||
* on, no thread container yet, user has set a non-default TTL). The
|
||||
* goal is a human-memorable label like "otter" or "harbor" instead of
|
||||
* "chat-abc12345" — the user can still rename via the Studio-side
|
||||
* alias map.
|
||||
*
|
||||
* The list is curated to:
|
||||
* - Be unambiguous, non-offensive nouns from natural categories
|
||||
* (animals, plants, geography, materials, weather).
|
||||
* - Avoid technical / political / brand words that might read as
|
||||
* odd in a chat UI.
|
||||
* - Stay reasonably small so the bundle cost is negligible (~200
|
||||
* entries × ~7 bytes ≈ 1.5 KB).
|
||||
*
|
||||
* Collisions are tolerated — the container's real unique key is its
|
||||
* ``cntr_*`` id, not its name. A short random hex suffix is appended
|
||||
* to make accidental same-name collisions visually distinct in the
|
||||
* picker list.
|
||||
*/
|
||||
|
||||
const WORDS = [
|
||||
// animals
|
||||
"otter",
|
||||
"falcon",
|
||||
"heron",
|
||||
"lynx",
|
||||
"marten",
|
||||
"stoat",
|
||||
"raven",
|
||||
"magpie",
|
||||
"salmon",
|
||||
"trout",
|
||||
"perch",
|
||||
"tortoise",
|
||||
"gecko",
|
||||
"iguana",
|
||||
"axolotl",
|
||||
"narwhal",
|
||||
"manatee",
|
||||
"dolphin",
|
||||
"porpoise",
|
||||
"octopus",
|
||||
"cuttlefish",
|
||||
"nautilus",
|
||||
"starfish",
|
||||
"urchin",
|
||||
"anemone",
|
||||
"coral",
|
||||
"puffin",
|
||||
"kestrel",
|
||||
"osprey",
|
||||
"buzzard",
|
||||
"kingfisher",
|
||||
"robin",
|
||||
"wren",
|
||||
"finch",
|
||||
"sparrow",
|
||||
"thrush",
|
||||
"siskin",
|
||||
"warbler",
|
||||
"tanager",
|
||||
"oriole",
|
||||
"hare",
|
||||
"badger",
|
||||
"weasel",
|
||||
"ferret",
|
||||
"polecat",
|
||||
"civet",
|
||||
"tapir",
|
||||
"okapi",
|
||||
"ibex",
|
||||
"chamois",
|
||||
// plants & trees
|
||||
"alder",
|
||||
"aspen",
|
||||
"birch",
|
||||
"cedar",
|
||||
"cypress",
|
||||
"elder",
|
||||
"elm",
|
||||
"fir",
|
||||
"ginkgo",
|
||||
"hawthorn",
|
||||
"hazel",
|
||||
"hemlock",
|
||||
"holly",
|
||||
"juniper",
|
||||
"larch",
|
||||
"linden",
|
||||
"maple",
|
||||
"oak",
|
||||
"olive",
|
||||
"pine",
|
||||
"rowan",
|
||||
"spruce",
|
||||
"sycamore",
|
||||
"willow",
|
||||
"yew",
|
||||
"thistle",
|
||||
"fern",
|
||||
"moss",
|
||||
"ivy",
|
||||
"clover",
|
||||
"heather",
|
||||
"lavender",
|
||||
"rosemary",
|
||||
"sage",
|
||||
"thyme",
|
||||
"myrtle",
|
||||
"laurel",
|
||||
"magnolia",
|
||||
// geography / landscape
|
||||
"harbor",
|
||||
"atoll",
|
||||
"lagoon",
|
||||
"estuary",
|
||||
"fjord",
|
||||
"delta",
|
||||
"isthmus",
|
||||
"mesa",
|
||||
"plateau",
|
||||
"valley",
|
||||
"ridge",
|
||||
"summit",
|
||||
"glade",
|
||||
"meadow",
|
||||
"moor",
|
||||
"heath",
|
||||
"tundra",
|
||||
"savanna",
|
||||
"prairie",
|
||||
"steppe",
|
||||
"bayou",
|
||||
"marsh",
|
||||
"fen",
|
||||
"grotto",
|
||||
"cavern",
|
||||
"canyon",
|
||||
"ravine",
|
||||
"gorge",
|
||||
"knoll",
|
||||
"dell",
|
||||
"vale",
|
||||
"coast",
|
||||
// materials / minerals / colors
|
||||
"amber",
|
||||
"agate",
|
||||
"onyx",
|
||||
"opal",
|
||||
"jade",
|
||||
"quartz",
|
||||
"obsidian",
|
||||
"basalt",
|
||||
"granite",
|
||||
"marble",
|
||||
"slate",
|
||||
"flint",
|
||||
"lapis",
|
||||
"topaz",
|
||||
"garnet",
|
||||
"pearl",
|
||||
"coral",
|
||||
"ivory",
|
||||
"ebony",
|
||||
"copper",
|
||||
"cobalt",
|
||||
"indigo",
|
||||
"saffron",
|
||||
"vermilion",
|
||||
"ochre",
|
||||
"umber",
|
||||
"sienna",
|
||||
"russet",
|
||||
// weather / sky / time
|
||||
"aurora",
|
||||
"comet",
|
||||
"ember",
|
||||
"frost",
|
||||
"gale",
|
||||
"harvest",
|
||||
"monsoon",
|
||||
"nebula",
|
||||
"solstice",
|
||||
"twilight",
|
||||
"zephyr",
|
||||
"drizzle",
|
||||
"tempest",
|
||||
"halcyon",
|
||||
"equinox",
|
||||
"rainbow",
|
||||
"horizon",
|
||||
"meridian",
|
||||
"zenith",
|
||||
"comet",
|
||||
// misc tactile / cozy nouns
|
||||
"lantern",
|
||||
"kettle",
|
||||
"compass",
|
||||
"anchor",
|
||||
"beacon",
|
||||
"harbor",
|
||||
"voyage",
|
||||
"trellis",
|
||||
"cottage",
|
||||
"thicket",
|
||||
"orchard",
|
||||
"bramble",
|
||||
"haystack",
|
||||
"snowfall",
|
||||
"campfire",
|
||||
];
|
||||
|
||||
/** RFC 4122-ish 4-character lowercase hex suffix using crypto.randomUUID. */
|
||||
function randomHexSuffix(): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID().replace(/-/g, "").slice(0, 4);
|
||||
}
|
||||
// Older browser fallback. Math.random is fine here — this is a
|
||||
// display suffix, not a security token.
|
||||
return Math.floor(Math.random() * 0xffff)
|
||||
.toString(16)
|
||||
.padStart(4, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single English-word name with a short random hex suffix.
|
||||
*
|
||||
* Example output: "kestrel-3f9c", "harbor-a012".
|
||||
*
|
||||
* The suffix keeps containers visually distinguishable in the picker
|
||||
* when the same word recurs across creations.
|
||||
*/
|
||||
export function pickFriendlyContainerName(): string {
|
||||
const word = WORDS[Math.floor(Math.random() * WORDS.length)] ?? "container";
|
||||
return `${word}-${randomHexSuffix()}`;
|
||||
}
|
||||
|
|
@ -121,6 +121,88 @@ export function providerSupportsBuiltinWebSearch(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the selected external provider/model exposes a server-side
|
||||
* code-execution tool. Two providers ship one today:
|
||||
*
|
||||
* - **Anthropic** (`code_execution_20250825`): Python + bash +
|
||||
* str_replace-based file edits inside a 5 GB sandboxed container
|
||||
* per request. Documented at
|
||||
* https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool
|
||||
*
|
||||
* - **OpenAI cloud** (`shell` on /v1/responses): bash inside a
|
||||
* reusable container; we auto-create one on the first turn of a
|
||||
* chat thread and reference it on subsequent turns via the
|
||||
* thread's stored `openaiCodeExecContainerId`. Documented at
|
||||
* https://developers.openai.com/api/docs/guides/tools-shell
|
||||
*
|
||||
* Returns false for every other provider. The backend additionally
|
||||
* gates the OpenAI shell tool on `is_openai_cloud` so custom
|
||||
* OpenAI-compat servers (ollama / llama.cpp / vLLM) that also report
|
||||
* `provider_type="openai"` never receive the tool — but in practice
|
||||
* none of those catalogs surface the `gpt-5.5` ids anyway, so the
|
||||
* frontend prefix match is enough.
|
||||
*
|
||||
* v1 wires the tools themselves; file uploads (Anthropic
|
||||
* `container_upload` / OpenAI `input_file`) are a deliberate follow-up.
|
||||
*/
|
||||
const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-5",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-haiku-4-5",
|
||||
// Deprecated upstream but the registry still exposes the ids, so the
|
||||
// pill should remain functional for users on those snapshots.
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-4",
|
||||
] as const;
|
||||
|
||||
// OpenAI cloud shell-tool gating. Docs only explicitly demonstrate
|
||||
// gpt-5.5; gpt-5.5-pro is included because the family share the same
|
||||
// /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix
|
||||
// match doesn't collide with a hypothetical `gpt-5.5-turbo` etc.
|
||||
const OPENAI_CODE_EXECUTION_MODEL_PREFIXES = [
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Strict check that a provider configuration points at OpenAI's
|
||||
* managed cloud (api.openai.com), as opposed to a custom OpenAI-compat
|
||||
* backend (ollama / llama.cpp / vLLM / generic "custom" preset). The
|
||||
* shell tool ONLY exists on OpenAI cloud; sending it to anything else
|
||||
* 400s the request. Mirror of the backend's
|
||||
* `is_openai_cloud = "api.openai.com" in self.base_url` guard.
|
||||
*/
|
||||
function isOpenAICloudBaseUrl(baseUrl: string | null | undefined): boolean {
|
||||
if (!baseUrl) return true; // No override → uses the default openai.com base.
|
||||
return baseUrl.trim().toLowerCase().includes("api.openai.com");
|
||||
}
|
||||
|
||||
export function providerSupportsBuiltinCodeExecution(
|
||||
providerType: string | null | undefined,
|
||||
modelId: string | null | undefined,
|
||||
baseUrl?: string | null,
|
||||
): boolean {
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
if (!normalized) return false;
|
||||
if (providerType === "anthropic") {
|
||||
return ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) =>
|
||||
normalized.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
if (providerType === "openai") {
|
||||
if (!isOpenAICloudBaseUrl(baseUrl)) return false;
|
||||
return OPENAI_CODE_EXECUTION_MODEL_PREFIXES.some((prefix) =>
|
||||
normalized.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider minimum on the outbound max_tokens. Kimi's docs require
|
||||
* `max_tokens >= 16000` whenever a thinking model is in use so the
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ function toThreadMessage(m: MessageRecord): ThreadMessage {
|
|||
};
|
||||
}
|
||||
|
||||
async function ensureThreadRecord({
|
||||
export async function ensureThreadRecord({
|
||||
threadId,
|
||||
modelType,
|
||||
pairId,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ import {
|
|||
type ReasoningEffort,
|
||||
useChatRuntimeStore,
|
||||
} from "./stores/chat-runtime-store";
|
||||
import { getExternalReasoningCapabilities } from "./provider-capabilities";
|
||||
import {
|
||||
getExternalReasoningCapabilities,
|
||||
providerSupportsBuiltinCodeExecution,
|
||||
} from "./provider-capabilities";
|
||||
import {
|
||||
type CompositionEvent,
|
||||
type KeyboardEvent,
|
||||
|
|
@ -367,13 +370,21 @@ export function SharedComposer({
|
|||
// Two-pill gating: Search pill lights up when the runtime has either
|
||||
// a local tool runtime (supportsTools, gives us our Code/python + local
|
||||
// web_search) OR a server-side web_search the provider runs for us
|
||||
// (supportsBuiltinWebSearch, currently just OpenAI's /v1/responses).
|
||||
// Code pill is gated on `supportsTools` only — external providers
|
||||
// never give us code execution, so the pill must stay disabled even
|
||||
// when Search is available.
|
||||
// (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter
|
||||
// / Kimi). Code pill lights up on the local runtime OR when Anthropic
|
||||
// is selected with a model that accepts the server-side
|
||||
// code_execution_20250825 tool — see
|
||||
// providerSupportsBuiltinCodeExecution. Anthropic is the only external
|
||||
// provider that ships a code-execution tool today.
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
selectedExternalProvider?.providerType,
|
||||
effectiveExternalModelId,
|
||||
selectedExternalProvider?.baseUrl,
|
||||
);
|
||||
const searchDisabled =
|
||||
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
|
||||
const codeDisabled = !modelLoaded || !supportsTools;
|
||||
const codeDisabled =
|
||||
!modelLoaded || !(supportsTools || supportsBuiltinCodeExecution);
|
||||
// Backwards-compatible alias for any other call site that may still
|
||||
// reference `toolsDisabled` (rare; both pills used it before).
|
||||
const toolsDisabled = codeDisabled;
|
||||
|
|
|
|||
|
|
@ -234,11 +234,19 @@ type ChatRuntimeStore = {
|
|||
* web_search tool (OpenAI's /v1/responses today). Distinct from
|
||||
* `supportsTools` — that flag governs the local tool runtime (Code,
|
||||
* python sandbox, our DuckDuckGo web_search). This one only enables
|
||||
* the chat composer's Search pill for external models and leaves
|
||||
* the Code pill disabled, because external providers do not give
|
||||
* us code execution. Local models keep `supportsTools` only.
|
||||
* the chat composer's Search pill for external models. Local models
|
||||
* keep `supportsTools` only.
|
||||
*/
|
||||
supportsBuiltinWebSearch: boolean;
|
||||
/**
|
||||
* Whether the active external provider exposes a server-side
|
||||
* code-execution tool (Anthropic's `code_execution_20250825` on the
|
||||
* Claude 4.x family). Distinct from `supportsTools` for the same
|
||||
* reason as `supportsBuiltinWebSearch`: external providers don't
|
||||
* give us a local tool runtime, but Anthropic dispatches code
|
||||
* execution server-side. Read by both composers' Code pill gate.
|
||||
*/
|
||||
supportsBuiltinCodeExecution: boolean;
|
||||
toolsEnabled: boolean;
|
||||
codeToolsEnabled: boolean;
|
||||
toolStatus: string | null;
|
||||
|
|
@ -331,6 +339,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
preserveThinking: loadBool(PRESERVE_THINKING_KEY, false),
|
||||
supportsTools: false,
|
||||
supportsBuiltinWebSearch: false,
|
||||
supportsBuiltinCodeExecution: false,
|
||||
toolsEnabled: false,
|
||||
codeToolsEnabled: false,
|
||||
toolStatus: null,
|
||||
|
|
@ -442,6 +451,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
supportsPreserveThinking: false,
|
||||
supportsTools: false,
|
||||
supportsBuiltinWebSearch: false,
|
||||
supportsBuiltinCodeExecution: false,
|
||||
toolsEnabled: false,
|
||||
codeToolsEnabled: false,
|
||||
toolStatus: null,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,22 @@ export interface ThreadRecord {
|
|||
pairId?: string;
|
||||
archived: boolean;
|
||||
createdAt: number;
|
||||
/**
|
||||
* OpenAI shell tool container id captured from a prior response on
|
||||
* this thread. When set, the next turn reuses it via
|
||||
* `environment.type="container_reference"` so the model can read
|
||||
* files it wrote earlier in the conversation. When null/undefined,
|
||||
* the next turn auto-creates a fresh container.
|
||||
*
|
||||
* OpenAI containers expire after ~20 min of inactivity by default;
|
||||
* 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;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
|
|
|
|||
|
|
@ -225,6 +225,16 @@ export interface OpenAIChatCompletionsRequest {
|
|||
encrypted_api_key?: string;
|
||||
provider_base_url?: string | null;
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/**
|
||||
* OpenAI shell-tool container id captured from the prior response in
|
||||
* this chat thread. When set and the Code pill is on, the backend
|
||||
* routes the next /v1/responses call with
|
||||
* `environment.type="container_reference"` so filesystem state
|
||||
* persists across turns. Unset → backend uses
|
||||
* `environment.type="container_auto"` and OpenAI creates a fresh
|
||||
* container. Only meaningful for OpenAI cloud + gpt-5.5 family.
|
||||
*/
|
||||
openai_code_exec_container_id?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenAIChatDelta {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue